From 21df872ca2fc339acfc24df3fccf1deedcc7d88f Mon Sep 17 00:00:00 2001 From: barrulus Date: Thu, 4 Sep 2025 10:13:01 +0100 Subject: [PATCH] efficiency --- main.js | 43 +++-- modules/burgs-and-states.js | 39 ++-- modules/heightmap-generator.js | 184 ++++++++++-------- modules/routes-generator.js | 334 +++++++++++++++++++++++---------- modules/ui/routes-overview.js | 16 +- run_python_server.sh | 4 +- utils/pathUtils.js | 56 ++++++ 7 files changed, 466 insertions(+), 210 deletions(-) diff --git a/main.js b/main.js index bd6ed474..5f937ca8 100644 --- a/main.js +++ b/main.js @@ -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; diff --git a/modules/burgs-and-states.js b/modules/burgs-and-states.js index f4e0a94f..09d8fcfd 100644 --- a/modules/burgs-and-states.js +++ b/modules/burgs-and-states.js @@ -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 diff --git a/modules/heightmap-generator.js b/modules/heightmap-generator.js index 5a69337e..27a8f4f3 100644 --- a/modules/heightmap-generator.js +++ b/modules/heightmap-generator.js @@ -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) => { diff --git a/modules/routes-generator.js b/modules/routes-generator.js index 133fb7a4..8627a992 100644 --- a/modules/routes-generator.js +++ b/modules/routes-generator.js @@ -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> 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) { diff --git a/modules/ui/routes-overview.js b/modules/ui/routes-overview.js index 42570478..d483fa6a 100644 --- a/modules/ui/routes-overview.js +++ b/modules/ui/routes-overview.js @@ -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() { diff --git a/run_python_server.sh b/run_python_server.sh index 7ac82957..76ec7113 100644 --- a/run_python_server.sh +++ b/run_python_server.sh @@ -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 diff --git a/utils/pathUtils.js b/utils/pathUtils.js index deafd678..384fa154 100644 --- a/utils/pathUtils.js +++ b/utils/pathUtils.js @@ -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 = [];