efficiency

This commit is contained in:
barrulus 2025-09-04 10:13:01 +01:00
parent 20dfb7cfcb
commit 21df872ca2
7 changed files with 466 additions and 210 deletions

43
main.js
View file

@ -1219,25 +1219,42 @@ function updateSuitabilityFromHubs() {
const hubs = burgs.filter(b => b.capital || b.port).map(b => b.cell);
if (!hubs.length) return;
const cellCoords = cells.p;
// Multi-source Dijkstra to compute min distance (in pixels) from any hub to every cell
const n = cells.i.length;
const distArr = new Float32Array(n);
for (let i = 0; i < n; i++) distArr[i] = Infinity;
const q = new FlatQueue();
for (const hub of hubs) {
distArr[hub] = 0;
q.push(hub, 0);
}
while (q.length) {
const d0 = q.peekValue();
const u = q.pop();
if (d0 !== distArr[u]) continue;
const [ux, uy] = cells.p[u];
const neigh = cells.c[u];
for (let k = 0; k < neigh.length; k++) {
const v = neigh[k];
// skip water for suitability
if (cells.h[v] < 20) continue;
const [vx, vy] = cells.p[v];
const w = Math.hypot(vx - ux, vy - uy);
const nd = d0 + w;
if (nd < distArr[v]) {
distArr[v] = nd;
q.push(v, nd);
}
}
}
const maxSuitability = 100;
const decayRate = 0.02;
const areaMean = d3.mean(cells.area);
function minDistToHub(cellId) {
const [x, y] = cellCoords[cellId];
let minDist = Infinity;
for (const hubCell of hubs) {
const [hx, hy] = cellCoords[hubCell];
const dist = Math.hypot(x - hx, y - hy);
if (dist < minDist) minDist = dist;
}
return minDist;
}
for (const i of cells.i) {
if (cells.h[i] < 20) continue;
const dist = minDistToHub(i);
const dist = distArr[i];
let s = maxSuitability * Math.exp(-decayRate * dist);
s *= +biomesData.habitability[cells.biome[i]] / 100;
cells.s[i] = s;

View file

@ -862,7 +862,8 @@ window.BurgsAndStates = (() => {
cells.state = cells.state || new Uint16Array(cells.i.length);
const queue = new FlatQueue();
const cost = [];
const cost = new Float32Array(cells.i.length);
cost.fill(Infinity);
const globalGrowthRate = byId("growthRate").valueAsNumber || 1;
const statesGrowthRate = byId("statesGrowthRate")?.valueAsNumber || 1;
@ -889,31 +890,39 @@ window.BurgsAndStates = (() => {
while (queue.length) {
const next = queue.pop();
const {e, p, s, b} = next;
const {type, culture} = states[s];
const e0 = next.e;
const p0 = next.p;
const s0 = next.s;
const b0 = next.b;
const st = states[s0];
const type = st.type;
const culture = st.culture;
cells.c[e].forEach(e => {
const state = states[cells.state[e]];
if (state.lock) return; // do not overwrite cell of locked states
if (cells.state[e] && e === state.center) return; // do not overwrite capital cells
const neigh = cells.c[e0];
for (let idx = 0; idx < neigh.length; idx++) {
const e = neigh[idx];
const stateOnCell = states[cells.state[e]];
if (stateOnCell.lock) continue; // do not overwrite cell of locked states
if (cells.state[e] && e === stateOnCell.center) continue; // do not overwrite capital cells
const cultureCost = culture === cells.culture[e] ? -9 : 100;
const populationCost = cells.h[e] < 20 ? 0 : cells.s[e] ? Math.max(20 - cells.s[e], 0) : 5000;
const biomeCost = getBiomeCost(b, cells.biome[e], type);
const sVal = cells.s[e];
const populationCost = cells.h[e] < 20 ? 0 : sVal ? Math.max(20 - sVal, 0) : 5000;
const biomeCost = getBiomeCost(b0, cells.biome[e], type);
const heightCost = getHeightCost(pack.features[cells.f[e]], cells.h[e], type);
const riverCost = getRiverCost(cells.r[e], e, type);
const typeCost = getTypeCost(cells.t[e], type);
const cellCost = Math.max(cultureCost + populationCost + biomeCost + heightCost + riverCost + typeCost, 0);
const totalCost = p + 10 + cellCost / states[s].expansionism;
const totalCost = p0 + 10 + cellCost / st.expansionism;
if (totalCost > growthRate) return;
if (totalCost > growthRate) continue;
if (!cost[e] || totalCost < cost[e]) {
if (cells.h[e] >= 20) cells.state[e] = s; // assign state to cell
if (totalCost < cost[e]) {
if (cells.h[e] >= 20) cells.state[e] = s0; // assign state to cell
cost[e] = totalCost;
queue.push({e, p: totalCost, s, b}, totalCost);
queue.push({e, p: totalCost, s: s0, b: b0}, totalCost);
}
});
}
}
burgs.filter(b => b.i && !b.removed).forEach(b => (b.state = cells.state[b.cell])); // assign state to burgs

View file

@ -134,7 +134,10 @@ window.HeightmapGenerator = (function () {
}
function addOneHill() {
const change = new Uint8Array(heights.length);
// reuse a scratch buffer to avoid reallocations
if (!addHill._change || addHill._change.length !== heights.length) addHill._change = new Uint8Array(heights.length);
const change = addHill._change;
change.fill(0);
let limit = 0;
let start;
let h = lim(getNumberInRange(height));
@ -148,17 +151,18 @@ window.HeightmapGenerator = (function () {
change[start] = h;
const queue = [start];
while (queue.length) {
const q = queue.shift();
for (const c of grid.cells.c[q]) {
for (let qi = 0; qi < queue.length; qi++) {
const q = queue[qi];
const neibs = grid.cells.c[q];
for (let k = 0; k < neibs.length; k++) {
const c = neibs[k];
if (change[c]) continue;
change[c] = change[q] ** blobPower * (Math.random() * 0.2 + 0.9);
if (change[c] > 1) queue.push(c);
}
}
heights = heights.map((h, i) => lim(h + change[i]));
for (let i = 0; i < heights.length; i++) heights[i] = lim(heights[i] + change[i]);
}
};
@ -170,7 +174,9 @@ window.HeightmapGenerator = (function () {
}
function addOnePit() {
const used = new Uint8Array(heights.length);
if (!addPit._used || addPit._used.length !== heights.length) addPit._used = new Uint8Array(heights.length);
const used = addPit._used;
used.fill(0);
let limit = 0,
start;
let h = lim(getNumberInRange(height));
@ -183,17 +189,18 @@ window.HeightmapGenerator = (function () {
} while (heights[start] < 20 && limit < 50);
const queue = [start];
while (queue.length) {
const q = queue.shift();
for (let qi = 0; qi < queue.length; qi++) {
const q = queue[qi];
h = h ** blobPower * (Math.random() * 0.2 + 0.9);
if (h < 1) return;
grid.cells.c[q].forEach(function (c, i) {
if (used[c]) return;
const neibs = grid.cells.c[q];
for (let k = 0; k < neibs.length; k++) {
const c = neibs[k];
if (used[c]) continue;
heights[c] = lim(heights[c] - h * (Math.random() * 0.2 + 0.9));
used[c] = 1;
queue.push(c);
});
}
}
}
};
@ -207,7 +214,9 @@ window.HeightmapGenerator = (function () {
}
function addOneRange() {
const used = new Uint8Array(heights.length);
if (!addRange._used || addRange._used.length !== heights.length) addRange._used = new Uint8Array(heights.length);
const used = addRange._used;
used.fill(0);
let h = lim(getNumberInRange(height));
if (rangeX && rangeY) {
@ -259,35 +268,44 @@ window.HeightmapGenerator = (function () {
}
// add height to ridge and cells around
let queue = range.slice(),
i = 0;
let queue = range.slice();
let i = 0;
while (queue.length) {
const frontier = queue.slice();
(queue = []), i++;
frontier.forEach(i => {
heights[i] = lim(heights[i] + h * (Math.random() * 0.3 + 0.85));
});
queue.length = 0; i++;
for (let fi = 0; fi < frontier.length; fi++) {
const idx = frontier[fi];
heights[idx] = lim(heights[idx] + h * (Math.random() * 0.3 + 0.85));
}
h = h ** linePower - 1;
if (h < 2) break;
frontier.forEach(f => {
grid.cells.c[f].forEach(i => {
if (!used[i]) {
queue.push(i);
used[i] = 1;
}
});
});
for (let fi = 0; fi < frontier.length; fi++) {
const f = frontier[fi];
const neibs = grid.cells.c[f];
for (let k = 0; k < neibs.length; k++) {
const idx = neibs[k];
if (!used[idx]) { queue.push(idx); used[idx] = 1; }
}
}
}
// generate prominences
range.forEach((cur, d) => {
if (d % 6 !== 0) return;
for (const l of d3.range(i)) {
const min = grid.cells.c[cur][d3.scan(grid.cells.c[cur], (a, b) => heights[a] - heights[b])]; // downhill cell
heights[min] = (heights[cur] * 2 + heights[min]) / 3;
cur = min;
for (let d = 0; d < range.length; d++) {
if (d % 6 !== 0) continue;
let cur = range[d];
for (let l = 0; l < i; l++) {
const nbrs = grid.cells.c[cur];
let minIdx = nbrs[0];
let minVal = heights[minIdx];
for (let k = 1; k < nbrs.length; k++) {
const idk = nbrs[k];
const hv = heights[idk];
if (hv < minVal) { minVal = hv; minIdx = idk; }
}
heights[minIdx] = (heights[cur] * 2 + heights[minIdx]) / 3;
cur = minIdx;
}
});
}
}
};
@ -299,7 +317,9 @@ window.HeightmapGenerator = (function () {
}
function addOneTrough() {
const used = new Uint8Array(heights.length);
if (!addTrough._used || addTrough._used.length !== heights.length) addTrough._used = new Uint8Array(heights.length);
const used = addTrough._used;
used.fill(0);
let h = lim(getNumberInRange(height));
if (rangeX && rangeY) {
@ -356,36 +376,44 @@ window.HeightmapGenerator = (function () {
}
// add height to ridge and cells around
let queue = range.slice(),
i = 0;
let queue = range.slice();
let i = 0;
while (queue.length) {
const frontier = queue.slice();
(queue = []), i++;
frontier.forEach(i => {
heights[i] = lim(heights[i] - h * (Math.random() * 0.3 + 0.85));
});
queue.length = 0; i++;
for (let fi = 0; fi < frontier.length; fi++) {
const idx = frontier[fi];
heights[idx] = lim(heights[idx] - h * (Math.random() * 0.3 + 0.85));
}
h = h ** linePower - 1;
if (h < 2) break;
frontier.forEach(f => {
grid.cells.c[f].forEach(i => {
if (!used[i]) {
queue.push(i);
used[i] = 1;
}
});
});
for (let fi = 0; fi < frontier.length; fi++) {
const f = frontier[fi];
const neibs = grid.cells.c[f];
for (let k = 0; k < neibs.length; k++) {
const idx = neibs[k];
if (!used[idx]) { queue.push(idx); used[idx] = 1; }
}
}
}
// generate prominences
range.forEach((cur, d) => {
if (d % 6 !== 0) return;
for (const l of d3.range(i)) {
const min = grid.cells.c[cur][d3.scan(grid.cells.c[cur], (a, b) => heights[a] - heights[b])]; // downhill cell
//debug.append("circle").attr("cx", p[min][0]).attr("cy", p[min][1]).attr("r", 1);
heights[min] = (heights[cur] * 2 + heights[min]) / 3;
cur = min;
for (let d = 0; d < range.length; d++) {
if (d % 6 !== 0) continue;
let cur = range[d];
for (let l = 0; l < i; l++) {
const nbrs = grid.cells.c[cur];
let minIdx = nbrs[0];
let minVal = heights[minIdx];
for (let k = 1; k < nbrs.length; k++) {
const idk = nbrs[k];
const hv = heights[idk];
if (hv < minVal) { minVal = hv; minIdx = idk; }
}
heights[minIdx] = (heights[cur] * 2 + heights[minIdx]) / 3;
cur = minIdx;
}
});
}
}
};
@ -452,37 +480,39 @@ window.HeightmapGenerator = (function () {
const max = range === "land" || range === "all" ? 100 : +range.split("-")[1];
const isLand = min === 20;
heights = heights.map(h => {
if (h < min || h > max) return h;
for (let i = 0; i < heights.length; i++) {
let h = heights[i];
if (h < min || h > max) continue;
if (add) h = isLand ? Math.max(h + add, 20) : h + add;
if (mult !== 1) h = isLand ? (h - 20) * mult + 20 : h * mult;
if (power) h = isLand ? (h - 20) ** power + 20 : h ** power;
return lim(h);
});
heights[i] = lim(h);
}
};
const smooth = (fr = 2, add = 0) => {
heights = heights.map((h, i) => {
const a = [h];
grid.cells.c[i].forEach(c => a.push(heights[c]));
if (fr === 1) return d3.mean(a) + add;
return lim((h * (fr - 1) + d3.mean(a) + add) / fr);
});
for (let i = 0; i < heights.length; i++) {
const h = heights[i];
const nbrs = grid.cells.c[i];
let sum = h;
for (let k = 0; k < nbrs.length; k++) sum += heights[nbrs[k]];
const mean = sum / (nbrs.length + 1);
heights[i] = fr === 1 ? mean + add : lim((h * (fr - 1) + mean + add) / fr);
}
};
const mask = (power = 1) => {
const fr = power ? Math.abs(power) : 1;
heights = heights.map((h, i) => {
for (let i = 0; i < heights.length; i++) {
const h = heights[i];
const [x, y] = grid.points[i];
const nx = (2 * x) / graphWidth - 1; // [-1, 1], 0 is center
const ny = (2 * y) / graphHeight - 1; // [-1, 1], 0 is center
let distance = (1 - nx ** 2) * (1 - ny ** 2); // 1 is center, 0 is edge
if (power < 0) distance = 1 - distance; // inverted, 0 is center, 1 is edge
const nx = (2 * x) / graphWidth - 1; // [-1, 1]
const ny = (2 * y) / graphHeight - 1; // [-1, 1]
let distance = (1 - nx * nx) * (1 - ny * ny);
if (power < 0) distance = 1 - distance;
const masked = h * distance;
return lim((h * (fr - 1) + masked) / fr);
});
heights[i] = lim((h * (fr - 1) + masked) / fr);
}
};
const invert = (count, axes) => {

View file

@ -21,10 +21,16 @@ const ROUTE_TIER_MODIFIERS = {
};
window.Routes = (function () {
// Per-cell cost cache for fast path evaluations
let RC = null;
function generate(lockedRoutes = []) {
TIME && console.time("generateRoutes");
const {capitalsByFeature, burgsByFeature, portsByFeature, primaryByFeature, plazaByFeature, unconnectedBurgsByFeature} = sortBurgsByFeature(pack.burgs);
// Build per-cell route cost factors once
RC = buildRouteCostCache();
// connections: Map<cellId, Set<neighborCellId>> for O(1) adjacency checks without string keys
const connections = new Map();
lockedRoutes.forEach(route => addConnections(route.points.map(p => p[2])));
@ -94,6 +100,36 @@ window.Routes = (function () {
return {burgsByFeature, capitalsByFeature, portsByFeature, primaryByFeature, plazaByFeature, unconnectedBurgsByFeature};
}
// Precompute per-cell route modifiers to avoid hot-path branching
function buildRouteCostCache() {
const {cells} = pack;
const n = cells.i.length;
const landHabitability = new Float32Array(n);
const landHeight = new Float32Array(n);
const isPassableLand = new Uint8Array(n);
const waterType = new Float32Array(n);
const isPassableWater = new Uint8Array(n);
const stateId = new Uint32Array(n);
const burgFactor = new Float32Array(n);
for (let i = 0; i < n; i++) {
const h = pack.cells.h[i];
const hab = biomesData.habitability[pack.cells.biome[i]];
isPassableLand[i] = h >= 20 && hab > 0 ? 1 : 0;
landHabitability[i] = 1 + Math.max(100 - hab, 0) / 1000;
landHeight[i] = 1 + Math.max(h - 25, 25) / 25;
stateId[i] = pack.cells.state[i] >>> 0;
burgFactor[i] = pack.cells.burg[i] ? 1 : 3;
const t = pack.cells.t[i];
waterType[i] = ROUTE_TYPE_MODIFIERS[t] || ROUTE_TYPE_MODIFIERS.default;
const temp = grid.cells.temp[pack.cells.g[i]];
isPassableWater[i] = h < 20 && temp >= MIN_PASSABLE_SEA_TEMP ? 1 : 0;
}
return {landHabitability, landHeight, isPassableLand, waterType, isPassableWater, stateId, burgFactor};
}
// Tier 1: Major Sea Routes - Connect capitals and major ports across ALL water bodies
// Simulates long-distance maritime trade like Hanseatic League routes
function generateMajorSeaRoutes() {
@ -101,18 +137,30 @@ window.Routes = (function () {
const majorSeaRoutes = [];
// Get all significant ports for major trade routes
const allMajorPorts = [];
let allMajorPorts = [];
pack.burgs.forEach(b => {
if (b.i && !b.removed && b.port) {
// Include more ports in major routes: capitals, large ports, and wealthy market towns
if (b.capital ||
b.isLargePort ||
(b.population >= 5 && b.plaza) || // Major market towns (5000+ pop with plaza)
(b.population >= 10)) { // Large cities regardless of status
if (!b.i || b.removed) return;
if (b.port) {
if (b.capital || b.isLargePort || (b.population >= 5 && b.plaza) || (b.population >= 10)) {
allMajorPorts.push(b);
}
}
});
// Fallback: if there are <2 declared ports (e.g., single-port water bodies),
// consider coastal capitals/markets as pseudo-ports to ensure some sea routes.
if (allMajorPorts.length < 2) {
const coastalCandidates = pack.burgs.filter(b => {
if (!b.i || b.removed) return false;
const cell = b.cell;
const coastal = pack.cells.t[cell] === 1; // coastline
const tempOK = grid.cells.temp[pack.cells.g[cell]] >= MIN_PASSABLE_SEA_TEMP;
return coastal && tempOK && (b.capital || b.plaza || b.isLargePort || b.population >= 5);
});
// take up to 12 best by importance
coastalCandidates.sort((a, b) => (b.capital - a.capital) || (b.population - a.population));
allMajorPorts = coastalCandidates.slice(0, Math.min(12, coastalCandidates.length));
}
if (allMajorPorts.length < 2) {
TIME && console.timeEnd("generateMajorSeaRoutes");
@ -133,39 +181,38 @@ window.Routes = (function () {
const mediumPorts = allMajorPorts.filter(p => !p.capital && !p.isLargePort && p.population < 10);
// Use all capitals and top large ports as primary hubs
const hubs = [...capitalPorts, ...largePorts.slice(0, Math.max(10, Math.floor(largePorts.length * 0.5)))];
let hubs = [...capitalPorts, ...largePorts.slice(0, Math.max(10, Math.floor(largePorts.length * 0.5)))];
if (hubs.length < 2) hubs = [...largePorts.slice(0, Math.min(20, largePorts.length))];
const secondaryHubs = [...largePorts.slice(Math.max(10, Math.floor(largePorts.length * 0.5))), ...mediumPorts.slice(0, 20)];
// Connect primary hubs strategically (not all-to-all to avoid too many routes)
// Connect capitals to each other
for (let i = 0; i < capitalPorts.length; i++) {
for (let j = i + 1; j < capitalPorts.length; j++) {
const start = capitalPorts[i].cell;
const exit = capitalPorts[j].cell;
const distance = Math.sqrt((capitalPorts[i].x - capitalPorts[j].x) ** 2 + (capitalPorts[i].y - capitalPorts[j].y) ** 2);
// Connect if reasonably distant (long-distance trade) or same cultural sphere
if (distance > 50 || capitalPorts[i].culture === capitalPorts[j].culture) {
const segments = findPathSegments({isWater: true, connections, start, exit, routeType: "majorSea"});
for (const segment of segments) {
addConnections(segment);
majorSeaRoutes.push({feature: -1, cells: segment, type: "majorSea"});
}
// Connect primary hubs strategically using sparse graph (Urquhart edges)
if (hubs.length >= 2) {
const points = hubs.map(p => [p.x, p.y]);
const edges = calculateUrquhartEdges(points);
edges.forEach(([ai, bi]) => {
const a = hubs[ai];
const b = hubs[bi];
const start = a.cell;
const exit = b.cell;
const segments = findPathSegments({isWater: true, connections, start, exit, routeType: "majorSea"});
for (const segment of segments) {
addConnections(segment);
majorSeaRoutes.push({feature: -1, cells: segment, type: "majorSea"});
}
}
});
}
// Connect large ports to nearest 2-3 capitals for trade network
// Connect large ports to nearest 1-2 hubs for trade network
largePorts.slice(0, 15).forEach(port => {
const nearestCapitals = capitalPorts
const nearestHubs = hubs
.map(cap => ({
cap,
distance: Math.sqrt((port.x - cap.x) ** 2 + (port.y - cap.y) ** 2)
distance2: (port.x - cap.x) ** 2 + (port.y - cap.y) ** 2
}))
.sort((a, b) => a.distance - b.distance)
.slice(0, Math.min(3, capitalPorts.length)); // Connect to up to 3 nearest capitals
.sort((a, b) => a.distance2 - b.distance2)
.slice(0, Math.min(2, hubs.length)); // Connect to up to 2 nearest hubs
nearestCapitals.forEach(({cap}) => {
nearestHubs.forEach(({cap}) => {
const segments = findPathSegments({
isWater: true,
connections,
@ -186,9 +233,9 @@ window.Routes = (function () {
let minDistance = Infinity;
hubs.forEach(hub => {
const distance = Math.sqrt((port.x - hub.x) ** 2 + (port.y - hub.y) ** 2);
if (distance < minDistance) {
minDistance = distance;
const dx = port.x - hub.x; const dy = port.y - hub.y; const d2 = dx*dx + dy*dy;
if (d2 < minDistance) {
minDistance = d2;
nearestHub = hub;
}
});
@ -238,10 +285,9 @@ window.Routes = (function () {
const edges = [];
for (let i = 0; i < capitals.length; i++) {
for (let j = i + 1; j < capitals.length; j++) {
const distance = Math.sqrt(
(capitals[i].x - capitals[j].x) ** 2 +
(capitals[i].y - capitals[j].y) ** 2
);
const dx = (capitals[i].x - capitals[j].x);
const dy = (capitals[i].y - capitals[j].y);
const distance = dx*dx + dy*dy; // squared distance is sufficient for sorting
edges.push({
from: i,
to: j,
@ -382,30 +428,30 @@ window.Routes = (function () {
);
// Connect each village to nearest market center
const mapScaleLocal = Math.sqrt(graphWidth * graphHeight / 1000000);
const maxLocalKm = 60; // cap local road reach
const maxLocalDist2 = (maxLocalKm * mapScaleLocal) ** 2;
const radiusPx = maxLocalKm * mapScaleLocal;
const hashMarkets = makeSpatialHash(marketCenters, b => [b.x, b.y], radiusPx);
villages.forEach(village => {
let nearestMarket = null;
let minDistance = Infinity;
marketCenters.forEach(market => {
const distance = Math.sqrt(
(village.x - market.x) ** 2 +
(village.y - market.y) ** 2
);
const candidates = queryCandidatesWithinRadius(hashMarkets, village.x, village.y, radiusPx);
for (const market of candidates) {
const dx = village.x - market.x;
const dy = village.y - market.y;
const d2 = dx*dx + dy*dy;
// Prefer markets in same state/culture
let culturalModifier = 1;
if (village.state === market.state) culturalModifier = 0.8;
if (village.culture === market.culture) culturalModifier *= 0.9;
const adjustedDistance = distance * culturalModifier;
if (adjustedDistance < minDistance) {
minDistance = adjustedDistance;
nearestMarket = market;
}
});
const adjustedDistance = d2 * culturalModifier;
if (adjustedDistance < minDistance) { minDistance = adjustedDistance; nearestMarket = market; }
}
if (nearestMarket) {
if (nearestMarket && minDistance <= maxLocalDist2) {
const segments = findPathSegments({
isWater: false,
connections,
@ -415,6 +461,10 @@ window.Routes = (function () {
});
for (const segment of segments) {
// Skip excessively long local segments
const pxLen = pathCellsLengthPx(segment);
const kmLen = pxLen / mapScaleLocal;
if (kmLen > maxLocalKm) continue;
addConnections(segment);
localRoads.push({
feature: village.feature,
@ -450,33 +500,27 @@ window.Routes = (function () {
)
);
// Connect each hamlet to nearest village (3-6 km as per research)
// Connect each hamlet to nearest village (limit to <= 8 km)
const mapScaleFoot = Math.sqrt(graphWidth * graphHeight / 1000000);
const maxFootKm = 8;
const maxFootDist2 = (maxFootKm * mapScaleFoot) ** 2;
const radiusFootPx = maxFootKm * mapScaleFoot;
const hashSettlements = makeSpatialHash(largerSettlements, b => [b.x, b.y], radiusFootPx);
hamlets.forEach(hamlet => {
let nearestVillage = null;
let minDistance = Infinity;
largerSettlements.forEach(village => {
const distance = Math.sqrt(
(hamlet.x - village.x) ** 2 +
(hamlet.y - village.y) ** 2
);
// Strong preference for same culture/state
const candidates = queryCandidatesWithinRadius(hashSettlements, hamlet.x, hamlet.y, radiusFootPx);
for (const village of candidates) {
const dx = hamlet.x - village.x;
const dy = hamlet.y - village.y;
const d2 = dx*dx + dy*dy;
let modifier = 1;
if (hamlet.state === village.state) modifier = 0.7;
if (hamlet.culture === village.culture) modifier *= 0.8;
const adjustedDistance = distance * modifier;
// Only connect to nearby settlements (6 km max range)
const mapScale = Math.sqrt(graphWidth * graphHeight / 1000000);
const kmDistance = distance / mapScale;
if (kmDistance <= 8 && adjustedDistance < minDistance) {
minDistance = adjustedDistance;
nearestVillage = village;
}
});
const adjustedDistance = d2 * modifier;
if (d2 <= maxFootDist2 && adjustedDistance < minDistance) { minDistance = adjustedDistance; nearestVillage = village; }
}
if (nearestVillage) {
const segments = findPathSegments({
@ -488,6 +532,9 @@ window.Routes = (function () {
});
for (const segment of segments) {
const pxLen = pathCellsLengthPx(segment);
const kmLen = pxLen / mapScaleFoot;
if (kmLen > maxFootKm) continue;
addConnections(segment);
footpaths.push({
feature: hamlet.feature,
@ -694,11 +741,11 @@ window.Routes = (function () {
// First, try connecting to the nearest connected burg
for (const connectedBurg of connectedBurgs) {
const distance = Math.sqrt(
(unconnectedBurg.x - connectedBurg.x) ** 2 + (unconnectedBurg.y - connectedBurg.y) ** 2
);
if (distance < minDistance) {
minDistance = distance;
const dx = unconnectedBurg.x - connectedBurg.x;
const dy = unconnectedBurg.y - connectedBurg.y;
const d2 = dx * dx + dy * dy;
if (d2 < minDistance) {
minDistance = d2;
bestConnection = connectedBurg;
}
}
@ -730,19 +777,38 @@ window.Routes = (function () {
}
function addConnections(segment) {
for (let i = 0; i < segment.length; i++) {
const cellId = segment[i];
const nextCellId = segment[i + 1];
if (nextCellId) {
connections.set(`${cellId}-${nextCellId}`, true);
connections.set(`${nextCellId}-${cellId}`, true);
}
for (let i = 0; i < segment.length - 1; i++) {
const a = segment[i];
const b = segment[i + 1];
let setA = connections.get(a);
if (!setA) { setA = new Set(); connections.set(a, setA); }
setA.add(b);
let setB = connections.get(b);
if (!setB) { setB = new Set(); connections.set(b, setB); }
setB.add(a);
}
}
function hasConnection(a, b) {
const s = connections.get(a);
return s ? s.has(b) : false;
}
function findPathSegments({isWater, connections, start, exit, routeType}) {
const getCost = createCostEvaluator({isWater, connections, routeType});
const pathCells = findPath(start, current => current === exit, getCost);
const heuristicScale = getHeuristicScale(routeType, isWater);
const heuristic = (node) => {
const [ax, ay] = pack.cells.p[node];
const [bx, by] = pack.cells.p[exit];
const dx = ax - bx, dy = ay - by;
const d = Math.hypot(dx, dy);
return d * heuristicScale;
};
let pathCells = findPathAStar(start, exit, getCost, heuristic);
if (!pathCells) {
// Fallback to Dijkstra if A* fails or exceeds caps
pathCells = findPath(start, current => current === exit, getCost);
}
if (!pathCells) return [];
const segments = getRouteSegments(pathCells, connections);
return segments;
@ -859,20 +925,53 @@ window.Routes = (function () {
}
}
// Simple spatial hash for fast radius queries
function makeSpatialHash(items, getXY, binSize) {
const bins = new Map();
for (const it of items) {
const [x, y] = getXY(it);
const ix = Math.floor(x / binSize);
const iy = Math.floor(y / binSize);
const key = ix + "," + iy;
let arr = bins.get(key);
if (!arr) { arr = []; bins.set(key, arr); }
arr.push(it);
}
return {bins, binSize};
}
function queryCandidatesWithinRadius(hash, x, y, radius) {
const out = [];
const s = hash.binSize;
const r = Math.ceil(radius / s);
const ix0 = Math.floor((x - radius) / s);
const iy0 = Math.floor((y - radius) / s);
const ix1 = Math.floor((x + radius) / s);
const iy1 = Math.floor((y + radius) / s);
for (let ix = ix0; ix <= ix1; ix++) {
for (let iy = iy0; iy <= iy1; iy++) {
const arr = hash.bins.get(ix + "," + iy);
if (!arr) continue;
for (const it of arr) out.push(it);
}
}
return out;
}
function createCostEvaluator({isWater, connections, routeType = "market"}) {
return isWater ? getWaterPathCost : getLandPathCost;
function getLandPathCost(current, next) {
if (pack.cells.h[next] < 20) return Infinity; // ignore water cells
if (!RC || !RC.isPassableLand[next]) return Infinity;
const habitability = biomesData.habitability[pack.cells.biome[next]];
if (!habitability) return Infinity; // inhabitable cells are not passable (e.g. glacier)
const distanceCost = dist2(pack.cells.p[current], pack.cells.p[next]);
const habitabilityModifier = 1 + Math.max(100 - habitability, 0) / 1000; // [1, 1.1];
const heightModifier = 1 + Math.max(pack.cells.h[next] - 25, 25) / 25; // [1, 3];
const connectionModifier = connections.has(`${current}-${next}`) ? 0.5 : 1;
const burgModifier = pack.cells.burg[next] ? 1 : 3;
const [ax, ay] = pack.cells.p[current];
const [bx, by] = pack.cells.p[next];
const distanceCost = Math.hypot(ax - bx, ay - by);
const habitabilityModifier = RC.landHabitability[next];
const heightModifier = RC.landHeight[next];
const setA = connections.get(current);
const connectionModifier = setA && setA.has(next) ? 0.5 : 1;
const burgModifier = RC.burgFactor[next];
// Medieval travel constraints
const riverCrossingPenalty = pack.cells.r[next] && !pack.cells.burg[next] ? 1.5 : 1; // Bridges rare except at settlements
@ -887,12 +986,14 @@ window.Routes = (function () {
}
function getWaterPathCost(current, next) {
if (pack.cells.h[next] >= 20) return Infinity; // ignore land cells
if (grid.cells.temp[pack.cells.g[next]] < MIN_PASSABLE_SEA_TEMP) return Infinity; // ignore too cold cells
if (!RC || !RC.isPassableWater[next]) return Infinity;
const distanceCost = dist2(pack.cells.p[current], pack.cells.p[next]);
const typeModifier = ROUTE_TYPE_MODIFIERS[pack.cells.t[next]] || ROUTE_TYPE_MODIFIERS.default;
const connectionModifier = connections.has(`${current}-${next}`) ? 0.5 : 1;
const [ax, ay] = pack.cells.p[current];
const [bx, by] = pack.cells.p[next];
const distanceCost = Math.hypot(ax - bx, ay - by);
const typeModifier = RC.waterType[next];
const setA = connections.get(current);
const connectionModifier = setA && setA.has(next) ? 0.5 : 1;
// Apply route tier modifier for sea routes
const tierModifier = ROUTE_TIER_MODIFIERS[routeType]?.cost || 1;
@ -917,6 +1018,12 @@ window.Routes = (function () {
}
}
function getHeuristicScale(routeType, isWater) {
// Conservative lower bound for per-edge modifier to keep heuristic admissible
const tier = ROUTE_TIER_MODIFIERS[routeType]?.cost || 1;
return 0.5 * tier;
}
function buildLinks(routes) {
const links = {};
@ -999,7 +1106,7 @@ window.Routes = (function () {
for (let i = 0; i < pathCells.length; i++) {
const cellId = pathCells[i];
const nextCellId = pathCells[i + 1];
const isConnected = connections.has(`${cellId}-${nextCellId}`) || connections.has(`${nextCellId}-${cellId}`);
const isConnected = nextCellId !== undefined && ((connections.get(cellId)?.has(nextCellId)) || (connections.get(nextCellId)?.has(cellId)));
if (isConnected) {
if (segment.length) {
@ -1288,7 +1395,19 @@ window.Routes = (function () {
};
function generateName({group, points}) {
if (points.length < 4) return "Unnamed route segment";
if (points.length < 4) {
const start = points[0]?.[2];
const end = points.at(-1)?.[2];
const startB = start != null ? pack.cells.burg[start] : 0;
const endB = end != null ? pack.cells.burg[end] : 0;
const startName = startB ? getAdjective(pack.burgs[startB].name) : null;
const endName = endB ? getAdjective(pack.burgs[endB].name) : null;
const base = group === "searoutes" ? "Sea route" : group === "secondary" || group === "roads" ? "Road" : "Trail";
if (startName && endName) return `${base} ${startName}${endName}`;
if (startName) return `${base} from ${startName}`;
if (endName) return `${base} to ${endName}`;
return `${base} segment`;
}
const model = rw(models[group]);
const suffix = rw(suffixes[group]);
@ -1298,7 +1417,7 @@ window.Routes = (function () {
if (model === "prefix_suffix") return `${ra(prefixes)} ${suffix}`;
if (model === "the_descriptor_prefix_suffix") return `The ${ra(descriptors)} ${ra(prefixes)} ${suffix}`;
if (model === "the_descriptor_burg_suffix" && burgName) return `The ${ra(descriptors)} ${burgName} ${suffix}`;
return "Unnamed route";
return group === "searoutes" ? "Sea route" : "Route";
function getBurgName() {
const priority = [points.at(-1), points.at(0), points.slice(1, -1).reverse()];
@ -1325,6 +1444,17 @@ window.Routes = (function () {
return path;
}
// Compute path length in pixels from list of cell ids
function pathCellsLengthPx(cells) {
let len = 0;
for (let i = 0; i < cells.length - 1; i++) {
const [x1, y1] = pack.cells.p[cells[i]];
const [x2, y2] = pack.cells.p[cells[i + 1]];
len += Math.hypot(x2 - x1, y2 - y1);
}
return len;
}
function getLength(routeId) {
const path = routes.select("#route" + routeId).node();
if (!path) {

View file

@ -98,7 +98,21 @@ function overviewRoutes() {
function zoomToRoute() {
const routeId = +this.parentNode.dataset.id;
const route = routes.select("#route" + routeId).node();
highlightElement(route, 3);
if (route) {
highlightElement(route, 3);
return;
}
// Fallback if SVG element not yet in DOM: center on route points
const r = pack.routes.find(r => r.i === routeId);
if (!r || !r.points || r.points.length === 0) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const [x, y] of r.points) {
if (x < minX) minX = x; if (x > maxX) maxX = x;
if (y < minY) minY = y; if (y > maxY) maxY = y;
}
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
zoomTo(cx, cy, 3, 1200);
}
function downloadRoutesData() {

View file

@ -8,6 +8,6 @@ else
exit 1
fi
chromium http://localhost:8000
chromium http://localhost:9001
$PYTHON -m http.server 8000
$PYTHON -m http.server 9001

View file

@ -216,6 +216,62 @@ function findPath(start, isExit, getCost) {
return null;
}
/**
* A* shortest path between two cells using an admissible heuristic.
* Uses a reusable FlatQueue and typed arrays to minimize allocations.
* @param {number} start - start cell id
* @param {number} goal - goal cell id
* @param {(current:number,next:number)=>number} getCost - edge cost; return Infinity for impassable
* @param {(id:number)=>number} heuristic - estimated remaining cost from id to goal; must be admissible
* @returns {number[]|null}
*/
function findPathAStar(start, goal, getCost, heuristic) {
if (start === goal) return null;
// reusable arrays sized to pack.cells.c.length
const n = pack.cells.c.length;
if (!findPathAStar._g || findPathAStar._g.length !== n) {
findPathAStar._g = new Float64Array(n);
findPathAStar._f = new Float64Array(n);
findPathAStar._from = new Int32Array(n);
}
const g = findPathAStar._g;
const f = findPathAStar._f;
const from = findPathAStar._from;
for (let i = 0; i < n; i++) { g[i] = Infinity; f[i] = Infinity; from[i] = -1; }
const open = new FlatQueue();
g[start] = 0;
f[start] = heuristic(start);
open.push(start, f[start]);
// simple safety cap to avoid pathological searches
const maxPops = n * 4;
let pops = 0;
while (open.length) {
if (pops++ > maxPops) return null;
const currentF = open.peekValue();
const current = open.pop();
if (current === goal) return restorePath(goal, start, from);
if (currentF > f[current]) continue; // stale entry
const neigh = pack.cells.c[current];
for (let k = 0; k < neigh.length; k++) {
const next = neigh[k];
const edge = getCost(current, next);
if (edge === Infinity) continue;
const tentativeG = g[current] + edge;
if (tentativeG >= g[next]) continue;
from[next] = current;
g[next] = tentativeG;
f[next] = tentativeG + heuristic(next);
open.push(next, f[next]);
}
}
return null;
}
// supplementary function for findPath
function restorePath(exit, start, from) {
const pathCells = [];