diff --git a/adminRegionsCore.js b/adminRegionsCore.js index 5e964a7..04f902c 100644 --- a/adminRegionsCore.js +++ b/adminRegionsCore.js @@ -306,11 +306,11 @@ export function terrainBoundaryTargetScore(i, elevation, slope, river, ridgeFiel const urbanPenalty = urbanBoundaryPenalty(i, populationDensity, landuse); const majorRiver = clamp(Math.max(river[i] - 0.32, 0) * 1.9 + Math.max(flowAccum[i] - 0.38, 0) * 0.75); const minorStream = clamp(river[i] * 0.34 + flowAccum[i] * 0.18); - const ridgeDivide = clamp(ridgeField[i] * 1.55 + Math.max(0, elevation[i] - 0.54) * ridgeField[i] * 0.95); - const slopeBreak = clamp(slope[i] * 0.58 + Math.max(0, slope[i] - 0.32) * 0.68); - const highGround = Math.max(0, elevation[i] - 0.56) * 0.22; - const valleyFloorPenalty = valleyField[i] * (majorRiver > 0.34 ? -0.10 : -0.62); - return clamp(ridgeDivide + majorRiver * 0.88 + minorStream * 0.22 + slopeBreak + highGround + valleyFloorPenalty - urbanPenalty * 0.72); + const ridgeDivide = clamp(ridgeField[i] * 2.12 + Math.max(0, elevation[i] - 0.52) * ridgeField[i] * 1.32); + const slopeBreak = clamp(slope[i] * 0.70 + Math.max(0, slope[i] - 0.30) * 0.88); + const highGround = Math.max(0, elevation[i] - 0.54) * 0.34; + const valleyFloorPenalty = valleyField[i] * (majorRiver > 0.34 ? -0.08 : -0.68); + return clamp(ridgeDivide + majorRiver * 0.92 + minorStream * 0.20 + slopeBreak + highGround + valleyFloorPenalty - urbanPenalty * 0.92); } function urbanBoundaryPenalty(i, populationDensity, landuse) { @@ -518,20 +518,20 @@ export function buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, for (const [nx, ny] of neighbors8(x, y)) if (sea[indexOf(nx, ny)]) coastEdge = 1; const urbanContinuity = populationDensity && landuse ? urbanBoundaryPenalty(i, populationDensity, landuse) : 0; const majorRiver = clamp(Math.max(river[i] - 0.34, 0) * 1.95 + Math.max(flowAccum[i] - 0.42, 0) * 0.82); - const ridgeDivide = clamp(ridgeField[i] * 1.65 + Math.max(0, elevation[i] - 0.52) * ridgeField[i] * 1.05); - const crest = crestCrossingScore ? crestCrossingScore[i] : clamp(Math.max(0, elevation[i] - 0.55) * ridgeField[i] * 1.4 + slope[i] * ridgeField[i] * 0.8); - const basinRim = basinField ? clamp(Math.max(0, basinField[i] - 0.32) * Math.max(0, slope[i] - 0.18) * 1.15 + Math.max(0, ridgeField[i] - 0.34) * basinField[i] * 0.62) : 0; - const foothillBreak = clamp(Math.max(0, slope[i] - 0.30) * Math.max(ridgeField[i], Math.max(0, elevation[i] - 0.48)) * 0.82); + const ridgeDivide = clamp(ridgeField[i] * 2.05 + Math.max(0, elevation[i] - 0.50) * ridgeField[i] * 1.28); + const crest = crestCrossingScore ? crestCrossingScore[i] : clamp(Math.max(0, elevation[i] - 0.53) * ridgeField[i] * 1.72 + slope[i] * ridgeField[i] * 1.02); + const basinRim = basinField ? clamp(Math.max(0, basinField[i] - 0.30) * Math.max(0, slope[i] - 0.16) * 1.32 + Math.max(0, ridgeField[i] - 0.32) * basinField[i] * 0.78) : 0; + const foothillBreak = clamp(Math.max(0, slope[i] - 0.28) * Math.max(ridgeField[i], Math.max(0, elevation[i] - 0.46)) * 1.02); const livingCorridor = clamp((plain?.[i] || 0) * 0.34 + (agriculture?.[i] || 0) * 0.26 + valleyField[i] * (majorRiver > 0.34 ? 0.10 : 0.46) + coastalLowland[i] * 0.18); score[i] = clamp( - ridgeDivide * 0.92 + - crest * 0.72 + + ridgeDivide * 1.42 + + crest * 1.10 + majorRiver * 0.86 + - basinRim * 0.54 + - foothillBreak * 0.48 + + basinRim * 0.66 + + foothillBreak * 0.58 + coastEdge * 0.34 + - terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity || score, landuse || score) * 0.38 - - livingCorridor * 0.50 - + terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity || score, landuse || score) * 0.44 - + livingCorridor * 0.28 - urbanContinuity * 0.72 ); } @@ -568,7 +568,7 @@ function canShareNaturalCompartment(a, b, classA, classB, barrier, river, flowAc const urbanEdge = ((landuse[a] >= 2 && landuse[a] <= 4) || landuse[a] === 7 || populationDensity[a] > 0.34) && ((landuse[b] >= 2 && landuse[b] <= 4) || landuse[b] === 7 || populationDensity[b] > 0.34); const valleyContinuity = (valleyField[a] + valleyField[b]) * 0.5 > 0.48 && !majorRiverEdge; - const threshold = urbanEdge ? 0.78 : valleyContinuity ? 0.62 : classA === 8 || classB === 8 ? 0.36 : 0.50; + const threshold = urbanEdge ? 0.74 : valleyContinuity ? 0.56 : classA === 8 || classB === 8 ? 0.28 : 0.43; return barrier < threshold && (!majorRiverEdge || urbanEdge); } @@ -665,9 +665,9 @@ function splitOneNaturalCompartment(unit, newId, compartmentId, fields, seed) { if (!cellSet.has(ni)) continue; const barrier = ((naturalBarrierScore?.[cur.i] || 0) + (naturalBarrierScore?.[ni] || 0)) * 0.5; const riverBarrier = Math.max(river?.[cur.i] || 0, river?.[ni] || 0) + Math.max(flowAccum?.[cur.i] || 0, flowAccum?.[ni] || 0) * 0.32; - const ridgeStep = Math.max(ridgeField[cur.i], ridgeField[ni]) * 0.80 + Math.abs(elevation[cur.i] - elevation[ni]) * 1.25; - const corridorBonus = Math.min(0.48, ((valleyField[cur.i] + valleyField[ni]) * 0.5 + (plain?.[ni] || 0) * 0.18 + (coastalLowland?.[ni] || 0) * 0.12)); - const stepCost = Math.max(0.18, 0.78 + barrier * 3.0 + riverBarrier * 1.10 + ridgeStep + slope[ni] * 0.38 - corridorBonus) * step; + const ridgeStep = Math.max(ridgeField[cur.i], ridgeField[ni]) * 1.18 + Math.abs(elevation[cur.i] - elevation[ni]) * 1.48; + const corridorBonus = Math.min(0.42, ((valleyField[cur.i] + valleyField[ni]) * 0.5 + (plain?.[ni] || 0) * 0.15 + (coastalLowland?.[ni] || 0) * 0.10)); + const stepCost = Math.max(0.18, 0.78 + barrier * 3.75 + riverBarrier * 1.10 + ridgeStep + slope[ni] * 0.48 - corridorBonus) * step; const nd = cur.f + stepCost; if (nd < dist[ni]) { dist[ni] = nd; @@ -735,6 +735,44 @@ function collectLandComponents(prefectureMask, sea) { return components; } + +function collectNaturalGrowthComponents(prefectureMask, sea, watershedId = null) { + if (!watershedId) return collectLandComponents(prefectureMask, sea); + const seen = new Uint8Array(SIZE); + const components = []; + const queue = []; + for (let i = 0; i < SIZE; i++) { + if (seen[i] || !prefectureMask[i] || sea[i]) continue; + const wid = watershedId[i]; + const cells = []; + queue.length = 0; + queue.push(i); + seen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + cells.push(cur); + const [x, y] = xyOf(cur); + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue; + if (watershedId[ni] !== wid) continue; + seen[ni] = 1; + queue.push(ni); + } + } + components.push(cells); + } + return components; +} + +function isWatershedBoundary(a, b, fields) { + const watershedId = fields?.watershedId; + if (!watershedId) return false; + const aw = watershedId[a]; + const bw = watershedId[b]; + return aw >= 0 && bw >= 0 && aw !== bw; +} + function naturalSeedScore(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, seed) { const klassUrban = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8; const lowland = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); @@ -762,8 +800,23 @@ function chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, seed const { cells, componentIndex, area } = sortedComponents[componentOrder]; if (area <= 0) continue; const proportional = Math.round((targetCount || Math.round(totalArea / 42)) * area / Math.max(1, totalArea)); - let localTarget = Math.max(1, proportional); + let highland = 0; + let rugged = 0; + for (const ci of cells) { + highland += clamp((elevation[ci] - 0.52) * 2.1 + ridgeField[ci] * 0.55 + slope[ci] * 0.45); + rugged += clamp(ridgeField[ci] * 0.75 + slope[ci] * 0.55 + Math.max(0, elevation[ci] - 0.58) * 0.85); + } + highland /= Math.max(1, area); + rugged /= Math.max(1, area); + // Watersheds can be very large in mountain ranges. The watershed switch is + // a hard stop, but a single watershed still needs several internal natural + // units; otherwise an entire mountain massif becomes one compartment. Keep + // the global target budget roughly intact by capping the terrain boost. + const terrainBoost = highland > 0.48 ? 2.0 : rugged > 0.38 ? 1.55 : 1.0; + let localTarget = Math.max(1, Math.round(Math.max(1, proportional) * terrainBoost)); localTarget = Math.min(localTarget, Math.max(1, Math.floor(area / minCellsPerUnit))); + const reservedForRest = Math.max(0, sortedComponents.length - componentOrder - 1); + localTarget = Math.min(localTarget, Math.max(1, remainingTarget - reservedForRest)); if (componentOrder === sortedComponents.length - 1) localTarget = Math.max(1, Math.min(localTarget, remainingTarget)); remainingTarget -= localTarget; @@ -800,6 +853,13 @@ function chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, seed return { seeds, seedComponentId }; } +function isHardNaturalRidgeCrossing(a, b, cellClass, fields) { + // Natural compartments now use drainage basins as the only hard barrier. + // Ridges still increase naturalStepCost, but they must not freeze an entire + // mountain block into one unsplittable component inside the same basin. + return isWatershedBoundary(a, b, fields); +} + function naturalStepCost(a, b, cellClass, fields) { const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, flowAccum } = fields; const barrier = ((naturalBarrierScore?.[a] || 0) + (naturalBarrierScore?.[b] || 0)) * 0.5; @@ -819,12 +879,13 @@ function naturalStepCost(a, b, cellClass, fields) { const valleyContinuity = Math.min(valleyField[a], valleyField[b]) * (majorRiverCrossing ? 0.10 : 0.45); const corridorBonus = Math.min(0.42, lowlandContinuity * 0.22 + valleyContinuity + (bothUrban ? 0.18 : 0)); const riverPenalty = majorRiverCrossing && !bothUrban ? 1.85 + flowEdge * 1.45 : riverEdge > 0.22 ? 0.38 : 0; + if (isHardNaturalRidgeCrossing(a, b, cellClass, fields)) return INF; return Math.max(0.16, 0.72 + - barrier * 5.1 + - ridge * 0.82 + - elevationBreak * 3.0 + - slopeBreak * 0.56 + + barrier * 8.8 + + ridge * 2.35 + + elevationBreak * 5.10 + + slopeBreak * 1.08 + riverPenalty + classBreak - corridorBonus @@ -894,6 +955,42 @@ function splitDisconnectedCompartments(compartmentId, compartments, prefectureMa } } + +function splitCompartmentsByWatershed(compartmentId, compartments, fields) { + const watershedId = fields?.watershedId; + if (!watershedId) return 0; + let split = 0; + for (const unit of [...compartments]) { + if (!unit || unit.area === 0 || !unit.cells?.length) continue; + const groups = new Map(); + for (const ci of unit.cells) { + const wid = watershedId[ci]; + const key = wid >= 0 ? wid : -1; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(ci); + } + if (groups.size <= 1) continue; + const sorted = [...groups.values()].sort((a, b) => b.length - a.length); + unit.cells = sorted[0]; + unit.area = sorted[0].length; + for (const extra of sorted.slice(1)) { + const newId = compartments.length; + for (const ci of extra) compartmentId[ci] = newId; + compartments.push({ + id: newId, + cells: extra, + centerIds: [], + adjacent: new Map(), + area: extra.length, + classId: unit.classId, + dominantLandscapeClass: unit.dominantLandscapeClass, + }); + split++; + } + } + return split; +} + function renumberCompartments(compartmentId, compartments, prefectureMask, sea) { const active = compartments.filter((unit) => unit && unit.area > 0 && unit.cells?.length); const idMap = new Map(); @@ -967,7 +1064,9 @@ function splitNaturalCompartmentCompact(unit, newId, compartmentId, fields, seed for (const [nx, ny, step] of neighbors4(x, y)) { const ni = indexOf(nx, ny); if (!cellSet.has(ni)) continue; - const nd = cur.f + naturalStepCost(cur.i, ni, cellClass, fields) * step; + const stepCost = naturalStepCost(cur.i, ni, cellClass, fields); + if (stepCost >= INF) continue; + const nd = cur.f + stepCost * step; if (nd < dist[ni]) { dist[ni] = nd; owner[ni] = cur.owner; @@ -986,14 +1085,43 @@ function splitNaturalCompartmentCompact(unit, newId, compartmentId, fields, seed return newUnit; } +function splitNaturalCompartmentByAxis(unit, newId, compartmentId, fields, seed) { + if (!unit || unit.area < 20 || !unit.cells?.length) return null; + const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse } = fields; + const minPart = Math.max(7, Math.min(34, Math.floor(unit.area * 0.20))); + const horizontal = (unit.width || 0) >= (unit.height || 0); + const cx = unit.x || 0; + const cy = unit.y || 0; + const sorted = [...unit.cells].sort((a, b) => { + const [ax, ay] = xyOf(a); + const [bx, by] = xyOf(b); + const av = (horizontal ? ax : ay) + hashSeededTie(ax, ay, seed) * 0.35 + Math.abs((horizontal ? ay - cy : ax - cx)) * 0.015; + const bv = (horizontal ? bx : by) + hashSeededTie(bx, by, seed) * 0.35 + Math.abs((horizontal ? by - cy : bx - cx)) * 0.015; + return av - bv; + }); + const cut = Math.max(minPart, Math.min(sorted.length - minPart, Math.floor(sorted.length * 0.50))); + if (cut <= 0 || sorted.length - cut < minPart) return null; + const aCells = sorted.slice(0, cut); + const bCells = sorted.slice(cut); + unit.cells = aCells; + unit.area = aCells.length; + for (const ci of aCells) compartmentId[ci] = unit.id; + for (const ci of bCells) compartmentId[ci] = newId; + const newUnit = { id: newId, cells: bCells, area: bCells.length, centerIds: [], adjacent: new Map(), classId: unit.classId, dominantLandscapeClass: unit.dominantLandscapeClass }; + refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); + refreshCompartmentStats(newUnit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); + return newUnit; +} + function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) { const progress = typeof options.progress === "function" ? options.progress : null; const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse); const cellClass = new Int16Array(SIZE); cellClass.fill(-1); for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) cellClass[i] = classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse); - const fields = { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, naturalBarrierScore, cellClass }; - const landComponents = collectLandComponents(prefectureMask, sea); + const watershedId = options.watershedId || null; + const fields = { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, naturalBarrierScore, cellClass, watershedId }; + const landComponents = collectNaturalGrowthComponents(prefectureMask, sea, watershedId); const landArea = landComponents.reduce((sum, cells) => sum + cells.length, 0); const requestedTarget = options.targetCompartmentCount || clamp(Math.round(landArea / 34), 40, 360); const targetCount = clamp(Math.round(requestedTarget), Math.min(1, landArea), Math.max(1, Math.floor(landArea / 8))); @@ -1016,7 +1144,9 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r for (const [nx, ny, step] of neighbors4(x, y)) { const ni = indexOf(nx, ny); if (!prefectureMask[ni] || sea[ni]) continue; - const nd = cur.f + naturalStepCost(cur.i, ni, cellClass, fields) * step; + const stepCost = naturalStepCost(cur.i, ni, cellClass, fields); + if (stepCost >= INF) continue; + const nd = cur.f + stepCost * step; if (nd < dist[ni]) { dist[ni] = nd; compartmentId[ni] = cur.id; @@ -1033,6 +1163,7 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r mergeWeakArtificialLandscapeUnits(compartmentId, compartments, fields, prefectureMask, sea, Math.max(42, Math.round(landArea / Math.max(1, targetCount) * 2.15)), 5); progress?.(`natural post-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`); splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea); + splitCompartmentsByWatershed(compartmentId, compartments, fields); compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea); refreshAllCompartmentStats(compartments, fields); rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); @@ -1051,7 +1182,8 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r return sb - sa; })[0]; if (!worst) break; - const newUnit = splitNaturalCompartmentCompact(worst, compartments.length, compartmentId, fields, (options.seed || 0) + guard * 97); + const newUnit = splitNaturalCompartmentCompact(worst, compartments.length, compartmentId, fields, (options.seed || 0) + guard * 97) + || splitNaturalCompartmentByAxis(worst, compartments.length, compartmentId, fields, (options.seed || 0) + guard * 131); if (!newUnit) { worst._splitRejected = (worst._splitRejected || 0) + 1; if (worst._splitRejected > 2) worst.elongation = Math.min(worst.elongation || 1, 3.1); @@ -1070,6 +1202,7 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r mergeWeakArtificialLandscapeUnits(compartmentId, compartments, fields, prefectureMask, sea, Math.max(48, Math.round(landArea / Math.max(1, targetCount) * 2.05)), 4); splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea); + splitCompartmentsByWatershed(compartmentId, compartments, fields); compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea); refreshAllCompartmentStats(compartments, fields); rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); @@ -1214,8 +1347,14 @@ function mergeWeakArtificialLandscapeUnits(unitId, units, fields, prefectureMask const lowlandContinuity = Math.min(unit.lowlandFitness || 0, other.lowlandFitness || 0); const mountainContinuity = Math.min(unit.mountainFitness || 0, other.mountainFitness || 0); const urbanGuard = Math.max(unit.urbanWeight || 0, other.urbanWeight || 0); - const weakDivider = avgBarrier < (sameClass ? 0.46 : 0.36); - if (!weakDivider) continue; + const ridgeDivider = avgBarrier > 0.58 || Math.max(unit.ridgeExposure || 0, other.ridgeExposure || 0) > 0.62; + const weakDivider = avgBarrier < (sameClass ? 0.40 : 0.30); + const bothMountain = (unit.mountainFitness || 0) > 0.56 && (other.mountainFitness || 0) > 0.56; + // Large highland units are visually important. Do not erase their + // internal subdivision just because two neighbouring cells share a class + // inside the same watershed. + if (bothMountain && combinedArea > Math.max(32, maxMergedArea * 0.72)) continue; + if (!weakDivider || ridgeDivider) continue; const score = (sameClass ? 1.7 : 0) + (sameGroup ? 1.2 : 0) + @@ -1241,8 +1380,10 @@ function naturalOwnershipAffinity(unit, neighbor, edge) { const bothUrban = unit.classId <= 3 && neighbor.classId <= 3; const bothCorridor = [5, 6, 7, 10].includes(unit.classId) && [5, 6, 7, 10].includes(neighbor.classId); const urbanContinuity = bothUrban ? 1.35 : (unit.urbanWeight + neighbor.urbanWeight) > 0.75 && Math.abs(unit.urbanWeight - neighbor.urbanWeight) < 0.35 ? 0.58 : 0; - const strongDividerPenalty = boundaryTarget * (edge.count > 2 ? 2.8 : 1.8); - return edge.count * 0.55 + sameClass + sameGroup + urbanContinuity + (bothCorridor ? 0.72 : 0) - strongDividerPenalty; + const ridgeExposure = Math.max(unit.ridgeExposure || 0, neighbor.ridgeExposure || 0); + const hardRidgePenalty = boundaryTarget > 0.62 || ridgeExposure > 0.62 ? 2.4 : 0; + const strongDividerPenalty = boundaryTarget * (edge.count > 2 ? 5.2 : 3.8) + ridgeExposure * 1.35 + hardRidgePenalty; + return edge.count * 0.50 + sameClass + sameGroup + urbanContinuity + (bothCorridor ? 0.58 : 0) - strongDividerPenalty; } function compartmentCrossingCost(unit, neighbor, edge) { @@ -1255,17 +1396,41 @@ function compartmentCrossingCost(unit, neighbor, edge) { const ridgePenalty = Math.max(unit.ridgeExposure || 0, neighbor.ridgeExposure || 0); return Math.max(0.18, 1.0 + - boundaryScore * 5.2 + - mountainPenalty * 1.8 + - ridgePenalty * 0.9 - + boundaryScore * 9.2 + + mountainPenalty * 2.65 + + ridgePenalty * 2.75 + + (boundaryScore > 0.64 || ridgePenalty > 0.64 ? 5.5 : 0) - sameClass * 0.45 - sameGroup * 0.35 - - lowlandContinuity * 1.15 - - urbanContinuity * 0.70 - + lowlandContinuity * 0.98 - + urbanContinuity * 0.64 - Math.min(1.0, edge.count / 12) * 0.25 ); } +function enrichCompartmentsWithUnifiedGeography(compartments, options = {}) { + const geo = options.geography || options || {}; + const fields = [ + ["habitability", geo.habitability], + ["accessibility", geo.accessibility], + ["centrality", geo.centrality], + ["boundaryAvoidance", geo.boundaryAvoidance], + ["adminBoundaryPreference", geo.adminBoundaryPreference], + ["geographicBarrier", geo.geographicBarrier], + ]; + if (!fields.some(([, field]) => field)) return false; + for (const unit of compartments || []) { + if (!unit || !unit.cells?.length) continue; + for (const [name, field] of fields) { + if (!field) continue; + let sum = 0; + for (const i of unit.cells) sum += field[i] || 0; + unit[name] = sum / Math.max(1, unit.cells.length); + } + } + return true; +} + function graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options = {}) { const owner = new Int16Array(compartments.length); const dist = new Float32Array(compartments.length); @@ -1297,7 +1462,8 @@ function graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters const crossing = compartmentCrossingCost(unit, neighbor, edge); const euclideanTie = center ? Math.hypot(neighbor.x - center.x, neighbor.y - center.y) * 0.006 : 0; const hinterlandDrag = (neighbor.mountainFitness || 0) > 0.64 && (neighbor.population || 0) < 6 ? 0.35 : 0; - const next = cur.f + crossing + euclideanTie + hinterlandDrag; + const ridgeExpansionDrag = Math.max(0, (neighbor.ridgeExposure || 0) - (unit.ridgeExposure || 0)) * 1.75 + (crossing > 9.0 ? 2.8 : 0); + const next = cur.f + crossing + euclideanTie + hinterlandDrag + ridgeExpansionDrag; if (next + 1e-5 < dist[neighborId]) { dist[neighborId] = next; owner[neighborId] = cur.owner; @@ -1364,6 +1530,25 @@ function averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierS return count ? sum / count : 0; } +function averageFinalBorderField(adminId, prefectureMask, sea, field) { + if (!field) return 0; + let sum = 0; + let count = 0; + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; + for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni] || adminId[ni] < 0 || adminId[ni] === adminId[i]) continue; + sum += ((field[i] || 0) + (field[ni] || 0)) * 0.5; + count++; + } + } + } + return count ? sum / count : 0; +} + function assignCompartmentsToAdminOwners(compartments, compartmentId, adminCenters, naturalBarrierScore, prefectureMask, sea) { const owner = new Int16Array(compartments.length); owner.fill(-1); @@ -1453,6 +1638,7 @@ export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, e progress?.("natural compartments built"); const adminId = new Int16Array(SIZE); adminId.fill(-1); + const unifiedGeographyApplied = enrichCompartmentsWithUnifiedGeography(compartments, options); const owner = graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options); progress?.("natural compartments assigned"); for (const unit of compartments) { @@ -1485,6 +1671,10 @@ export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, e .sort((a, b) => (b.elongation * Math.sqrt(Math.max(1, b.area))) - (a.elongation * Math.sqrt(Math.max(1, a.area)))) .slice(0, 8), finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore), + unifiedGeographyAppliedToAdminCompartments: unifiedGeographyApplied, + finalBorderUnifiedBoundaryPreferenceAverage: averageFinalBorderField(adminId, prefectureMask, sea, options.adminBoundaryPreference || options.geography?.adminBoundaryPreference), + finalBorderUnifiedBoundaryAvoidanceAverage: averageFinalBorderField(adminId, prefectureMask, sea, options.boundaryAvoidance || options.geography?.boundaryAvoidance), + finalBorderUnifiedCentralityAverage: averageFinalBorderField(adminId, prefectureMask, sea, options.centrality || options.geography?.centrality), voronoiLikeRateBefore: 0, voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore), }, diff --git a/app.js b/app.js index 0190ccf..b9bdc54 100644 --- a/app.js +++ b/app.js @@ -24,6 +24,7 @@ const state = { }; const canvas = document.getElementById("mapCanvas"); +const canvasShell = document.querySelector(".canvas-shell"); const seedInput = document.getElementById("seed"); const randomSeedButton = document.getElementById("randomSeed"); const showFeaturesInput = document.getElementById("showFeatures"); @@ -38,6 +39,69 @@ let generationStartedAt = 0; let generationCurrentStage = ""; let generationTimer = null; +const panState = { keys: new Set(), raf: null, lastTime: 0, speedPxPerSecond: 520 }; + +function mapClientToCell(event) { + if (!state.map) return null; + const rect = canvas.getBoundingClientRect(); + if (!rect.width || !rect.height) return null; + const relX = (event.clientX - rect.left) / rect.width; + const relY = (event.clientY - rect.top) / rect.height; + return { + x: Math.floor(relX * state.map.width), + y: Math.floor(relY * state.map.height), + }; +} + +function isEditableTarget(target) { + if (!target) return false; + const tag = target.tagName?.toLowerCase?.(); + return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable; +} + +function panFrame(time) { + if (!canvasShell || panState.keys.size === 0) { + panState.raf = null; + panState.lastTime = 0; + return; + } + const dt = panState.lastTime ? Math.min(0.05, (time - panState.lastTime) / 1000) : 0; + panState.lastTime = time; + let dx = 0; + let dy = 0; + if (panState.keys.has("a")) dx -= 1; + if (panState.keys.has("d")) dx += 1; + if (panState.keys.has("w")) dy -= 1; + if (panState.keys.has("s")) dy += 1; + if (dx || dy) { + const normalizer = dx && dy ? Math.SQRT1_2 : 1; + const amount = panState.speedPxPerSecond * dt; + canvasShell.scrollLeft += dx * normalizer * amount; + canvasShell.scrollTop += dy * normalizer * amount; + tooltipEl?.classList.remove("visible"); + } + panState.raf = requestAnimationFrame(panFrame); +} + +function startKeyboardPan() { + if (panState.raf == null) panState.raf = requestAnimationFrame(panFrame); +} + +function handlePanKeyDown(event) { + const key = event.key?.toLowerCase?.(); + if (!key || !"wasd".includes(key) || isEditableTarget(event.target)) return; + panState.keys.add(key); + startKeyboardPan(); + event.preventDefault(); +} + +function handlePanKeyUp(event) { + const key = event.key?.toLowerCase?.(); + if (!key || !"wasd".includes(key)) return; + panState.keys.delete(key); + event.preventDefault(); +} + function parseSeed(seedText) { const numeric = Number.parseInt(seedText, 10); if (Number.isFinite(numeric)) return numeric >>> 0; @@ -121,6 +185,7 @@ function getStats(map) { ["Map Type", map.terrainTemplate?.terrainTypeLabel || map.terrainDebug?.terrainTypeLabel || "-"], ["Terrain ID", map.terrainTemplate?.terrainType || map.terrainDebug?.terrainType || "-"], ["Generation Total", map.generationTotalMs ? formatMs(map.generationTotalMs) : "-"], + ["Geography Basis", map.geographyDebug?.version ? `${map.geographyDebug.version} / ${map.geographyDebug.stage || "-"}` : "-"], ...(map.generationTimings || []).map((row) => [`Time: ${row.label}`, formatMs(row.ms)]), ["Villages", countText(map.villages)], ["Market Towns", countText(map.markets)], @@ -218,8 +283,9 @@ function prefectureNameForCell(map, i) { function updateTooltip(event) { if (!state.map || !tooltipEl) return; const rect = canvas.getBoundingClientRect(); - const x = Math.floor((event.clientX - rect.left) / rect.width * state.map.width); - const y = Math.floor((event.clientY - rect.top) / rect.height * state.map.height); + const cell = mapClientToCell(event); + if (!cell) return; + const { x, y } = cell; if (x < 0 || y < 0 || x >= state.map.width || y >= state.map.height) { tooltipEl.classList.remove("visible"); return; @@ -318,8 +384,13 @@ function init() { redraw(); }); + canvasShell?.setAttribute("tabindex", "0"); + window.addEventListener("keydown", handlePanKeyDown); + window.addEventListener("keyup", handlePanKeyUp); canvas.addEventListener("mousemove", updateTooltip); - canvas.addEventListener("mouseleave", () => tooltipEl?.classList.remove("visible")); + canvas.addEventListener("mouseleave", () => { + tooltipEl?.classList.remove("visible"); + }); regenerate(); } diff --git a/mapAdminCompartmentRepair.js b/mapAdminCompartmentRepair.js new file mode 100644 index 0000000..a759230 --- /dev/null +++ b/mapAdminCompartmentRepair.js @@ -0,0 +1,744 @@ +import { INF, MAP_H, MAP_W, SIZE, clamp, hash2, indexOf, inside, xyOf } from "./mapUtils.js"; +import { applyCompartmentOwners, dominantCompartmentOwners } from "./mapAdminShared.js"; + +export function compactWholeCompartmentMunicipalities(adminId, centers, prefectureMask, sea, fields = {}) { + const activeIds = [...new Set([...adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i]))].sort((a, b) => a - b); + const idMap = new Map(activeIds.map((id, n) => [id, n])); + const compactId = new Int16Array(SIZE); + compactId.fill(-1); + const stats = activeIds.map(() => ({ sx: 0, sy: 0, count: 0, bestI: -1, bestScore: -INF })); + for (let i = 0; i < SIZE; i++) { + if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; + const nextId = idMap.get(adminId[i]); + if (nextId === undefined) continue; + compactId[i] = nextId; + const [x, y] = xyOf(i); + const row = stats[nextId]; + row.sx += x; + row.sy += y; + row.count++; + const score = (fields.populationDensity?.[i] || 0) * 2.2 + (fields.plain?.[i] || 0) * 0.25 - (fields.slope?.[i] || 0) * 0.2; + if (score > row.bestScore) { row.bestScore = score; row.bestI = i; } + } + const compactCenters = activeIds.map((oldId, newId) => { + const current = centers[oldId]; + if (current && inside(current.x, current.y) && compactId[indexOf(current.x, current.y)] === newId) { + return { ...current, originalAdminId: oldId }; + } + const row = stats[newId]; + const fallback = row.bestI >= 0 ? xyOf(row.bestI) : [Math.round(row.sx / Math.max(1, row.count)), Math.round(row.sy / Math.max(1, row.count))]; + return { + ...(current || {}), + x: fallback[0], + y: fallback[1], + originalAdminId: oldId, + generatedOfficePoint: true, + invisibleLowlandAdminSeed: current?.invisibleLowlandAdminSeed ?? true, + seedKind: current?.seedKind || "compactedMunicipalityOffice", + }; + }); + return { adminId: compactId, adminCentersRaw: compactCenters, activeMunicipalityCount: compactCenters.length }; +} + +export function enforceCompartmentMunicipalityOwnership(adminId, compartmentId, compartments, prefectureMask, sea) { + if (!compartmentId || !compartments) return 0; + let changed = 0; + for (const comp of compartments) { + if (!comp || !comp.cells?.length) continue; + const counts = new Map(); + for (const i of comp.cells) { + if (!prefectureMask[i] || sea[i]) continue; + const id = adminId[i]; + if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); + } + let bestId = -1, bestCount = -1; + for (const [id, count] of counts) { + if (count > bestCount || (count === bestCount && id < bestId)) { + bestId = id; + bestCount = count; + } + } + if (bestId < 0) continue; + for (const i of comp.cells) { + if (!prefectureMask[i] || sea[i] || adminId[i] === bestId) continue; + adminId[i] = bestId; + changed++; + } + } + return changed; +} + + +export function mergeSingleCompartmentMunicipalities(adminId, compartments, prefectureMask, sea, minCompartments = 2, maxPasses = 8) { + if (!compartments?.length) return { changedCells: 0, mergedMunicipalities: 0, remainingSingleCompartmentMunicipalities: 0 }; + let totalChangedCells = 0; + let mergedMunicipalities = 0; + let remainingSingles = 0; + for (let pass = 0; pass < maxPasses; pass++) { + const owner = dominantCompartmentOwners(compartments, adminId); + const byOwner = new Map(); + const areaByOwner = new Map(); + for (const unit of compartments) { + if (!unit || unit.area === 0) continue; + const id = owner[unit.id]; + if (id < 0) continue; + if (!byOwner.has(id)) byOwner.set(id, []); + byOwner.get(id).push(unit); + areaByOwner.set(id, (areaByOwner.get(id) || 0) + unit.area); + } + const singles = [...byOwner.entries()] + .filter(([, units]) => units.length > 0 && units.length < minCompartments) + .sort((a, b) => (areaByOwner.get(a[0]) || 0) - (areaByOwner.get(b[0]) || 0) || a[0] - b[0]); + remainingSingles = singles.length; + if (!singles.length) break; + let passChanged = 0; + for (const [id, units] of singles) { + // The old rule allowed one natural compartment to become one municipality. + // That produces many tiny office-only municipalities and makes the hierarchy + // hard to read. Merge such municipalities into the strongest adjacent owner. + const neighborScores = new Map(); + for (const unit of units) { + for (const [neighborId, edge] of unit.adjacent || []) { + const candidate = owner[neighborId]; + if (candidate < 0 || candidate === id) continue; + const neighborUnit = compartments[neighborId]; + const shared = edge.count || 1; + const barrier = edge.target ? edge.target / Math.max(1, shared) : 0; + const sameLandscape = neighborUnit?.classId === unit.classId ? 0.7 : 0; + const score = shared * (2.2 - Math.min(1.6, barrier) + sameLandscape) + Math.sqrt(areaByOwner.get(candidate) || 1) * 0.05; + neighborScores.set(candidate, (neighborScores.get(candidate) || 0) + score); + } + } + let best = -1, bestScore = -INF; + for (const [candidate, score] of neighborScores) { + if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; } + } + if (best < 0) { + // One-cell islets have no land adjacency. Attach them to the nearest + // existing municipality instead of leaving a one-compartment municipality. + const ux = units.reduce((sum, unit) => sum + (unit.x || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0)); + const uy = units.reduce((sum, unit) => sum + (unit.y || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0)); + let bestDist = INF; + for (const [candidate, candidateUnits] of byOwner) { + if (candidate === id || candidateUnits.length < minCompartments) continue; + for (const unit of candidateUnits) { + const d = Math.hypot((unit.x || 0) - ux, (unit.y || 0) - uy); + if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; } + } + } + if (bestDist > 28) best = -1; + } + if (best < 0) continue; + for (const unit of units) { + for (const i of unit.cells || []) { + if (!prefectureMask[i] || sea[i]) continue; + if (adminId[i] !== best) { + adminId[i] = best; + passChanged++; + } + } + } + mergedMunicipalities++; + } + totalChangedCells += passChanged; + if (!passChanged) break; + } + const finalOwner = dominantCompartmentOwners(compartments, adminId); + const finalCounts = new Map(); + for (const unit of compartments) { + if (!unit || unit.area === 0) continue; + const id = finalOwner[unit.id]; + if (id >= 0) finalCounts.set(id, (finalCounts.get(id) || 0) + 1); + } + remainingSingles = [...finalCounts.values()].filter((count) => count > 0 && count < minCompartments).length; + return { changedCells: totalChangedCells, mergedMunicipalities, remainingSingleCompartmentMunicipalities: remainingSingles }; +} + +export function ownerAreaByCompartment(owner, compartments) { + const area = new Map(); + const count = new Map(); + for (const unit of compartments || []) { + if (!unit || unit.area === 0) continue; + const id = owner[unit.id]; + if (id < 0) continue; + area.set(id, (area.get(id) || 0) + (unit.area || 0)); + count.set(id, (count.get(id) || 0) + 1); + } + return { area, count }; +} + +export function compartmentTouchesOutside(unit, prefectureMask, sea) { + if (!unit?.cells?.length) return true; + for (const i of unit.cells) { + const [x, y] = xyOf(i); + if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) return true; + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = x + dx, ny = y + dy; + if (!inside(nx, ny)) return true; + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) return true; + } + } + return false; +} + +export function bestNeighborOwnerForUnits(units, owner, compartments, allowNearestFallback = false) { + const { area } = ownerAreaByCompartment(owner, compartments); + const scores = new Map(); + const blocked = new Set(units.map((unit) => owner[unit.id])); + for (const unit of units) { + for (const [neighborId, edge] of unit.adjacent || []) { + const candidate = owner[neighborId]; + if (candidate < 0 || blocked.has(candidate)) continue; + const neighbor = compartments[neighborId]; + const shared = edge.count || 1; + const barrier = (edge.target || 0) / Math.max(1, shared); + const landscape = neighbor?.classId === unit.classId ? 0.75 : 0; + const lowland = Math.min(unit.lowlandFitness || 0, neighbor?.lowlandFitness || 0) * 0.5; + const score = shared * (2.4 + landscape + lowland - Math.min(1.8, barrier)) + Math.sqrt(area.get(candidate) || 1) * 0.04; + scores.set(candidate, (scores.get(candidate) || 0) + score); + } + } + let best = -1, bestScore = -INF; + for (const [candidate, score] of scores) { + if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; } + } + if (best >= 0 || !allowNearestFallback) return best; + const ux = units.reduce((sum, unit) => sum + (unit.x || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0)); + const uy = units.reduce((sum, unit) => sum + (unit.y || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0)); + let bestDist = INF; + for (const unit of compartments || []) { + if (!unit || unit.area === 0) continue; + const candidate = owner[unit.id]; + if (candidate < 0 || blocked.has(candidate)) continue; + const d = Math.hypot((unit.x || 0) - ux, (unit.y || 0) - uy); + if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; } + } + return bestDist <= 32 ? best : -1; +} + +export function mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, minCompartments = 2, maxPasses = 8) { + let changedCells = 0; + let mergedMunicipalities = 0; + let remainingSingleCompartmentMunicipalities = 0; + for (let pass = 0; pass < maxPasses; pass++) { + const byOwner = new Map(); + for (const unit of compartments || []) { + if (!unit || unit.area === 0) continue; + const id = owner[unit.id]; + if (id < 0) continue; + if (!byOwner.has(id)) byOwner.set(id, []); + byOwner.get(id).push(unit); + } + const small = [...byOwner.entries()] + .filter(([, units]) => units.length > 0 && units.length < minCompartments) + .sort((a, b) => a[1].length - b[1].length || a[0] - b[0]); + remainingSingleCompartmentMunicipalities = small.length; + if (!small.length) break; + let passChanged = 0; + for (const [id, units] of small) { + const target = bestNeighborOwnerForUnits(units, owner, compartments, true); + if (target < 0 || target === id) continue; + for (const unit of units) { + if (owner[unit.id] === target) continue; + owner[unit.id] = target; + changedCells += unit.area || 0; + passChanged += unit.area || 0; + } + mergedMunicipalities++; + } + if (!passChanged) break; + } + const counts = ownerAreaByCompartment(owner, compartments).count; + remainingSingleCompartmentMunicipalities = [...counts.values()].filter((count) => count > 0 && count < minCompartments).length; + return { changedCells, mergedMunicipalities, remainingSingleCompartmentMunicipalities }; +} + +export function repairCompartmentOwnerConnectivity(owner, compartments, maxPasses = 8) { + let changedCells = 0; + let changedComponents = 0; + for (let pass = 0; pass < maxPasses; pass++) { + const ownerIds = [...new Set([...owner].filter((id) => id >= 0))].sort((a, b) => a - b); + let passChanged = 0; + for (const id of ownerIds) { + const members = (compartments || []).filter((unit) => unit && unit.area > 0 && owner[unit.id] === id); + if (members.length <= 1) continue; + const memberSet = new Set(members.map((unit) => unit.id)); + const seen = new Set(); + const components = []; + for (const unit of members) { + if (seen.has(unit.id)) continue; + const queue = [unit.id]; + const comp = []; + seen.add(unit.id); + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + comp.push(compartments[cur]); + for (const next of compartments[cur]?.adjacent?.keys?.() || []) { + if (!memberSet.has(next) || seen.has(next)) continue; + seen.add(next); + queue.push(next); + } + } + components.push(comp); + } + if (components.length <= 1) continue; + components.sort((a, b) => b.reduce((sum, unit) => sum + (unit.area || 0), 0) - a.reduce((sum, unit) => sum + (unit.area || 0), 0)); + for (const comp of components.slice(1)) { + const target = bestNeighborOwnerForUnits(comp, owner, compartments, true); + if (target < 0 || target === id) continue; + for (const unit of comp) { + owner[unit.id] = target; + changedCells += unit.area || 0; + passChanged += unit.area || 0; + } + changedComponents++; + } + } + if (!passChanged) break; + } + return { changedCells, changedComponents }; +} + +export function repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, maxPasses = 6) { + let changedCells = 0; + let changedComponents = 0; + const outsideCache = new Map(); + const touchesOutside = (unit) => { + if (!outsideCache.has(unit.id)) outsideCache.set(unit.id, compartmentTouchesOutside(unit, prefectureMask, sea)); + return outsideCache.get(unit.id); + }; + for (let pass = 0; pass < maxPasses; pass++) { + const ownerIds = [...new Set([...owner].filter((id) => id >= 0))].sort((a, b) => a - b); + let passChanged = 0; + for (const id of ownerIds) { + const members = (compartments || []).filter((unit) => unit && unit.area > 0 && owner[unit.id] === id); + if (!members.length) continue; + const memberSet = new Set(members.map((unit) => unit.id)); + const seen = new Set(); + for (const unit of members) { + if (seen.has(unit.id)) continue; + const queue = [unit.id]; + const comp = []; + const boundaryOwners = new Map(); + let outside = false; + seen.add(unit.id); + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + const curUnit = compartments[cur]; + if (!curUnit) continue; + comp.push(curUnit); + if (touchesOutside(curUnit)) outside = true; + for (const [next, edge] of curUnit.adjacent || []) { + const nextOwner = owner[next]; + if (nextOwner === id) { + if (!seen.has(next) && memberSet.has(next)) { seen.add(next); queue.push(next); } + } else if (nextOwner >= 0) { + boundaryOwners.set(nextOwner, (boundaryOwners.get(nextOwner) || 0) + (edge.count || 1)); + } + } + } + if (outside || boundaryOwners.size !== 1) continue; + const [target] = boundaryOwners.keys(); + if (target < 0 || target === id) continue; + for (const compUnit of comp) { + owner[compUnit.id] = target; + changedCells += compUnit.area || 0; + passChanged += compUnit.area || 0; + } + changedComponents++; + } + } + if (!passChanged) break; + } + return { changedCells, changedComponents }; +} + +export function lockCompactUrbanCompartmentsToDominantOwner(owner, compartments, maxCells = 1100) { + const isUrbanUnit = (unit) => unit && unit.area > 0 && ( + unit.classId <= 3 || + (unit.urbanWeight || 0) >= 0.34 || + ((unit.urbanWeight || 0) >= 0.22 && (unit.lowlandFitness || 0) >= 0.34) + ); + const seen = new Set(); + let changedCells = 0; + let unifiedComponents = 0; + for (const start of compartments || []) { + if (!isUrbanUnit(start) || seen.has(start.id)) continue; + const queue = [start.id]; + const comp = []; + seen.add(start.id); + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + const unit = compartments[cur]; + if (!isUrbanUnit(unit)) continue; + comp.push(unit); + for (const next of unit.adjacent?.keys?.() || []) { + if (seen.has(next) || !isUrbanUnit(compartments[next])) continue; + seen.add(next); + queue.push(next); + } + } + const totalArea = comp.reduce((sum, unit) => sum + (unit.area || 0), 0); + if (comp.length <= 1 || totalArea <= 0 || totalArea > maxCells) continue; + const ownerScore = new Map(); + for (const unit of comp) { + const id = owner[unit.id]; + if (id < 0) continue; + const score = (unit.area || 0) * (1 + (unit.urbanWeight || 0) * 1.8); + ownerScore.set(id, (ownerScore.get(id) || 0) + score); + } + let best = -1, bestScore = -INF; + for (const [id, score] of ownerScore) if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; } + if (best < 0) continue; + let localChanged = 0; + for (const unit of comp) { + if (owner[unit.id] === best) continue; + owner[unit.id] = best; + localChanged += unit.area || 0; + } + if (localChanged > 0) { + changedCells += localChanged; + unifiedComponents++; + } + } + return { changedCells, unifiedComponents }; +} + + +export function lockCityMetroCompartmentsToSingleMunicipality(owner, compartments, fields = {}) { + const { modernCities = [] } = fields; + if (!owner || !compartments?.length || !modernCities?.length) return { changedCells: 0, unifiedCities: 0 }; + let changedCells = 0; + let unifiedCities = 0; + const urbanUnit = (unit) => unit && unit.area > 0 && ( + unit.classId <= 3 || + (unit.urbanWeight || 0) >= 0.24 || + ((unit.urbanWeight || 0) >= 0.16 && (unit.lowlandFitness || 0) >= 0.40) + ); + for (const city of modernCities) { + if (!city || !Number.isFinite(city.x) || !Number.isFinite(city.y) || (city.population || 0) < 18000) continue; + const radius = clamp( + (city.urbanRadius || 8) * ((city.population || 0) >= 200000 ? 1.95 : (city.population || 0) >= 80000 ? 1.65 : 1.35), + 8, + (city.population || 0) >= 200000 ? 34 : 24 + ); + const units = []; + for (const unit of compartments) { + if (!urbanUnit(unit)) continue; + const d = Math.hypot((unit.x || 0) - city.x, (unit.y || 0) - city.y); + if (d > radius) continue; + const weight = (unit.area || 1) * + (1 + (unit.urbanWeight || 0) * 2.4 + (unit.lowlandFitness || 0) * 0.55) * + Math.max(0.20, 1 - d / Math.max(1, radius) * 0.58); + units.push({ unit, weight, d }); + } + if (units.length <= 1) continue; + const totalArea = units.reduce((sum, row) => sum + (row.unit.area || 0), 0); + // Large multi-core conurbations may legitimately contain multiple municipalities. + // This pass targets compact urban areas that visually read as one city. + const maxArea = (city.population || 0) >= 200000 ? 1800 : 900; + if (totalArea > maxArea) continue; + const ownerScore = new Map(); + for (const row of units) { + const id = owner[row.unit.id]; + if (id < 0) continue; + ownerScore.set(id, (ownerScore.get(id) || 0) + row.weight); + } + if (ownerScore.size <= 1) continue; + let best = -1, bestScore = -INF, totalScore = 0; + for (const [id, score] of ownerScore) { + totalScore += score; + if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; } + } + if (best < 0 || bestScore / Math.max(1, totalScore) < 0.28) continue; + let localChanged = 0; + for (const row of units) { + if (owner[row.unit.id] === best) continue; + owner[row.unit.id] = best; + localChanged += row.unit.area || 0; + } + if (localChanged > 0) { + changedCells += localChanged; + unifiedCities++; + } + } + return { changedCells, unifiedCities }; +} + +export function enforceSimpleAdministrativeHierarchy(adminId, compartments, prefectureMask, sea, fields = {}) { + const owner = dominantCompartmentOwners(compartments, adminId); + const urban = lockCompactUrbanCompartmentsToDominantOwner(owner, compartments, fields.maxUrbanClusterCells || 1100); + const metro = lockCityMetroCompartmentsToSingleMunicipality(owner, compartments, fields); + const connectivity1 = repairCompartmentOwnerConnectivity(owner, compartments, 8); + const enclave1 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 6); + const singleMerge = mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, fields.minCompartmentsPerMunicipality || 2, 8); + const connectivity2 = repairCompartmentOwnerConnectivity(owner, compartments, 8); + const enclave2 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 6); + const singleMerge2 = mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, fields.minCompartmentsPerMunicipality || 2, 4); + const connectivity3 = repairCompartmentOwnerConnectivity(owner, compartments, 4); + const enclave3 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 4); + applyCompartmentOwners(adminId, compartments, owner); + const counts = ownerAreaByCompartment(owner, compartments).count; + return { + changedAfterUrbanUnification: urban.changedCells + metro.changedCells, + urbanComponentsUnified: urban.unifiedComponents, + changedAfterCityMetroMunicipalityUnification: metro.changedCells, + cityMetroMunicipalitiesUnified: metro.unifiedCities, + changedAfterCompartmentConnectivity: connectivity1.changedCells + connectivity2.changedCells + connectivity3.changedCells, + disconnectedCompartmentComponentsMerged: connectivity1.changedComponents + connectivity2.changedComponents + connectivity3.changedComponents, + changedAfterCompartmentEnclaveRepair: enclave1.changedCells + enclave2.changedCells + enclave3.changedCells, + compartmentEnclaveComponentsMerged: enclave1.changedComponents + enclave2.changedComponents + enclave3.changedComponents, + changedAfterSingleCompartmentMunicipalityMerge: singleMerge.changedCells + singleMerge2.changedCells, + singleCompartmentMunicipalitiesMerged: singleMerge.mergedMunicipalities + singleMerge2.mergedMunicipalities, + remainingSingleCompartmentMunicipalities: [...counts.values()].filter((count) => count > 0 && count < (fields.minCompartmentsPerMunicipality || 2)).length, + }; +} + + +export function repairPrefectureCellEnclavesFromGrid(adminId, owner, prefectureMask, sea, maxPasses = 5) { + let changed = 0; + const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; + for (let pass = 0; pass < maxPasses; pass++) { + const prefId = new Int16Array(SIZE); + prefId.fill(-1); + for (let i = 0; i < SIZE; i++) { + if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; + prefId[i] = owner.get(adminId[i]) ?? -1; + } + const seen = new Uint8Array(SIZE); + let passChanged = 0; + for (let i = 0; i < SIZE; i++) { + if (seen[i] || prefId[i] < 0) continue; + const id = prefId[i]; + const queue = [i]; + const comp = []; + seen[i] = 1; + let touchesOutside = false; + const boundaryCounts = new Map(); + const adminCounts = new Map(); + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + comp.push(cur); + const aid = adminId[cur]; + if (aid >= 0) adminCounts.set(aid, (adminCounts.get(aid) || 0) + 1); + const [x, y] = xyOf(cur); + if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesOutside = true; + for (const [dx, dy] of dirs) { + const nx = x + dx, ny = y + dy; + if (!inside(nx, ny)) { touchesOutside = true; continue; } + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) { touchesOutside = true; continue; } + const nid = prefId[ni]; + if (nid === id) { + if (!seen[ni]) { seen[ni] = 1; queue.push(ni); } + } else if (nid >= 0) { + boundaryCounts.set(nid, (boundaryCounts.get(nid) || 0) + 1); + } + } + } + if (touchesOutside || boundaryCounts.size !== 1) continue; + const [targetPref] = boundaryCounts.keys(); + if (targetPref < 0 || targetPref === id) continue; + for (const aid of adminCounts.keys()) { + if (owner.get(aid) !== targetPref) { owner.set(aid, targetPref); passChanged++; } + } + } + changed += passChanged; + if (!passChanged) break; + } + return changed; +} + +export function repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, maxPasses = 5) { + let changed = 0; + const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; + for (let pass = 0; pass < maxPasses; pass++) { + const seen = new Uint8Array(SIZE); + let passChanged = 0; + for (let i = 0; i < SIZE; i++) { + if (seen[i] || !prefectureMask[i] || sea[i] || adminId[i] < 0) continue; + const id = adminId[i]; + const queue = [i]; + const comp = []; + seen[i] = 1; + let touchesOutside = false; + const boundaryCounts = new Map(); + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + comp.push(cur); + const [x, y] = xyOf(cur); + if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesOutside = true; + for (const [dx, dy] of dirs) { + const nx = x + dx, ny = y + dy; + if (!inside(nx, ny)) { touchesOutside = true; continue; } + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) { touchesOutside = true; continue; } + const nid = adminId[ni]; + if (nid === id) { + if (!seen[ni]) { seen[ni] = 1; queue.push(ni); } + } else if (nid >= 0) { + boundaryCounts.set(nid, (boundaryCounts.get(nid) || 0) + 1); + } + } + } + if (touchesOutside || boundaryCounts.size !== 1) continue; + const [targetId] = boundaryCounts.keys(); + if (targetId < 0 || targetId === id) continue; + for (const ci of comp) adminId[ci] = targetId; + passChanged += comp.length; + } + changed += passChanged; + if (!passChanged) break; + } + return changed; +} + +export function lockCompactUrbanAreasToDominantAdmin(adminId, prefectureMask, sea, landuse, populationDensity, maxCells = 950) { + if (!landuse || !populationDensity) return 0; + const seen = new Uint8Array(SIZE); + const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; + let changed = 0; + const isUrban = (i) => !sea[i] && prefectureMask[i] && (populationDensity[i] > 0.34 || [2, 3, 4, 7, 8].includes(landuse[i])); + for (let i = 0; i < SIZE; i++) { + if (seen[i] || !isUrban(i) || adminId[i] < 0) continue; + const queue = [i]; + const comp = []; + seen[i] = 1; + const counts = new Map(); + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + comp.push(cur); + const id = adminId[cur]; + if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1 + (populationDensity[cur] || 0) * 2.0); + const [x, y] = xyOf(cur); + for (const [dx, dy] of dirs) { + const nx = x + dx, ny = y + dy; + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (seen[ni] || !isUrban(ni)) continue; + seen[ni] = 1; + queue.push(ni); + } + } + if (comp.length < 8 || comp.length > maxCells || counts.size <= 1 || counts.size > 7) continue; + let best = -1, bestScore = -INF; + let total = 0; + for (const [id, score] of counts) { + total += score; + if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; } + } + if (best < 0 || bestScore / Math.max(1, total) < 0.38) continue; + for (const ci of comp) { + if (adminId[ci] !== best) { adminId[ci] = best; changed++; } + } + } + return changed; +} + + +export function splitOversizedCompartmentMunicipalities(adminId, centers, compartments, prefectureMask, sea, fields = {}, seed = 0) { + if (!compartments?.length) return { changedCells: 0, splitMunicipalities: 0, addedCenters: 0 }; + const compOwner = new Int16Array(compartments.length); + compOwner.fill(-1); + for (const comp of compartments) { + if (!comp || !comp.cells?.length) continue; + const counts = new Map(); + for (const i of comp.cells) { + if (!prefectureMask[i] || sea[i]) continue; + const id = adminId[i]; + if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); + } + let best = -1, bestCount = -1; + for (const [id, count] of counts) if (count > bestCount || (count === bestCount && id < best)) { best = id; bestCount = count; } + compOwner[comp.id] = best; + } + const byOwner = new Map(); + for (const comp of compartments) { + if (!comp || !comp.cells?.length) continue; + const owner = compOwner[comp.id]; + if (owner < 0) continue; + if (!byOwner.has(owner)) byOwner.set(owner, []); + byOwner.get(owner).push(comp); + } + const areas = [...byOwner.values()].map((list) => list.reduce((sum, comp) => sum + comp.area, 0)).sort((a, b) => a - b); + if (!areas.length) return { changedCells: 0, splitMunicipalities: 0, addedCenters: 0 }; + const median = areas[Math.floor(areas.length / 2)] || 1; + const total = areas.reduce((sum, value) => sum + value, 0); + const maxArea = Math.max(360, Math.min(total * 0.11, Math.max(median * 2.65, total / Math.max(12, Math.round(total / 520))))); + let changedCells = 0; + let splitMunicipalities = 0; + let addedCenters = 0; + const elevation = fields.elevation; + const slope = fields.slope; + const ridgeField = fields.ridgeField; + const plain = fields.plain; + const agriculture = fields.agriculture; + const basinField = fields.basinField; + const coastalLowland = fields.coastalLowland; + const populationDensity = fields.populationDensity; + for (const [owner, list] of [...byOwner.entries()].sort((a, b) => a[0] - b[0])) { + const area = list.reduce((sum, comp) => sum + comp.area, 0); + if (area <= maxArea || list.length < 4) continue; + const desiredParts = clamp(Math.ceil(area / Math.max(1, maxArea)), 2, 9); + const splitCount = desiredParts - 1; + if (splitCount <= 0) continue; + const candidates = list.map((comp) => { + let sx = 0, sy = 0, n = 0, score = 0, bestI = -1, bestScore = -INF; + for (const i of comp.cells) { + if (!prefectureMask[i] || sea[i]) continue; + const [x, y] = xyOf(i); + sx += x; sy += y; n++; + const cellScore = + (plain?.[i] || 0) * 0.22 + + (agriculture?.[i] || 0) * 0.24 + + (basinField?.[i] || 0) * 0.14 + + (coastalLowland?.[i] || 0) * 0.10 + + (populationDensity?.[i] || 0) * 0.24 - + (slope?.[i] || 0) * 0.20 - + (ridgeField?.[i] || 0) * 0.18 - + Math.max(0, (elevation?.[i] || 0) - 0.62) * 0.38; + score += cellScore; + if (cellScore > bestScore) { bestScore = cellScore; bestI = i; } + } + const [x, y] = bestI >= 0 ? xyOf(bestI) : [Math.round(sx / Math.max(1, n)), Math.round(sy / Math.max(1, n))]; + return { comp, x, y, score: score / Math.max(1, n) + Math.sqrt(comp.area) * 0.025 + hash2(seed + owner, comp.id) * 0.03 }; + }).sort((a, b) => b.score - a.score || a.comp.id - b.comp.id); + const newSeeds = []; + for (const cand of candidates) { + if (newSeeds.length >= splitCount) break; + if (newSeeds.every((s) => Math.hypot(s.x - cand.x, s.y - cand.y) >= 9)) newSeeds.push(cand); + } + if (!newSeeds.length) continue; + const seedIds = newSeeds.map((cand) => { + const id = centers.length; + centers.push({ x: cand.x, y: cand.y, score: cand.score, invisibleLowlandAdminSeed: true, seedKind: "oversizedMunicipalitySplit", splitFromAdminId: owner }); + addedCenters++; + return id; + }); + const oldCenter = centers[owner] || candidates[0] || { x: list[0].x || 0, y: list[0].y || 0, score: 0 }; + const owners = [{ id: owner, x: oldCenter.x, y: oldCenter.y, score: oldCenter.score || 0 }, ...newSeeds.map((cand, k) => ({ id: seedIds[k], x: cand.x, y: cand.y, score: cand.score }))]; + const targetArea = area / Math.max(1, owners.length); + const claimedArea = new Map(owners.map((entry) => [entry.id, 0])); + for (const cand of candidates) { + let bestSeed = owner; + let bestCost = INF; + for (const entry of owners) { + const d = Math.hypot(cand.x - entry.x, cand.y - entry.y); + const pressure = Math.max(0, ((claimedArea.get(entry.id) || 0) + cand.comp.area - targetArea * 1.25) / Math.max(1, targetArea)); + const cost = d + pressure * 18 - cand.score * 2.5 + hash2(entry.id, cand.comp.id) * 0.05; + if (cost < bestCost) { bestCost = cost; bestSeed = entry.id; } + } + claimedArea.set(bestSeed, (claimedArea.get(bestSeed) || 0) + cand.comp.area); + if (bestSeed === owner) continue; + for (const i of cand.comp.cells) { + if (!prefectureMask[i] || sea[i]) continue; + if (adminId[i] !== bestSeed) { adminId[i] = bestSeed; changedCells++; } + } + } + splitMunicipalities++; + } + return { changedCells, splitMunicipalities, addedCenters, maxArea }; +} + + diff --git a/mapAdminSeedLifecycle.js b/mapAdminSeedLifecycle.js new file mode 100644 index 0000000..234d047 --- /dev/null +++ b/mapAdminSeedLifecycle.js @@ -0,0 +1,200 @@ +import { INF, clamp, indexOf, inside } from "./mapUtils.js"; +import { applyCompartmentOwners, dominantCompartmentOwners, municipalityAreaById } from "./mapAdminShared.js"; + +export function absorbSeedCompartments(adminId, compartments, seedLifecycle) { + const owner = dominantCompartmentOwners(compartments, adminId); + const absorbed = new Set(seedLifecycle.filter((seed) => seed.state === "absorbed").map((seed) => seed.id)); + let changed = 0; + for (const unit of compartments) { + if (!unit || unit.area === 0 || !absorbed.has(owner[unit.id])) continue; + let bestId = -1, bestScore = -INF; + for (const [neighborId, edge] of unit.adjacent) { + const candidate = owner[neighborId]; + if (candidate < 0 || absorbed.has(candidate)) continue; + const neighbor = compartments[neighborId]; + const score = edge.count * 2 - (edge.target / Math.max(1, edge.count)) * 2 + (neighbor?.area || 0) * 0.002; + if (score > bestScore) { bestScore = score; bestId = candidate; } + } + if (bestId < 0) continue; + owner[unit.id] = bestId; + changed += unit.area; + } + applyCompartmentOwners(adminId, compartments, owner); + return changed; +} + +export function splitOversizedLowlandsWithPendingSeeds(adminId, prefectureMask, sea, fields, compartments, adminCenters, seedLifecycle, settlements = []) { + const { plain, agriculture, basinField, coastalLowland, valleyField, ridgeField, slope, elevation } = fields; + const areaById = municipalityAreaById(adminId, prefectureMask, sea); + const areas = [...areaById.values()].sort((a, b) => a - b); + const median = areas.length ? areas[Math.floor(areas.length / 2)] : 0; + if (!median) return { changedCells: 0, splitMunicipalities: 0, pendingSeedsUsed: 0 }; + const owner = dominantCompartmentOwners(compartments, adminId); + const unitsByOwner = new Map(); + for (const unit of compartments) { + if (!unit || unit.area === 0 || owner[unit.id] < 0) continue; + if (!unitsByOwner.has(owner[unit.id])) unitsByOwner.set(owner[unit.id], []); + unitsByOwner.get(owner[unit.id]).push(unit); + } + const pending = seedLifecycle.filter((seed) => seed.state === "pending" && !seed.protected); + let changedCells = 0; + let splitMunicipalities = 0; + let pendingSeedsUsed = 0; + for (const [id, units] of unitsByOwner) { + const area = areaById.get(id) || 0; + if (area < Math.max(260, median * 1.45) || units.length < 6) continue; + let lowland = 0, rough = 0; + for (const unit of units) { + for (const i of unit.cells) { + lowland += (plain[i] || 0) * 0.38 + (agriculture[i] || 0) * 0.20 + basinField[i] * 0.22 + coastalLowland[i] * 0.22 + valleyField[i] * 0.10; + rough += ridgeField[i] * 0.48 + slope[i] * 0.34 + Math.max(0, elevation[i] - 0.58) * 0.30; + } + } + if (lowland / area < 0.26 || rough / area > 0.48) continue; + const localPending = pending.filter((seed) => { + const center = adminCenters[seed.id]; + if (!center || !inside(center.x, center.y)) return false; + const centerOwner = adminId[indexOf(center.x, center.y)]; + return centerOwner === id || Math.hypot(center.x - (adminCenters[id]?.x || center.x), center.y - (adminCenters[id]?.y || center.y)) < 28; + }); + const localSettlements = settlements.filter((p) => p && inside(p.x, p.y) && adminId[indexOf(p.x, p.y)] === id); + if (localPending.length === 0 || localPending.length + localSettlements.length < 2) continue; + let municipalitySplit = false; + for (const seed of localPending.slice(0, 3)) { + const center = adminCenters[seed.id]; + if (!center) continue; + const targetArea = clamp(95 + (center.score || 0.5) * 60, 90, 180); + let claimed = 0; + const candidates = units + .filter((unit) => owner[unit.id] === id && unit.classId !== 8 && unit.classId !== 9) + .map((unit) => ({ + unit, + score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 8 - (unit.urbanWeight || 0) * 3, + })) + .sort((a, b) => a.score - b.score); + if (candidates.length < 2) continue; + for (const { unit } of candidates) { + if (claimed >= targetArea && claimed >= 2) break; + owner[unit.id] = seed.id; + claimed += unit.area; + changedCells += unit.area; + } + if (claimed >= 45) { + seed.state = "survived"; + seed.area = claimed; + pendingSeedsUsed++; + municipalitySplit = true; + } + } + if (municipalitySplit) splitMunicipalities++; + } + applyCompartmentOwners(adminId, compartments, owner); + return { changedCells, splitMunicipalities, pendingSeedsUsed }; +} + +export function promotePendingSeedsForMunicipalityCount(adminId, prefectureMask, sea, fields, compartments, adminCenters, seedLifecycle, targetMinCount = 20) { + const { plain, agriculture, basinField, coastalLowland, valleyField, ridgeField, slope, elevation } = fields; + let areaById = municipalityAreaById(adminId, prefectureMask, sea); + let currentCount = areaById.size; + if (currentCount >= targetMinCount) return { changedCells: 0, promotedSeeds: 0 }; + const owner = dominantCompartmentOwners(compartments, adminId); + let changedCells = 0; + let promotedSeeds = 0; + const pending = seedLifecycle + .filter((seed) => seed.state === "pending" && !seed.protected) + .sort((a, b) => (adminCenters[b.id]?.score || 0) - (adminCenters[a.id]?.score || 0)); + for (const seed of pending) { + if (currentCount >= targetMinCount) break; + const center = adminCenters[seed.id]; + if (!center || !inside(center.x, center.y)) continue; + const existingArea = areaById.get(seed.id) || 0; + if (existingArea >= 12) { + seed.state = "survived"; + seed.area = existingArea; + promotedSeeds++; + continue; + } + const candidates = compartments + .filter((unit) => { + if (!unit || unit.area === 0) return false; + const currentOwner = owner[unit.id]; + if (currentOwner < 0 || currentOwner === seed.id) return false; + const ownerArea = areaById.get(currentOwner) || 0; + if (ownerArea < 90) return false; + const lowlandFit = (unit.lowlandFitness || 0) + (unit.basinIdentity || 0) * 0.15 + (unit.coastalExposure || 0) * 0.15; + if (lowlandFit < 0.26) return false; + return Math.hypot(unit.x - center.x, unit.y - center.y) < 36; + }) + .map((unit) => ({ + unit, + score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 6 - (unit.urbanWeight || 0) * 2, + })) + .sort((a, b) => a.score - b.score); + if (candidates.length === 0) continue; + let claimed = 0; + for (const { unit } of candidates) { + const currentOwner = owner[unit.id]; + if ((areaById.get(currentOwner) || 0) - unit.area < 70) continue; + owner[unit.id] = seed.id; + areaById.set(currentOwner, (areaById.get(currentOwner) || 0) - unit.area); + areaById.set(seed.id, (areaById.get(seed.id) || 0) + unit.area); + claimed += unit.area; + changedCells += unit.area; + if (claimed >= 55) break; + } + if (claimed >= 25) { + seed.state = "survived"; + seed.area = areaById.get(seed.id) || claimed; + promotedSeeds++; + currentCount++; + } + } + applyCompartmentOwners(adminId, compartments, owner); + return { changedCells, promotedSeeds }; +} + +export function restoreSurvivedSeedsByCompartment(adminId, prefectureMask, sea, compartments, adminCenters, seedLifecycle, targetMinCount = 20) { + const areaById = municipalityAreaById(adminId, prefectureMask, sea); + let currentCount = areaById.size; + if (currentCount >= targetMinCount) return { changedCells: 0, restoredSeeds: 0 }; + const owner = dominantCompartmentOwners(compartments, adminId); + let changedCells = 0; + let restoredSeeds = 0; + const missing = seedLifecycle + .filter((seed) => (seed.state === "survived" || seed.protected) && (areaById.get(seed.id) || 0) === 0) + .sort((a, b) => (b.protected ? 1 : 0) - (a.protected ? 1 : 0)); + for (const seed of missing) { + if (currentCount >= targetMinCount) break; + const center = adminCenters[seed.id]; + if (!center || !inside(center.x, center.y)) continue; + const candidates = compartments + .filter((unit) => { + if (!unit || unit.area === 0 || unit.classId === 8 || unit.classId === 9) return false; + const currentOwner = owner[unit.id]; + if (currentOwner < 0 || currentOwner === seed.id) return false; + if ((areaById.get(currentOwner) || 0) - unit.area < 25) return false; + return Math.hypot(unit.x - center.x, unit.y - center.y) < 90 && ((unit.lowlandFitness || 0) > 0.08 || seed.protected); + }) + .map((unit) => ({ + unit, + score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 6 - (unit.urbanWeight || 0) * 2 + (unit.classId === 8 || unit.classId === 9 ? 20 : 0), + })) + .sort((a, b) => a.score - b.score); + if (candidates.length === 0) continue; + const unit = candidates[0].unit; + const oldOwner = owner[unit.id]; + if ((areaById.get(oldOwner) || 0) - unit.area < 25) continue; + owner[unit.id] = seed.id; + const claimed = unit.area; + areaById.set(oldOwner, (areaById.get(oldOwner) || 0) - claimed); + changedCells += claimed; + areaById.set(seed.id, claimed); + seed.area = claimed; + restoredSeeds++; + currentCount++; + } + applyCompartmentOwners(adminId, compartments, owner); + return { changedCells, restoredSeeds }; +} + + diff --git a/mapAdminShared.js b/mapAdminShared.js new file mode 100644 index 0000000..8d5cd10 --- /dev/null +++ b/mapAdminShared.js @@ -0,0 +1,80 @@ +import { SIZE } from "./mapUtils.js"; + +export function changedCellsSince(before, after, prefectureMask, sea) { + let changed = 0; + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== after[i]) changed++; + return changed; +} + +export function municipalityAreaById(adminId, prefectureMask, sea) { + const area = new Map(); + for (let i = 0; i < SIZE; i++) { + if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; + area.set(adminId[i], (area.get(adminId[i]) || 0) + 1); + } + return area; +} + +export function maskLandArea(mask, sea) { + let area = 0; + for (let i = 0; i < SIZE; i++) if (mask[i] && !sea[i]) area++; + return area; +} + + +export function isProtectedAdminSeed(seed) { + if (!seed) return false; + if (seed.seedKind === "capital" || seed.protectedCity?.isPrefecturalCapital) return true; + if (seed.seedKind === "modernCity" && (seed.protectedCity?.population || seed.population || 0) >= 180000) return true; + if (seed.seedKind === "port" && seed.portClass === "major") return true; + if (seed.seedKind === "satelliteCity" && (seed.protectedSatellite?.population || seed.population || 0) >= 60000) return true; + return false; +} + +export function buildSeedLifecycle(adminCenters, adminId, prefectureMask, sea, minArea = 80) { + const areaById = municipalityAreaById(adminId, prefectureMask, sea); + const lifecycle = adminCenters.map((center, id) => { + const protectedSeed = isProtectedAdminSeed(center); + const area = areaById.get(id) || 0; + const enoughArea = area >= (protectedSeed ? 28 : minArea); + return { + id, + protected: protectedSeed, + area, + state: enoughArea || protectedSeed ? "survived" : "pending", + }; + }); + return lifecycle; +} + +export function activeSeedIds(seedLifecycle) { + return new Set(seedLifecycle.filter((seed) => seed.state === "survived" || seed.protected).map((seed) => seed.id)); +} + +export function dominantCompartmentOwners(compartments, adminId) { + const owner = new Int16Array(compartments.length); + owner.fill(-1); + for (const unit of compartments) { + if (!unit || unit.area === 0) continue; + const counts = new Map(); + for (const i of unit.cells) { + const id = adminId[i]; + if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); + } + let bestId = -1, best = -1; + for (const [id, count] of counts) if (count > best) { best = count; bestId = id; } + owner[unit.id] = bestId; + } + return owner; +} + +export function applyCompartmentOwners(adminId, compartments, owner) { + for (const unit of compartments) { + if (!unit || unit.area === 0) continue; + const id = owner[unit.id]; + if (id < 0) continue; + for (const i of unit.cells) adminId[i] = id; + } +} + + diff --git a/mapAdminStage.js b/mapAdminStage.js index 1ae38bf..49a9cb2 100644 --- a/mapAdminStage.js +++ b/mapAdminStage.js @@ -5,2044 +5,42 @@ import { mergeTinyMunicipalities, removeMunicipalExclaves, smoothAdminRegionsTerrainAware, - splitOversizedLowlandMunicipalities, snapAdminBoundariesToTerrain, } from "./adminRegions.js"; -import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, xyOf } from "./mapUtils.js"; +import { INF, clamp, indexOf, inside, rand } from "./mapUtils.js"; import { extractAdminBorderSegments } from "./mapGeneratorHelpers.js"; - -function changedCellsSince(before, after, prefectureMask, sea) { - let changed = 0; - for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== after[i]) changed++; - return changed; -} - -function municipalityAreaById(adminId, prefectureMask, sea) { - const area = new Map(); - for (let i = 0; i < SIZE; i++) { - if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; - area.set(adminId[i], (area.get(adminId[i]) || 0) + 1); - } - return area; -} - -function maskLandArea(mask, sea) { - let area = 0; - for (let i = 0; i < SIZE; i++) if (mask[i] && !sea[i]) area++; - return area; -} - -function compactWholeCompartmentMunicipalities(adminId, centers, prefectureMask, sea, fields = {}) { - const activeIds = [...new Set([...adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i]))].sort((a, b) => a - b); - const idMap = new Map(activeIds.map((id, n) => [id, n])); - const compactId = new Int16Array(SIZE); - compactId.fill(-1); - const stats = activeIds.map(() => ({ sx: 0, sy: 0, count: 0, bestI: -1, bestScore: -INF })); - for (let i = 0; i < SIZE; i++) { - if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; - const nextId = idMap.get(adminId[i]); - if (nextId === undefined) continue; - compactId[i] = nextId; - const [x, y] = xyOf(i); - const row = stats[nextId]; - row.sx += x; - row.sy += y; - row.count++; - const score = (fields.populationDensity?.[i] || 0) * 2.2 + (fields.plain?.[i] || 0) * 0.25 - (fields.slope?.[i] || 0) * 0.2; - if (score > row.bestScore) { row.bestScore = score; row.bestI = i; } - } - const compactCenters = activeIds.map((oldId, newId) => { - const current = centers[oldId]; - if (current && inside(current.x, current.y) && compactId[indexOf(current.x, current.y)] === newId) { - return { ...current, originalAdminId: oldId }; - } - const row = stats[newId]; - const fallback = row.bestI >= 0 ? xyOf(row.bestI) : [Math.round(row.sx / Math.max(1, row.count)), Math.round(row.sy / Math.max(1, row.count))]; - return { - ...(current || {}), - x: fallback[0], - y: fallback[1], - originalAdminId: oldId, - generatedOfficePoint: true, - invisibleLowlandAdminSeed: current?.invisibleLowlandAdminSeed ?? true, - seedKind: current?.seedKind || "compactedMunicipalityOffice", - }; - }); - return { adminId: compactId, adminCentersRaw: compactCenters, activeMunicipalityCount: compactCenters.length }; -} - -function enforceCompartmentMunicipalityOwnership(adminId, compartmentId, compartments, prefectureMask, sea) { - if (!compartmentId || !compartments) return 0; - let changed = 0; - for (const comp of compartments) { - if (!comp || !comp.cells?.length) continue; - const counts = new Map(); - for (const i of comp.cells) { - if (!prefectureMask[i] || sea[i]) continue; - const id = adminId[i]; - if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); - } - let bestId = -1, bestCount = -1; - for (const [id, count] of counts) { - if (count > bestCount || (count === bestCount && id < bestId)) { - bestId = id; - bestCount = count; - } - } - if (bestId < 0) continue; - for (const i of comp.cells) { - if (!prefectureMask[i] || sea[i] || adminId[i] === bestId) continue; - adminId[i] = bestId; - changed++; - } - } - return changed; -} - - -function mergeSingleCompartmentMunicipalities(adminId, compartments, prefectureMask, sea, minCompartments = 2, maxPasses = 8) { - if (!compartments?.length) return { changedCells: 0, mergedMunicipalities: 0, remainingSingleCompartmentMunicipalities: 0 }; - let totalChangedCells = 0; - let mergedMunicipalities = 0; - let remainingSingles = 0; - for (let pass = 0; pass < maxPasses; pass++) { - const owner = dominantCompartmentOwners(compartments, adminId); - const byOwner = new Map(); - const areaByOwner = new Map(); - for (const unit of compartments) { - if (!unit || unit.area === 0) continue; - const id = owner[unit.id]; - if (id < 0) continue; - if (!byOwner.has(id)) byOwner.set(id, []); - byOwner.get(id).push(unit); - areaByOwner.set(id, (areaByOwner.get(id) || 0) + unit.area); - } - const singles = [...byOwner.entries()] - .filter(([, units]) => units.length > 0 && units.length < minCompartments) - .sort((a, b) => (areaByOwner.get(a[0]) || 0) - (areaByOwner.get(b[0]) || 0) || a[0] - b[0]); - remainingSingles = singles.length; - if (!singles.length) break; - let passChanged = 0; - for (const [id, units] of singles) { - // The old rule allowed one natural compartment to become one municipality. - // That produces many tiny office-only municipalities and makes the hierarchy - // hard to read. Merge such municipalities into the strongest adjacent owner. - const neighborScores = new Map(); - for (const unit of units) { - for (const [neighborId, edge] of unit.adjacent || []) { - const candidate = owner[neighborId]; - if (candidate < 0 || candidate === id) continue; - const neighborUnit = compartments[neighborId]; - const shared = edge.count || 1; - const barrier = edge.target ? edge.target / Math.max(1, shared) : 0; - const sameLandscape = neighborUnit?.classId === unit.classId ? 0.7 : 0; - const score = shared * (2.2 - Math.min(1.6, barrier) + sameLandscape) + Math.sqrt(areaByOwner.get(candidate) || 1) * 0.05; - neighborScores.set(candidate, (neighborScores.get(candidate) || 0) + score); - } - } - let best = -1, bestScore = -INF; - for (const [candidate, score] of neighborScores) { - if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; } - } - if (best < 0) { - // One-cell islets have no land adjacency. Attach them to the nearest - // existing municipality instead of leaving a one-compartment municipality. - const ux = units.reduce((sum, unit) => sum + (unit.x || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0)); - const uy = units.reduce((sum, unit) => sum + (unit.y || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0)); - let bestDist = INF; - for (const [candidate, candidateUnits] of byOwner) { - if (candidate === id || candidateUnits.length < minCompartments) continue; - for (const unit of candidateUnits) { - const d = Math.hypot((unit.x || 0) - ux, (unit.y || 0) - uy); - if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; } - } - } - if (bestDist > 28) best = -1; - } - if (best < 0) continue; - for (const unit of units) { - for (const i of unit.cells || []) { - if (!prefectureMask[i] || sea[i]) continue; - if (adminId[i] !== best) { - adminId[i] = best; - passChanged++; - } - } - } - mergedMunicipalities++; - } - totalChangedCells += passChanged; - if (!passChanged) break; - } - const finalOwner = dominantCompartmentOwners(compartments, adminId); - const finalCounts = new Map(); - for (const unit of compartments) { - if (!unit || unit.area === 0) continue; - const id = finalOwner[unit.id]; - if (id >= 0) finalCounts.set(id, (finalCounts.get(id) || 0) + 1); - } - remainingSingles = [...finalCounts.values()].filter((count) => count > 0 && count < minCompartments).length; - return { changedCells: totalChangedCells, mergedMunicipalities, remainingSingleCompartmentMunicipalities: remainingSingles }; -} - -function ownerAreaByCompartment(owner, compartments) { - const area = new Map(); - const count = new Map(); - for (const unit of compartments || []) { - if (!unit || unit.area === 0) continue; - const id = owner[unit.id]; - if (id < 0) continue; - area.set(id, (area.get(id) || 0) + (unit.area || 0)); - count.set(id, (count.get(id) || 0) + 1); - } - return { area, count }; -} - -function compartmentTouchesOutside(unit, prefectureMask, sea) { - if (!unit?.cells?.length) return true; - for (const i of unit.cells) { - const [x, y] = xyOf(i); - if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) return true; - for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { - const nx = x + dx, ny = y + dy; - if (!inside(nx, ny)) return true; - const ni = indexOf(nx, ny); - if (!prefectureMask[ni] || sea[ni]) return true; - } - } - return false; -} - -function bestNeighborOwnerForUnits(units, owner, compartments, allowNearestFallback = false) { - const { area } = ownerAreaByCompartment(owner, compartments); - const scores = new Map(); - const blocked = new Set(units.map((unit) => owner[unit.id])); - for (const unit of units) { - for (const [neighborId, edge] of unit.adjacent || []) { - const candidate = owner[neighborId]; - if (candidate < 0 || blocked.has(candidate)) continue; - const neighbor = compartments[neighborId]; - const shared = edge.count || 1; - const barrier = (edge.target || 0) / Math.max(1, shared); - const landscape = neighbor?.classId === unit.classId ? 0.75 : 0; - const lowland = Math.min(unit.lowlandFitness || 0, neighbor?.lowlandFitness || 0) * 0.5; - const score = shared * (2.4 + landscape + lowland - Math.min(1.8, barrier)) + Math.sqrt(area.get(candidate) || 1) * 0.04; - scores.set(candidate, (scores.get(candidate) || 0) + score); - } - } - let best = -1, bestScore = -INF; - for (const [candidate, score] of scores) { - if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; } - } - if (best >= 0 || !allowNearestFallback) return best; - const ux = units.reduce((sum, unit) => sum + (unit.x || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0)); - const uy = units.reduce((sum, unit) => sum + (unit.y || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0)); - let bestDist = INF; - for (const unit of compartments || []) { - if (!unit || unit.area === 0) continue; - const candidate = owner[unit.id]; - if (candidate < 0 || blocked.has(candidate)) continue; - const d = Math.hypot((unit.x || 0) - ux, (unit.y || 0) - uy); - if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; } - } - return bestDist <= 32 ? best : -1; -} - -function mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, minCompartments = 2, maxPasses = 8) { - let changedCells = 0; - let mergedMunicipalities = 0; - let remainingSingleCompartmentMunicipalities = 0; - for (let pass = 0; pass < maxPasses; pass++) { - const byOwner = new Map(); - for (const unit of compartments || []) { - if (!unit || unit.area === 0) continue; - const id = owner[unit.id]; - if (id < 0) continue; - if (!byOwner.has(id)) byOwner.set(id, []); - byOwner.get(id).push(unit); - } - const small = [...byOwner.entries()] - .filter(([, units]) => units.length > 0 && units.length < minCompartments) - .sort((a, b) => a[1].length - b[1].length || a[0] - b[0]); - remainingSingleCompartmentMunicipalities = small.length; - if (!small.length) break; - let passChanged = 0; - for (const [id, units] of small) { - const target = bestNeighborOwnerForUnits(units, owner, compartments, true); - if (target < 0 || target === id) continue; - for (const unit of units) { - if (owner[unit.id] === target) continue; - owner[unit.id] = target; - changedCells += unit.area || 0; - passChanged += unit.area || 0; - } - mergedMunicipalities++; - } - if (!passChanged) break; - } - const counts = ownerAreaByCompartment(owner, compartments).count; - remainingSingleCompartmentMunicipalities = [...counts.values()].filter((count) => count > 0 && count < minCompartments).length; - return { changedCells, mergedMunicipalities, remainingSingleCompartmentMunicipalities }; -} - -function repairCompartmentOwnerConnectivity(owner, compartments, maxPasses = 8) { - let changedCells = 0; - let changedComponents = 0; - for (let pass = 0; pass < maxPasses; pass++) { - const ownerIds = [...new Set([...owner].filter((id) => id >= 0))].sort((a, b) => a - b); - let passChanged = 0; - for (const id of ownerIds) { - const members = (compartments || []).filter((unit) => unit && unit.area > 0 && owner[unit.id] === id); - if (members.length <= 1) continue; - const memberSet = new Set(members.map((unit) => unit.id)); - const seen = new Set(); - const components = []; - for (const unit of members) { - if (seen.has(unit.id)) continue; - const queue = [unit.id]; - const comp = []; - seen.add(unit.id); - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - comp.push(compartments[cur]); - for (const next of compartments[cur]?.adjacent?.keys?.() || []) { - if (!memberSet.has(next) || seen.has(next)) continue; - seen.add(next); - queue.push(next); - } - } - components.push(comp); - } - if (components.length <= 1) continue; - components.sort((a, b) => b.reduce((sum, unit) => sum + (unit.area || 0), 0) - a.reduce((sum, unit) => sum + (unit.area || 0), 0)); - for (const comp of components.slice(1)) { - const target = bestNeighborOwnerForUnits(comp, owner, compartments, true); - if (target < 0 || target === id) continue; - for (const unit of comp) { - owner[unit.id] = target; - changedCells += unit.area || 0; - passChanged += unit.area || 0; - } - changedComponents++; - } - } - if (!passChanged) break; - } - return { changedCells, changedComponents }; -} - -function repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, maxPasses = 6) { - let changedCells = 0; - let changedComponents = 0; - const outsideCache = new Map(); - const touchesOutside = (unit) => { - if (!outsideCache.has(unit.id)) outsideCache.set(unit.id, compartmentTouchesOutside(unit, prefectureMask, sea)); - return outsideCache.get(unit.id); - }; - for (let pass = 0; pass < maxPasses; pass++) { - const ownerIds = [...new Set([...owner].filter((id) => id >= 0))].sort((a, b) => a - b); - let passChanged = 0; - for (const id of ownerIds) { - const members = (compartments || []).filter((unit) => unit && unit.area > 0 && owner[unit.id] === id); - if (!members.length) continue; - const memberSet = new Set(members.map((unit) => unit.id)); - const seen = new Set(); - for (const unit of members) { - if (seen.has(unit.id)) continue; - const queue = [unit.id]; - const comp = []; - const boundaryOwners = new Map(); - let outside = false; - seen.add(unit.id); - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - const curUnit = compartments[cur]; - if (!curUnit) continue; - comp.push(curUnit); - if (touchesOutside(curUnit)) outside = true; - for (const [next, edge] of curUnit.adjacent || []) { - const nextOwner = owner[next]; - if (nextOwner === id) { - if (!seen.has(next) && memberSet.has(next)) { seen.add(next); queue.push(next); } - } else if (nextOwner >= 0) { - boundaryOwners.set(nextOwner, (boundaryOwners.get(nextOwner) || 0) + (edge.count || 1)); - } - } - } - if (outside || boundaryOwners.size !== 1) continue; - const [target] = boundaryOwners.keys(); - if (target < 0 || target === id) continue; - for (const compUnit of comp) { - owner[compUnit.id] = target; - changedCells += compUnit.area || 0; - passChanged += compUnit.area || 0; - } - changedComponents++; - } - } - if (!passChanged) break; - } - return { changedCells, changedComponents }; -} - -function lockCompactUrbanCompartmentsToDominantOwner(owner, compartments, maxCells = 1100) { - const isUrbanUnit = (unit) => unit && unit.area > 0 && ( - unit.classId <= 3 || - (unit.urbanWeight || 0) >= 0.34 || - ((unit.urbanWeight || 0) >= 0.22 && (unit.lowlandFitness || 0) >= 0.34) - ); - const seen = new Set(); - let changedCells = 0; - let unifiedComponents = 0; - for (const start of compartments || []) { - if (!isUrbanUnit(start) || seen.has(start.id)) continue; - const queue = [start.id]; - const comp = []; - seen.add(start.id); - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - const unit = compartments[cur]; - if (!isUrbanUnit(unit)) continue; - comp.push(unit); - for (const next of unit.adjacent?.keys?.() || []) { - if (seen.has(next) || !isUrbanUnit(compartments[next])) continue; - seen.add(next); - queue.push(next); - } - } - const totalArea = comp.reduce((sum, unit) => sum + (unit.area || 0), 0); - if (comp.length <= 1 || totalArea <= 0 || totalArea > maxCells) continue; - const ownerScore = new Map(); - for (const unit of comp) { - const id = owner[unit.id]; - if (id < 0) continue; - const score = (unit.area || 0) * (1 + (unit.urbanWeight || 0) * 1.8); - ownerScore.set(id, (ownerScore.get(id) || 0) + score); - } - let best = -1, bestScore = -INF; - for (const [id, score] of ownerScore) if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; } - if (best < 0) continue; - let localChanged = 0; - for (const unit of comp) { - if (owner[unit.id] === best) continue; - owner[unit.id] = best; - localChanged += unit.area || 0; - } - if (localChanged > 0) { - changedCells += localChanged; - unifiedComponents++; - } - } - return { changedCells, unifiedComponents }; -} - - -function lockCityMetroCompartmentsToSingleMunicipality(owner, compartments, fields = {}) { - const { modernCities = [] } = fields; - if (!owner || !compartments?.length || !modernCities?.length) return { changedCells: 0, unifiedCities: 0 }; - let changedCells = 0; - let unifiedCities = 0; - const urbanUnit = (unit) => unit && unit.area > 0 && ( - unit.classId <= 3 || - (unit.urbanWeight || 0) >= 0.24 || - ((unit.urbanWeight || 0) >= 0.16 && (unit.lowlandFitness || 0) >= 0.40) - ); - for (const city of modernCities) { - if (!city || !Number.isFinite(city.x) || !Number.isFinite(city.y) || (city.population || 0) < 18000) continue; - const radius = clamp( - (city.urbanRadius || 8) * ((city.population || 0) >= 200000 ? 1.95 : (city.population || 0) >= 80000 ? 1.65 : 1.35), - 8, - (city.population || 0) >= 200000 ? 34 : 24 - ); - const units = []; - for (const unit of compartments) { - if (!urbanUnit(unit)) continue; - const d = Math.hypot((unit.x || 0) - city.x, (unit.y || 0) - city.y); - if (d > radius) continue; - const weight = (unit.area || 1) * - (1 + (unit.urbanWeight || 0) * 2.4 + (unit.lowlandFitness || 0) * 0.55) * - Math.max(0.20, 1 - d / Math.max(1, radius) * 0.58); - units.push({ unit, weight, d }); - } - if (units.length <= 1) continue; - const totalArea = units.reduce((sum, row) => sum + (row.unit.area || 0), 0); - // Large multi-core conurbations may legitimately contain multiple municipalities. - // This pass targets compact urban areas that visually read as one city. - const maxArea = (city.population || 0) >= 200000 ? 1800 : 900; - if (totalArea > maxArea) continue; - const ownerScore = new Map(); - for (const row of units) { - const id = owner[row.unit.id]; - if (id < 0) continue; - ownerScore.set(id, (ownerScore.get(id) || 0) + row.weight); - } - if (ownerScore.size <= 1) continue; - let best = -1, bestScore = -INF, totalScore = 0; - for (const [id, score] of ownerScore) { - totalScore += score; - if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; } - } - if (best < 0 || bestScore / Math.max(1, totalScore) < 0.28) continue; - let localChanged = 0; - for (const row of units) { - if (owner[row.unit.id] === best) continue; - owner[row.unit.id] = best; - localChanged += row.unit.area || 0; - } - if (localChanged > 0) { - changedCells += localChanged; - unifiedCities++; - } - } - return { changedCells, unifiedCities }; -} - -function enforceSimpleAdministrativeHierarchy(adminId, compartments, prefectureMask, sea, fields = {}) { - const owner = dominantCompartmentOwners(compartments, adminId); - const urban = lockCompactUrbanCompartmentsToDominantOwner(owner, compartments, fields.maxUrbanClusterCells || 1100); - const metro = lockCityMetroCompartmentsToSingleMunicipality(owner, compartments, fields); - const connectivity1 = repairCompartmentOwnerConnectivity(owner, compartments, 8); - const enclave1 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 6); - const singleMerge = mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, fields.minCompartmentsPerMunicipality || 2, 8); - const connectivity2 = repairCompartmentOwnerConnectivity(owner, compartments, 8); - const enclave2 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 6); - const singleMerge2 = mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, fields.minCompartmentsPerMunicipality || 2, 4); - const connectivity3 = repairCompartmentOwnerConnectivity(owner, compartments, 4); - const enclave3 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 4); - applyCompartmentOwners(adminId, compartments, owner); - const counts = ownerAreaByCompartment(owner, compartments).count; - return { - changedAfterUrbanUnification: urban.changedCells + metro.changedCells, - urbanComponentsUnified: urban.unifiedComponents, - changedAfterCityMetroMunicipalityUnification: metro.changedCells, - cityMetroMunicipalitiesUnified: metro.unifiedCities, - changedAfterCompartmentConnectivity: connectivity1.changedCells + connectivity2.changedCells + connectivity3.changedCells, - disconnectedCompartmentComponentsMerged: connectivity1.changedComponents + connectivity2.changedComponents + connectivity3.changedComponents, - changedAfterCompartmentEnclaveRepair: enclave1.changedCells + enclave2.changedCells + enclave3.changedCells, - compartmentEnclaveComponentsMerged: enclave1.changedComponents + enclave2.changedComponents + enclave3.changedComponents, - changedAfterSingleCompartmentMunicipalityMerge: singleMerge.changedCells + singleMerge2.changedCells, - singleCompartmentMunicipalitiesMerged: singleMerge.mergedMunicipalities + singleMerge2.mergedMunicipalities, - remainingSingleCompartmentMunicipalities: [...counts.values()].filter((count) => count > 0 && count < (fields.minCompartmentsPerMunicipality || 2)).length, - }; -} - -function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity, settlementFeatures = []) { - const nodes = new Map(); - const edges = new Map(); - for (let i = 0; i < SIZE; i++) { - if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; - const id = adminId[i]; - if (!nodes.has(id)) nodes.set(id, { id, area: 0, population: 0, cityPopulation: 0, settlementPopulation: 0, majorCityCount: 0, sx: 0, sy: 0, touchesOutside: false }); - const node = nodes.get(id); - const [x, y] = xyOf(i); - if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) node.touchesOutside = true; - for (const [ox, oy] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) { - if (!inside(ox, oy)) { node.touchesOutside = true; continue; } - const oi = indexOf(ox, oy); - if (!prefectureMask[oi] || sea[oi]) node.touchesOutside = true; - } - node.area++; - node.population += populationDensity?.[i] || 0; - node.sx += x; - node.sy += y; - for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { - if (!inside(nx, ny)) continue; - const ni = indexOf(nx, ny); - if (!prefectureMask[ni] || sea[ni] || adminId[ni] < 0 || adminId[ni] === id) continue; - const a = Math.min(id, adminId[ni]); - const b = Math.max(id, adminId[ni]); - const key = `${a}:${b}`; - const edge = edges.get(key) || { a, b, count: 0, barrier: 0 }; - edge.count++; - edge.barrier += ((naturalBarrierScore?.[i] || 0) + (naturalBarrierScore?.[ni] || 0)) * 0.5; - edges.set(key, edge); - } - } - for (const feature of settlementFeatures || []) { - if (!feature || !inside(feature.x, feature.y)) continue; - const i = indexOf(feature.x, feature.y); - if (!prefectureMask[i] || sea[i]) continue; - const id = adminId[i]; - const node = nodes.get(id); - if (!node) continue; - const pop = Math.max(0, feature.population || 0); - node.settlementPopulation += pop; - if (feature.kind === "Regional Capital" || feature.kind === "Prefectural Capital" || feature.kind === "Regional City" || feature.kind === "Local City" || feature.isRegionalCapital || feature.isPrefecturalCapital) { - node.cityPopulation += pop; - node.majorCityCount += pop >= 120000 ? 1 : 0; - } - node.population += pop / 16000; - } - for (const node of nodes.values()) { - node.x = node.sx / Math.max(1, node.area); - node.y = node.sy / Math.max(1, node.area); - node.adjacent = new Map(); - } - for (const edge of edges.values()) { - edge.barrier /= Math.max(1, edge.count); - // Prefecture grouping should pay the same natural-compartment crossing - // cost that municipality generation uses: ridges, rivers, valley walls and - // other strong natural dividers should be expensive to cross. Short shared - // boundaries are also unstable, so they get a small extra penalty. - edge.crossingCost = 1.0 + edge.barrier * 8.5 + 2.6 / Math.sqrt(Math.max(1, edge.count)); - nodes.get(edge.a)?.adjacent.set(edge.b, edge); - nodes.get(edge.b)?.adjacent.set(edge.a, edge); - } - return { nodes, edges }; -} - -function choosePrefectureMunicipalitySeeds(nodes, seed) { - const active = [...nodes.values()].filter((node) => node.area > 0).sort((a, b) => b.area - a.area || a.id - b.id); - const totalArea = active.reduce((sum, node) => sum + node.area, 0); - // Real Japan's smallest prefecture by municipality count is roughly Toyama's 15. - // Keep generated prefectures near that scale by limiting prefecture count unless - // enough municipalities exist to give each prefecture a meaningful set. - const minMunicipalitiesPerPrefecture = 14; - const maxByMunicipalityCount = Math.max(3, Math.floor(active.length / minMunicipalitiesPerPrefecture)); - const areaBased = clamp(Math.round(totalArea / 7800), 3, 6); - const targetCount = clamp(Math.min(areaBased, maxByMunicipalityCount || areaBased), 3, 6); - const seeds = []; - const minSpacing = Math.max(18, Math.sqrt(totalArea / Math.max(1, targetCount)) * 0.46); - function tryAdd(node, relaxed = false) { - if (!node || seeds.includes(node) || seeds.length >= targetCount) return false; - const nearest = seeds.length ? Math.min(...seeds.map((s) => Math.hypot(node.x - s.x, node.y - s.y))) : INF; - if (!relaxed && nearest < minSpacing) return false; - seeds.push(node); - return true; - } - const capitalLike = active - .filter((node) => (node.cityPopulation || 0) >= 120000 || node.majorCityCount > 0) - .sort((a, b) => (b.cityPopulation || 0) - (a.cityPopulation || 0) || b.population - a.population || a.id - b.id); - for (const node of capitalLike) tryAdd(node, false); - for (const node of capitalLike) tryAdd(node, true); - if (!seeds.length) { - const first = active.slice().sort((a, b) => (b.population * 1.8 + b.area * 0.08) - (a.population * 1.8 + a.area * 0.08) || a.id - b.id)[0]; - if (first) seeds.push(first); - } - while (seeds.length < targetCount) { - let best = null, bestScore = -INF; - for (const node of active) { - if (seeds.includes(node)) continue; - const nearest = Math.min(...seeds.map((s) => Math.hypot(node.x - s.x, node.y - s.y))); - const capitalBonus = Math.sqrt(Math.max(0, node.cityPopulation || 0)) * 0.05 + (node.majorCityCount || 0) * 8; - const score = nearest * (1.0 + hash2(seed, node.id) * 0.08) + Math.sqrt(node.area) * 0.12 + node.population * 0.28 + capitalBonus; - if (score > bestScore) { bestScore = score; best = node; } - } - if (!best) break; - seeds.push(best); - } - seeds.minMunicipalitiesPerPrefecture = minMunicipalitiesPerPrefecture; - return seeds; -} - -function assignMunicipalitiesToPrefectures(nodes, seeds) { - const owner = new Map(); - const area = new Map(); - const heap = new MinHeap(); - seeds.forEach((node, id) => { - owner.set(node.id, id); - area.set(id, node.area); - heap.push({ i: node.id, id, f: 0 }); - }); - const totalArea = [...nodes.values()].reduce((sum, node) => sum + node.area, 0); - const maxArea = Math.max(900, totalArea * 0.30); - while (heap.length) { - const cur = heap.pop(); - if (!cur || owner.get(cur.i) !== cur.id) continue; - const node = nodes.get(cur.i); - if (!node) continue; - for (const [nextId, edge] of node.adjacent) { - if (owner.has(nextId)) continue; - const next = nodes.get(nextId); - if (!next) continue; - const areaPressure = Math.max(0, ((area.get(cur.id) || 0) + next.area - maxArea) / Math.max(1, maxArea)); - const cost = cur.f + (edge.crossingCost ?? (1.0 + edge.barrier * 8.5)) + areaPressure * 14 + hash2(cur.id, nextId) * 0.05; - owner.set(nextId, cur.id); - area.set(cur.id, (area.get(cur.id) || 0) + next.area); - heap.push({ i: nextId, id: cur.id, f: cost }); - } - } - let fallback = 0; - for (const id of [...nodes.keys()].sort((a, b) => a - b)) { - if (!owner.has(id)) owner.set(id, fallback++ % Math.max(1, seeds.length)); - } - return owner; -} - -function repairPrefectureMunicipalityConnectivity(nodes, owner) { - let changed = 0; - for (let pass = 0; pass < 8; pass++) { - let passChanged = 0; - const prefIds = [...new Set(owner.values())].sort((a, b) => a - b); - for (const prefId of prefIds) { - const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId); - const memberSet = new Set(members); - const seen = new Set(); - const components = []; - for (const start of members) { - if (seen.has(start)) continue; - const queue = [start]; - const comp = []; - seen.add(start); - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - comp.push(cur); - for (const next of nodes.get(cur)?.adjacent.keys() || []) { - if (!memberSet.has(next) || seen.has(next)) continue; - seen.add(next); - queue.push(next); - } - } - components.push(comp); - } - if (components.length <= 1) continue; - components.sort((a, b) => b.length - a.length); - for (const comp of components.slice(1)) { - const neighborCounts = new Map(); - for (const id of comp) { - for (const next of nodes.get(id)?.adjacent.keys() || []) { - const nOwner = owner.get(next); - if (nOwner !== prefId) neighborCounts.set(nOwner, (neighborCounts.get(nOwner) || 0) + 1); - } - } - let best = -1, bestCount = -1; - for (const [id, count] of neighborCounts) if (count > bestCount || (count === bestCount && id < best)) { best = id; bestCount = count; } - if (best < 0) continue; - for (const id of comp) owner.set(id, best); - passChanged += comp.length; - } - } - changed += passChanged; - if (!passChanged) break; - } - return changed; -} - -function repairPrefectureMunicipalityEnclaves(nodes, owner, maxPasses = 6) { - let changed = 0; - for (let pass = 0; pass < maxPasses; pass++) { - let passChanged = 0; - const prefIds = [...new Set(owner.values())].filter((id) => id >= 0).sort((a, b) => a - b); - for (const prefId of prefIds) { - const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId); - const memberSet = new Set(members); - const seen = new Set(); - for (const start of members) { - if (seen.has(start)) continue; - const queue = [start]; - const comp = []; - seen.add(start); - let touchesOutside = false; - const boundaryPrefs = new Map(); - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - comp.push(cur); - const node = nodes.get(cur); - if (node?.touchesOutside) touchesOutside = true; - for (const next of node?.adjacent.keys() || []) { - const nextOwner = owner.get(next); - if (nextOwner === prefId) { - if (!seen.has(next)) { seen.add(next); queue.push(next); } - } else if (nextOwner >= 0) { - boundaryPrefs.set(nextOwner, (boundaryPrefs.get(nextOwner) || 0) + 1); - } - } - } - if (touchesOutside || boundaryPrefs.size !== 1) continue; - const [targetPref] = boundaryPrefs.keys(); - if (targetPref < 0 || targetPref === prefId) continue; - for (const id of comp) owner.set(id, targetPref); - passChanged += comp.length; - } - } - changed += passChanged; - if (!passChanged) break; - } - return changed; -} - -function lockCompactUrbanMunicipalitiesToSinglePrefecture(owner, adminId, context, maxCells = 2600) { - const { prefectureMask, sea, landuse, populationDensity } = context; - if (!owner || !adminId || !landuse) return 0; - const seen = new Uint8Array(SIZE); - const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; - const isUrban = (i) => { - const lu = landuse[i]; - return lu === 2 || lu === 3 || lu === 4 || lu === 7 || lu === 8 || (populationDensity?.[i] || 0) > 0.18; - }; - let changedMunicipalities = 0; - for (let start = 0; start < SIZE; start++) { - if (seen[start] || !prefectureMask[start] || sea[start] || adminId[start] < 0 || !isUrban(start)) continue; - const queue = [start]; - const comp = []; - seen[start] = 1; - const municipalityWeights = new Map(); - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - comp.push(cur); - const id = adminId[cur]; - const weight = 1 + Math.max(0, (populationDensity?.[cur] || 0) - 0.12) * 2.4 + (landuse[cur] === 3 ? 2.0 : landuse[cur] === 2 ? 1.2 : 0); - municipalityWeights.set(id, (municipalityWeights.get(id) || 0) + weight); - const [x, y] = xyOf(cur); - for (const [dx, dy] of dirs) { - const nx = x + dx, ny = y + dy; - if (!inside(nx, ny)) continue; - const ni = indexOf(nx, ny); - if (seen[ni] || !prefectureMask[ni] || sea[ni] || adminId[ni] < 0 || !isUrban(ni)) continue; - seen[ni] = 1; - queue.push(ni); - } - } - if (comp.length < 14 || comp.length > maxCells || municipalityWeights.size <= 1) continue; - const prefectureWeights = new Map(); - for (const [munId, weight] of municipalityWeights) { - const prefId = owner.get(munId); - if (prefId < 0) continue; - prefectureWeights.set(prefId, (prefectureWeights.get(prefId) || 0) + weight); - } - if (prefectureWeights.size <= 1) continue; - let bestPref = -1, bestWeight = -INF, totalWeight = 0; - for (const [prefId, weight] of prefectureWeights) { - totalWeight += weight; - if (weight > bestWeight || (weight === bestWeight && prefId < bestPref)) { bestPref = prefId; bestWeight = weight; } - } - if (bestPref < 0 || bestWeight / Math.max(1, totalWeight) < 0.34) continue; - for (const munId of municipalityWeights.keys()) { - if (owner.get(munId) === bestPref) continue; - owner.set(munId, bestPref); - changedMunicipalities++; - } - } - return changedMunicipalities; -} - -function lockCityMetroMunicipalitiesToSinglePrefecture(owner, adminId, context) { - const { prefectureMask, sea, landuse, populationDensity, modernCities = [] } = context; - if (!owner || !adminId || !modernCities?.length) return 0; - let changed = 0; - const isUrban = (i) => { - const lu = landuse?.[i]; - return lu === 2 || lu === 3 || lu === 4 || lu === 7 || lu === 8 || (populationDensity?.[i] || 0) > 0.14; - }; - for (const city of modernCities) { - if (!city || (city.population || 0) < 24000 || !inside(city.x, city.y)) continue; - const centerAdmin = adminId[indexOf(city.x, city.y)]; - if (centerAdmin < 0) continue; - const centerPref = owner.get(centerAdmin); - if (centerPref < 0) continue; - const radius = clamp(Math.round((city.urbanRadius || 8) * ((city.population || 0) >= 160000 ? 1.55 : 1.25)), 7, 26); - const municipalityWeights = new Map(); - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - const x = city.x + dx; - const y = city.y + dy; - if (!inside(x, y) || Math.hypot(dx, dy) > radius) continue; - const i = indexOf(x, y); - if (!prefectureMask[i] || sea[i] || adminId[i] < 0 || !isUrban(i)) continue; - const dist = Math.hypot(dx, dy) / Math.max(1, radius); - const weight = (1 - dist * 0.55) * (1 + (populationDensity?.[i] || 0) * 2.6 + (landuse?.[i] === 3 ? 1.6 : 0)); - municipalityWeights.set(adminId[i], (municipalityWeights.get(adminId[i]) || 0) + weight); - } - } - if (municipalityWeights.size <= 1) continue; - let total = 0, centerOwnedWeight = 0; - for (const [munId, weight] of municipalityWeights) { - total += weight; - if (owner.get(munId) === centerPref) centerOwnedWeight += weight; - } - // Only force compact city regions. If the center prefecture has almost no - // share, this is probably a genuine cross-prefecture conurbation or a city - // center on the edge; leave it to the graph repair. - if (centerOwnedWeight / Math.max(1, total) < 0.24) continue; - for (const munId of municipalityWeights.keys()) { - if (owner.get(munId) === centerPref) continue; - owner.set(munId, centerPref); - changed++; - } - } - return changed; -} - -function repairPrefectureCellEnclavesFromGrid(adminId, owner, prefectureMask, sea, maxPasses = 5) { - let changed = 0; - const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; - for (let pass = 0; pass < maxPasses; pass++) { - const prefId = new Int16Array(SIZE); - prefId.fill(-1); - for (let i = 0; i < SIZE; i++) { - if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; - prefId[i] = owner.get(adminId[i]) ?? -1; - } - const seen = new Uint8Array(SIZE); - let passChanged = 0; - for (let i = 0; i < SIZE; i++) { - if (seen[i] || prefId[i] < 0) continue; - const id = prefId[i]; - const queue = [i]; - const comp = []; - seen[i] = 1; - let touchesOutside = false; - const boundaryCounts = new Map(); - const adminCounts = new Map(); - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - comp.push(cur); - const aid = adminId[cur]; - if (aid >= 0) adminCounts.set(aid, (adminCounts.get(aid) || 0) + 1); - const [x, y] = xyOf(cur); - if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesOutside = true; - for (const [dx, dy] of dirs) { - const nx = x + dx, ny = y + dy; - if (!inside(nx, ny)) { touchesOutside = true; continue; } - const ni = indexOf(nx, ny); - if (!prefectureMask[ni] || sea[ni]) { touchesOutside = true; continue; } - const nid = prefId[ni]; - if (nid === id) { - if (!seen[ni]) { seen[ni] = 1; queue.push(ni); } - } else if (nid >= 0) { - boundaryCounts.set(nid, (boundaryCounts.get(nid) || 0) + 1); - } - } - } - if (touchesOutside || boundaryCounts.size !== 1) continue; - const [targetPref] = boundaryCounts.keys(); - if (targetPref < 0 || targetPref === id) continue; - for (const aid of adminCounts.keys()) { - if (owner.get(aid) !== targetPref) { owner.set(aid, targetPref); passChanged++; } - } - } - changed += passChanged; - if (!passChanged) break; - } - return changed; -} - -function repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, maxPasses = 5) { - let changed = 0; - const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; - for (let pass = 0; pass < maxPasses; pass++) { - const seen = new Uint8Array(SIZE); - let passChanged = 0; - for (let i = 0; i < SIZE; i++) { - if (seen[i] || !prefectureMask[i] || sea[i] || adminId[i] < 0) continue; - const id = adminId[i]; - const queue = [i]; - const comp = []; - seen[i] = 1; - let touchesOutside = false; - const boundaryCounts = new Map(); - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - comp.push(cur); - const [x, y] = xyOf(cur); - if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesOutside = true; - for (const [dx, dy] of dirs) { - const nx = x + dx, ny = y + dy; - if (!inside(nx, ny)) { touchesOutside = true; continue; } - const ni = indexOf(nx, ny); - if (!prefectureMask[ni] || sea[ni]) { touchesOutside = true; continue; } - const nid = adminId[ni]; - if (nid === id) { - if (!seen[ni]) { seen[ni] = 1; queue.push(ni); } - } else if (nid >= 0) { - boundaryCounts.set(nid, (boundaryCounts.get(nid) || 0) + 1); - } - } - } - if (touchesOutside || boundaryCounts.size !== 1) continue; - const [targetId] = boundaryCounts.keys(); - if (targetId < 0 || targetId === id) continue; - for (const ci of comp) adminId[ci] = targetId; - passChanged += comp.length; - } - changed += passChanged; - if (!passChanged) break; - } - return changed; -} - -function lockCompactUrbanAreasToDominantAdmin(adminId, prefectureMask, sea, landuse, populationDensity, maxCells = 950) { - if (!landuse || !populationDensity) return 0; - const seen = new Uint8Array(SIZE); - const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; - let changed = 0; - const isUrban = (i) => !sea[i] && prefectureMask[i] && (populationDensity[i] > 0.34 || [2, 3, 4, 7, 8].includes(landuse[i])); - for (let i = 0; i < SIZE; i++) { - if (seen[i] || !isUrban(i) || adminId[i] < 0) continue; - const queue = [i]; - const comp = []; - seen[i] = 1; - const counts = new Map(); - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - comp.push(cur); - const id = adminId[cur]; - if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1 + (populationDensity[cur] || 0) * 2.0); - const [x, y] = xyOf(cur); - for (const [dx, dy] of dirs) { - const nx = x + dx, ny = y + dy; - if (!inside(nx, ny)) continue; - const ni = indexOf(nx, ny); - if (seen[ni] || !isUrban(ni)) continue; - seen[ni] = 1; - queue.push(ni); - } - } - if (comp.length < 8 || comp.length > maxCells || counts.size <= 1 || counts.size > 7) continue; - let best = -1, bestScore = -INF; - let total = 0; - for (const [id, score] of counts) { - total += score; - if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; } - } - if (best < 0 || bestScore / Math.max(1, total) < 0.38) continue; - for (const ci of comp) { - if (adminId[ci] !== best) { adminId[ci] = best; changed++; } - } - } - return changed; -} - -function mergeTinyMunicipalityPrefectures(nodes, owner) { - let changed = 0; - for (let pass = 0; pass < 6; pass++) { - const areaByPref = new Map(); - for (const node of nodes.values()) { - const pref = owner.get(node.id); - areaByPref.set(pref, (areaByPref.get(pref) || 0) + node.area); - } - const totalArea = [...areaByPref.values()].reduce((sum, value) => sum + value, 0); - const minArea = Math.max(520, totalArea / Math.max(1, areaByPref.size) * 0.34); - const tiny = [...areaByPref.entries()] - .filter(([, area]) => area > 0 && area < minArea && areaByPref.size > 4) - .sort((a, b) => a[1] - b[1] || a[0] - b[0])[0]; - if (!tiny) break; - const [tinyPref] = tiny; - const neighborScores = new Map(); - for (const node of nodes.values()) { - if (owner.get(node.id) !== tinyPref) continue; - for (const [nextId, edge] of node.adjacent) { - const nextPref = owner.get(nextId); - if (nextPref === tinyPref || nextPref < 0) continue; - const score = (neighborScores.get(nextPref) || 0) + edge.count * 0.8 - (edge.crossingCost ?? (1.0 + edge.barrier * 8.5)) * 0.65; - neighborScores.set(nextPref, score); - } - } - let best = -1, bestScore = -INF; - for (const [pref, score] of neighborScores) { - if (score > bestScore || (score === bestScore && pref < best)) { best = pref; bestScore = score; } - } - if (best < 0) break; - for (const node of nodes.values()) if (owner.get(node.id) === tinyPref) { owner.set(node.id, best); changed++; } - } - return changed; -} - - -function splitOversizedCompartmentMunicipalities(adminId, centers, compartments, prefectureMask, sea, fields = {}, seed = 0) { - if (!compartments?.length) return { changedCells: 0, splitMunicipalities: 0, addedCenters: 0 }; - const compOwner = new Int16Array(compartments.length); - compOwner.fill(-1); - for (const comp of compartments) { - if (!comp || !comp.cells?.length) continue; - const counts = new Map(); - for (const i of comp.cells) { - if (!prefectureMask[i] || sea[i]) continue; - const id = adminId[i]; - if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); - } - let best = -1, bestCount = -1; - for (const [id, count] of counts) if (count > bestCount || (count === bestCount && id < best)) { best = id; bestCount = count; } - compOwner[comp.id] = best; - } - const byOwner = new Map(); - for (const comp of compartments) { - if (!comp || !comp.cells?.length) continue; - const owner = compOwner[comp.id]; - if (owner < 0) continue; - if (!byOwner.has(owner)) byOwner.set(owner, []); - byOwner.get(owner).push(comp); - } - const areas = [...byOwner.values()].map((list) => list.reduce((sum, comp) => sum + comp.area, 0)).sort((a, b) => a - b); - if (!areas.length) return { changedCells: 0, splitMunicipalities: 0, addedCenters: 0 }; - const median = areas[Math.floor(areas.length / 2)] || 1; - const total = areas.reduce((sum, value) => sum + value, 0); - const maxArea = Math.max(360, Math.min(total * 0.11, Math.max(median * 2.65, total / Math.max(12, Math.round(total / 520))))); - let changedCells = 0; - let splitMunicipalities = 0; - let addedCenters = 0; - const elevation = fields.elevation; - const slope = fields.slope; - const ridgeField = fields.ridgeField; - const plain = fields.plain; - const agriculture = fields.agriculture; - const basinField = fields.basinField; - const coastalLowland = fields.coastalLowland; - const populationDensity = fields.populationDensity; - for (const [owner, list] of [...byOwner.entries()].sort((a, b) => a[0] - b[0])) { - const area = list.reduce((sum, comp) => sum + comp.area, 0); - if (area <= maxArea || list.length < 4) continue; - const desiredParts = clamp(Math.ceil(area / Math.max(1, maxArea)), 2, 9); - const splitCount = desiredParts - 1; - if (splitCount <= 0) continue; - const candidates = list.map((comp) => { - let sx = 0, sy = 0, n = 0, score = 0, bestI = -1, bestScore = -INF; - for (const i of comp.cells) { - if (!prefectureMask[i] || sea[i]) continue; - const [x, y] = xyOf(i); - sx += x; sy += y; n++; - const cellScore = - (plain?.[i] || 0) * 0.22 + - (agriculture?.[i] || 0) * 0.24 + - (basinField?.[i] || 0) * 0.14 + - (coastalLowland?.[i] || 0) * 0.10 + - (populationDensity?.[i] || 0) * 0.24 - - (slope?.[i] || 0) * 0.20 - - (ridgeField?.[i] || 0) * 0.18 - - Math.max(0, (elevation?.[i] || 0) - 0.62) * 0.38; - score += cellScore; - if (cellScore > bestScore) { bestScore = cellScore; bestI = i; } - } - const [x, y] = bestI >= 0 ? xyOf(bestI) : [Math.round(sx / Math.max(1, n)), Math.round(sy / Math.max(1, n))]; - return { comp, x, y, score: score / Math.max(1, n) + Math.sqrt(comp.area) * 0.025 + hash2(seed + owner, comp.id) * 0.03 }; - }).sort((a, b) => b.score - a.score || a.comp.id - b.comp.id); - const newSeeds = []; - for (const cand of candidates) { - if (newSeeds.length >= splitCount) break; - if (newSeeds.every((s) => Math.hypot(s.x - cand.x, s.y - cand.y) >= 9)) newSeeds.push(cand); - } - if (!newSeeds.length) continue; - const seedIds = newSeeds.map((cand) => { - const id = centers.length; - centers.push({ x: cand.x, y: cand.y, score: cand.score, invisibleLowlandAdminSeed: true, seedKind: "oversizedMunicipalitySplit", splitFromAdminId: owner }); - addedCenters++; - return id; - }); - const oldCenter = centers[owner] || candidates[0] || { x: list[0].x || 0, y: list[0].y || 0, score: 0 }; - const owners = [{ id: owner, x: oldCenter.x, y: oldCenter.y, score: oldCenter.score || 0 }, ...newSeeds.map((cand, k) => ({ id: seedIds[k], x: cand.x, y: cand.y, score: cand.score }))]; - const targetArea = area / Math.max(1, owners.length); - const claimedArea = new Map(owners.map((entry) => [entry.id, 0])); - for (const cand of candidates) { - let bestSeed = owner; - let bestCost = INF; - for (const entry of owners) { - const d = Math.hypot(cand.x - entry.x, cand.y - entry.y); - const pressure = Math.max(0, ((claimedArea.get(entry.id) || 0) + cand.comp.area - targetArea * 1.25) / Math.max(1, targetArea)); - const cost = d + pressure * 18 - cand.score * 2.5 + hash2(entry.id, cand.comp.id) * 0.05; - if (cost < bestCost) { bestCost = cost; bestSeed = entry.id; } - } - claimedArea.set(bestSeed, (claimedArea.get(bestSeed) || 0) + cand.comp.area); - if (bestSeed === owner) continue; - for (const i of cand.comp.cells) { - if (!prefectureMask[i] || sea[i]) continue; - if (adminId[i] !== bestSeed) { adminId[i] = bestSeed; changedCells++; } - } - } - splitMunicipalities++; - } - return { changedCells, splitMunicipalities, addedCenters, maxArea }; -} - -function extractPrefectureBordersFromMunicipalities(adminId, municipalityToPrefectureId, prefectureMask, sea) { - const segments = []; - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); - if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; - const aPref = municipalityToPrefectureId[adminId[i]] ?? -1; - if (x + 1 < MAP_W) { - const ni = indexOf(x + 1, y); - const bPref = !sea[ni] && adminId[ni] >= 0 ? municipalityToPrefectureId[adminId[ni]] ?? -1 : -1; - if (prefectureMask[ni] && bPref >= 0 && aPref >= 0 && bPref !== aPref && adminId[ni] !== adminId[i]) segments.push([[x + 1, y], [x + 1, y + 1]]); - } - if (y + 1 < MAP_H) { - const ni = indexOf(x, y + 1); - const bPref = !sea[ni] && adminId[ni] >= 0 ? municipalityToPrefectureId[adminId[ni]] ?? -1 : -1; - if (prefectureMask[ni] && bPref >= 0 && aPref >= 0 && bPref !== aPref && adminId[ni] !== adminId[i]) segments.push([[x, y + 1], [x + 1, y + 1]]); - } - } - } - return segments; -} - - -function prefectureMunicipalityCounts(owner) { - const counts = new Map(); - for (const pref of owner.values()) counts.set(pref, (counts.get(pref) || 0) + 1); - return counts; -} - -function ownerMembersByPref(owner) { - const by = new Map(); - for (const [id, pref] of owner) { - if (!by.has(pref)) by.set(pref, []); - by.get(pref).push(id); - } - return by; -} - -function wouldRemainConnectedAfterRemoval(nodes, owner, adminId, prefId) { - const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId && id !== adminId); - if (members.length <= 1) return true; - const memberSet = new Set(members); - const seen = new Set([members[0]]); - const queue = [members[0]]; - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - for (const next of nodes.get(cur)?.adjacent.keys() || []) { - if (!memberSet.has(next) || seen.has(next)) continue; - seen.add(next); - queue.push(next); - } - } - return seen.size === members.length; -} - -function rebalanceSmallPrefecturesByMunicipalityCount(nodes, owner, minCount = 14, maxPasses = 96) { - let changed = 0; - for (let pass = 0; pass < maxPasses; pass++) { - const counts = prefectureMunicipalityCounts(owner); - const small = [...counts.entries()].filter(([, count]) => count > 0 && count < minCount).sort((a, b) => a[1] - b[1] || a[0] - b[0])[0]; - if (!small) break; - const [smallPref, smallCount] = small; - let best = null, bestScore = -INF; - for (const [id, pref] of owner) { - if (pref === smallPref) continue; - const donorCount = counts.get(pref) || 0; - if (donorCount <= minCount + 1) continue; - const node = nodes.get(id); - if (!node) continue; - let edgeToSmall = null; - for (const [nextId, edge] of node.adjacent || []) { - if (owner.get(nextId) === smallPref) { - edgeToSmall = edge; - break; - } - } - if (!edgeToSmall) continue; - if (!wouldRemainConnectedAfterRemoval(nodes, owner, id, pref)) continue; - const capitalPenalty = (node.cityPopulation || 0) >= 150000 ? 18 : 0; - const donorSurplus = donorCount - minCount; - const score = (edgeToSmall.count || 1) * 2.5 - (edgeToSmall.crossingCost ?? (1.0 + (edgeToSmall.barrier || 0) * 8.5)) * 1.15 + donorSurplus * 1.6 - Math.sqrt(node.area || 1) * 0.03 - capitalPenalty; - if (score > bestScore || (score === bestScore && id < best?.id)) best = { id, pref, score }; - } - if (!best) break; - owner.set(best.id, smallPref); - changed++; - repairPrefectureMunicipalityConnectivity(nodes, owner); - } - return changed; -} - -function mergePersistentlyTinyPrefecturesByCount(nodes, owner, minCount = 12, minRemainingPrefectures = 2) { - let changed = 0; - for (let pass = 0; pass < 16; pass++) { - const counts = prefectureMunicipalityCounts(owner); - if (counts.size <= minRemainingPrefectures) break; - const tiny = [...counts.entries()].filter(([, count]) => count > 0 && count < minCount).sort((a, b) => a[1] - b[1] || a[0] - b[0])[0]; - if (!tiny) break; - const [tinyPref] = tiny; - const neighborScores = new Map(); - for (const [id, pref] of owner) { - if (pref !== tinyPref) continue; - const node = nodes.get(id); - for (const [nextId, edge] of node?.adjacent || []) { - const other = owner.get(nextId); - if (other === undefined || other === tinyPref) continue; - const score = (edge.count || 1) * 2.4 - (edge.crossingCost ?? (1.0 + (edge.barrier || 0) * 8.5)) * 1.0 + Math.min(18, counts.get(other) || 0) * 0.20; - neighborScores.set(other, (neighborScores.get(other) || 0) + score); - } - } - let best = -1, bestScore = -INF; - for (const [candidate, score] of neighborScores) { - if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; } - } - if (best < 0) { - const tinyNodes = [...owner.keys()].filter((id) => owner.get(id) === tinyPref).map((id) => nodes.get(id)).filter(Boolean); - const tx = tinyNodes.reduce((sum, node) => sum + node.x * node.area, 0) / Math.max(1, tinyNodes.reduce((sum, node) => sum + node.area, 0)); - const ty = tinyNodes.reduce((sum, node) => sum + node.y * node.area, 0) / Math.max(1, tinyNodes.reduce((sum, node) => sum + node.area, 0)); - let bestDist = INF; - for (const [candidate, count] of counts) { - if (candidate === tinyPref || count <= 0) continue; - const candidateNodes = [...owner.keys()].filter((id) => owner.get(id) === candidate).map((id) => nodes.get(id)).filter(Boolean); - for (const node of candidateNodes) { - const d = Math.hypot(node.x - tx, node.y - ty); - if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; } - } - } - } - if (best < 0) break; - for (const [id, pref] of owner) if (pref === tinyPref) { owner.set(id, best); changed++; } - repairPrefectureMunicipalityConnectivity(nodes, owner); - } - // Renumber compactly so labels/debug do not expose deleted prefecture IDs. - const active = [...new Set(owner.values())].sort((a, b) => a - b); - const remap = new Map(active.map((id, n) => [id, n])); - for (const [id, pref] of owner) owner.set(id, remap.get(pref)); - return changed; -} - - -function splitOversizedPrefecturesByMunicipalityCount(nodes, owner, maxCount = 88, maxPrefectures = 8) { - let changed = 0; - for (let pass = 0; pass < 10; pass++) { - const counts = prefectureMunicipalityCounts(owner); - const oversized = [...counts.entries()].filter(([, count]) => count > maxCount).sort((a, b) => b[1] - a[1] || a[0] - b[0])[0]; - if (!oversized || counts.size >= maxPrefectures) break; - const [prefId, count] = oversized; - const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId); - if (members.length <= maxCount) break; - let sx = 0, sy = 0, area = 0; - for (const id of members) { - const node = nodes.get(id); - if (!node) continue; - sx += node.x * Math.max(1, node.area || 1); - sy += node.y * Math.max(1, node.area || 1); - area += Math.max(1, node.area || 1); - } - const cx = sx / Math.max(1, area); - const cy = sy / Math.max(1, area); - const seedNode = members - .map((id) => nodes.get(id)) - .filter(Boolean) - .sort((a, b) => Math.hypot(b.x - cx, b.y - cy) - Math.hypot(a.x - cx, a.y - cy) || a.id - b.id)[0]; - if (!seedNode) break; - const newPref = Math.max(-1, ...counts.keys()) + 1; - const target = Math.max(count - maxCount, Math.floor(count * 0.42)); - const queue = [seedNode.id]; - const picked = new Set([seedNode.id]); - for (let q = 0; q < queue.length && picked.size < target; q++) { - const cur = queue[q]; - const nexts = [...(nodes.get(cur)?.adjacent.keys() || [])] - .filter((id) => owner.get(id) === prefId && !picked.has(id)) - .map((id) => nodes.get(id)) - .filter(Boolean) - .sort((a, b) => Math.hypot(b.x - cx, b.y - cy) - Math.hypot(a.x - cx, a.y - cy) || a.id - b.id); - for (const next of nexts) { - picked.add(next.id); - queue.push(next.id); - if (picked.size >= target) break; - } - } - if (picked.size < Math.max(8, target * 0.55)) break; - for (const id of picked) owner.set(id, newPref); - changed += picked.size; - repairPrefectureMunicipalityConnectivity(nodes, owner); - } - return changed; -} - -function generatePrefecturesFromMunicipalities(context, adminResult) { - const { adminId } = adminResult; - const { prefectureMask, sea, naturalBarrierScore, populationDensity, landuse, modernCities, markets, seed } = context; - const graph = buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity, [...(modernCities || []), ...(markets || [])]); - const seeds = choosePrefectureMunicipalitySeeds(graph.nodes, seed + 91001); - const owner = assignMunicipalitiesToPrefectures(graph.nodes, seeds); - const changedForTinyMerge = mergeTinyMunicipalityPrefectures(graph.nodes, owner); - let changedForMetroUnification = lockCompactUrbanMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity }, 2600); - changedForMetroUnification += lockCityMetroMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity, modernCities }); - let changedForConnectivity = repairPrefectureMunicipalityConnectivity(graph.nodes, owner); - let changedForEnclaveRepair = repairPrefectureMunicipalityEnclaves(graph.nodes, owner); - changedForMetroUnification += lockCompactUrbanMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity }, 2600); - changedForMetroUnification += lockCityMetroMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity, modernCities }); - const changedForMunicipalityCountRebalance = rebalanceSmallPrefecturesByMunicipalityCount(graph.nodes, owner, seeds.minMunicipalitiesPerPrefecture || 14); - const changedForTinyPrefectureCountMerge = mergePersistentlyTinyPrefecturesByCount(graph.nodes, owner, Math.max(13, (seeds.minMunicipalitiesPerPrefecture || 14) - 1)); - const changedForPostMergeMunicipalityCountRebalance = rebalanceSmallPrefecturesByMunicipalityCount(graph.nodes, owner, Math.max(12, (seeds.minMunicipalitiesPerPrefecture || 14) - 1), 64); - const changedForOversizedPrefectureSplit = splitOversizedPrefecturesByMunicipalityCount(graph.nodes, owner, 88, 8); - changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner); - // Keep prefectures as connected groups of municipalities; do not perform cell-level prefecture repair here. - changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner); - const maxAdminId = Math.max(-1, ...[...graph.nodes.keys()]); - const municipalityToPrefectureId = new Int16Array(maxAdminId + 1); - municipalityToPrefectureId.fill(-1); - for (const [admin, pref] of owner) municipalityToPrefectureId[admin] = pref; - const prefectureRegionId = new Int16Array(SIZE); - prefectureRegionId.fill(-1); - for (let i = 0; i < SIZE; i++) { - if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; - prefectureRegionId[i] = municipalityToPrefectureId[adminId[i]] ?? -1; - } - const regionalPrefectureBorders = extractPrefectureBordersFromMunicipalities(adminId, municipalityToPrefectureId, prefectureMask, sea); - const areaByPref = new Map(); - const popByPref = new Map(); - for (const node of graph.nodes.values()) { - const pref = owner.get(node.id); - areaByPref.set(pref, (areaByPref.get(pref) || 0) + node.area); - popByPref.set(pref, (popByPref.get(pref) || 0) + node.population); - } - const totalArea = [...areaByPref.values()].reduce((sum, value) => sum + value, 0); - return { - prefectureRegionId, - municipalityToPrefectureId, - regionalPrefectureBorders, - regionalDebug: { - prefecturesGeneratedAfterMunicipalities: true, - prefectureSource: "municipality-boundary-union", - municipalityGraphNodeCount: graph.nodes.size, - municipalityGraphEdgeCount: graph.edges.size, - prefectureMunicipalitySeedCount: seeds.length, - prefectureTinyMergeChangedMunicipalities: changedForTinyMerge, - prefectureConnectivityRepairChangedMunicipalities: changedForConnectivity, - prefectureEnclaveRepairChangedMunicipalities: changedForEnclaveRepair, - prefectureUrbanMetroUnificationChangedMunicipalities: changedForMetroUnification, - prefectureMunicipalityCountRebalanceChangedMunicipalities: (changedForMunicipalityCountRebalance || 0) + (changedForPostMergeMunicipalityCountRebalance || 0), - prefectureTinyMunicipalityCountMergeChangedMunicipalities: changedForTinyPrefectureCountMerge || 0, - prefectureOversizedCountSplitChangedMunicipalities: changedForOversizedPrefectureSplit || 0, - finalRegionalMaxMunicipalityCount: Math.max(...prefectureMunicipalityCounts(owner).values()), - finalRegionalMunicipalityCountCap: 88, - finalRegionalMinMunicipalityCount: Math.min(...prefectureMunicipalityCounts(owner).values()), - finalRegionalMaxAreaShare: totalArea ? Math.max(0, ...areaByPref.values()) / totalArea : 0, - finalRegionalTinyPrefectureCount: [...areaByPref.values()].filter((area) => area < 520).length, - finalRegionalPrefectureBorderCount: regionalPrefectureBorders.length, - regionalPrefectureBordersRebuiltFromFinalId: true, - }, - }; -} - -function isProtectedAdminSeed(seed) { - if (!seed) return false; - if (seed.seedKind === "capital" || seed.protectedCity?.isPrefecturalCapital) return true; - if (seed.seedKind === "modernCity" && (seed.protectedCity?.population || seed.population || 0) >= 180000) return true; - if (seed.seedKind === "port" && seed.portClass === "major") return true; - if (seed.seedKind === "satelliteCity" && (seed.protectedSatellite?.population || seed.population || 0) >= 60000) return true; - return false; -} - -function buildSeedLifecycle(adminCenters, adminId, prefectureMask, sea, minArea = 80) { - const areaById = municipalityAreaById(adminId, prefectureMask, sea); - const lifecycle = adminCenters.map((center, id) => { - const protectedSeed = isProtectedAdminSeed(center); - const area = areaById.get(id) || 0; - const enoughArea = area >= (protectedSeed ? 28 : minArea); - return { - id, - protected: protectedSeed, - area, - state: enoughArea || protectedSeed ? "survived" : "pending", - }; - }); - return lifecycle; -} - -function activeSeedIds(seedLifecycle) { - return new Set(seedLifecycle.filter((seed) => seed.state === "survived" || seed.protected).map((seed) => seed.id)); -} - -function dominantCompartmentOwners(compartments, adminId) { - const owner = new Int16Array(compartments.length); - owner.fill(-1); - for (const unit of compartments) { - if (!unit || unit.area === 0) continue; - const counts = new Map(); - for (const i of unit.cells) { - const id = adminId[i]; - if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); - } - let bestId = -1, best = -1; - for (const [id, count] of counts) if (count > best) { best = count; bestId = id; } - owner[unit.id] = bestId; - } - return owner; -} - -function applyCompartmentOwners(adminId, compartments, owner) { - for (const unit of compartments) { - if (!unit || unit.area === 0) continue; - const id = owner[unit.id]; - if (id < 0) continue; - for (const i of unit.cells) adminId[i] = id; - } -} - -function absorbSeedCompartments(adminId, compartments, seedLifecycle) { - const owner = dominantCompartmentOwners(compartments, adminId); - const absorbed = new Set(seedLifecycle.filter((seed) => seed.state === "absorbed").map((seed) => seed.id)); - let changed = 0; - for (const unit of compartments) { - if (!unit || unit.area === 0 || !absorbed.has(owner[unit.id])) continue; - let bestId = -1, bestScore = -INF; - for (const [neighborId, edge] of unit.adjacent) { - const candidate = owner[neighborId]; - if (candidate < 0 || absorbed.has(candidate)) continue; - const neighbor = compartments[neighborId]; - const score = edge.count * 2 - (edge.target / Math.max(1, edge.count)) * 2 + (neighbor?.area || 0) * 0.002; - if (score > bestScore) { bestScore = score; bestId = candidate; } - } - if (bestId < 0) continue; - owner[unit.id] = bestId; - changed += unit.area; - } - applyCompartmentOwners(adminId, compartments, owner); - return changed; -} - -function splitOversizedLowlandsWithPendingSeeds(adminId, prefectureMask, sea, fields, compartments, adminCenters, seedLifecycle, settlements = []) { - const { plain, agriculture, basinField, coastalLowland, valleyField, ridgeField, slope, elevation } = fields; - const areaById = municipalityAreaById(adminId, prefectureMask, sea); - const areas = [...areaById.values()].sort((a, b) => a - b); - const median = areas.length ? areas[Math.floor(areas.length / 2)] : 0; - if (!median) return { changedCells: 0, splitMunicipalities: 0, pendingSeedsUsed: 0 }; - const owner = dominantCompartmentOwners(compartments, adminId); - const unitsByOwner = new Map(); - for (const unit of compartments) { - if (!unit || unit.area === 0 || owner[unit.id] < 0) continue; - if (!unitsByOwner.has(owner[unit.id])) unitsByOwner.set(owner[unit.id], []); - unitsByOwner.get(owner[unit.id]).push(unit); - } - const pending = seedLifecycle.filter((seed) => seed.state === "pending" && !seed.protected); - let changedCells = 0; - let splitMunicipalities = 0; - let pendingSeedsUsed = 0; - for (const [id, units] of unitsByOwner) { - const area = areaById.get(id) || 0; - if (area < Math.max(260, median * 1.45) || units.length < 6) continue; - let lowland = 0, rough = 0; - for (const unit of units) { - for (const i of unit.cells) { - lowland += (plain[i] || 0) * 0.38 + (agriculture[i] || 0) * 0.20 + basinField[i] * 0.22 + coastalLowland[i] * 0.22 + valleyField[i] * 0.10; - rough += ridgeField[i] * 0.48 + slope[i] * 0.34 + Math.max(0, elevation[i] - 0.58) * 0.30; - } - } - if (lowland / area < 0.26 || rough / area > 0.48) continue; - const localPending = pending.filter((seed) => { - const center = adminCenters[seed.id]; - if (!center || !inside(center.x, center.y)) return false; - const centerOwner = adminId[indexOf(center.x, center.y)]; - return centerOwner === id || Math.hypot(center.x - (adminCenters[id]?.x || center.x), center.y - (adminCenters[id]?.y || center.y)) < 28; - }); - const localSettlements = settlements.filter((p) => p && inside(p.x, p.y) && adminId[indexOf(p.x, p.y)] === id); - if (localPending.length === 0 || localPending.length + localSettlements.length < 2) continue; - let municipalitySplit = false; - for (const seed of localPending.slice(0, 3)) { - const center = adminCenters[seed.id]; - if (!center) continue; - const targetArea = clamp(95 + (center.score || 0.5) * 60, 90, 180); - let claimed = 0; - const candidates = units - .filter((unit) => owner[unit.id] === id && unit.classId !== 8 && unit.classId !== 9) - .map((unit) => ({ - unit, - score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 8 - (unit.urbanWeight || 0) * 3, - })) - .sort((a, b) => a.score - b.score); - if (candidates.length < 2) continue; - for (const { unit } of candidates) { - if (claimed >= targetArea && claimed >= 2) break; - owner[unit.id] = seed.id; - claimed += unit.area; - changedCells += unit.area; - } - if (claimed >= 45) { - seed.state = "survived"; - seed.area = claimed; - pendingSeedsUsed++; - municipalitySplit = true; - } - } - if (municipalitySplit) splitMunicipalities++; - } - applyCompartmentOwners(adminId, compartments, owner); - return { changedCells, splitMunicipalities, pendingSeedsUsed }; -} - -function promotePendingSeedsForMunicipalityCount(adminId, prefectureMask, sea, fields, compartments, adminCenters, seedLifecycle, targetMinCount = 20) { - const { plain, agriculture, basinField, coastalLowland, valleyField, ridgeField, slope, elevation } = fields; - let areaById = municipalityAreaById(adminId, prefectureMask, sea); - let currentCount = areaById.size; - if (currentCount >= targetMinCount) return { changedCells: 0, promotedSeeds: 0 }; - const owner = dominantCompartmentOwners(compartments, adminId); - let changedCells = 0; - let promotedSeeds = 0; - const pending = seedLifecycle - .filter((seed) => seed.state === "pending" && !seed.protected) - .sort((a, b) => (adminCenters[b.id]?.score || 0) - (adminCenters[a.id]?.score || 0)); - for (const seed of pending) { - if (currentCount >= targetMinCount) break; - const center = adminCenters[seed.id]; - if (!center || !inside(center.x, center.y)) continue; - const existingArea = areaById.get(seed.id) || 0; - if (existingArea >= 12) { - seed.state = "survived"; - seed.area = existingArea; - promotedSeeds++; - continue; - } - const candidates = compartments - .filter((unit) => { - if (!unit || unit.area === 0) return false; - const currentOwner = owner[unit.id]; - if (currentOwner < 0 || currentOwner === seed.id) return false; - const ownerArea = areaById.get(currentOwner) || 0; - if (ownerArea < 90) return false; - const lowlandFit = (unit.lowlandFitness || 0) + (unit.basinIdentity || 0) * 0.15 + (unit.coastalExposure || 0) * 0.15; - if (lowlandFit < 0.26) return false; - return Math.hypot(unit.x - center.x, unit.y - center.y) < 36; - }) - .map((unit) => ({ - unit, - score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 6 - (unit.urbanWeight || 0) * 2, - })) - .sort((a, b) => a.score - b.score); - if (candidates.length === 0) continue; - let claimed = 0; - for (const { unit } of candidates) { - const currentOwner = owner[unit.id]; - if ((areaById.get(currentOwner) || 0) - unit.area < 70) continue; - owner[unit.id] = seed.id; - areaById.set(currentOwner, (areaById.get(currentOwner) || 0) - unit.area); - areaById.set(seed.id, (areaById.get(seed.id) || 0) + unit.area); - claimed += unit.area; - changedCells += unit.area; - if (claimed >= 55) break; - } - if (claimed >= 25) { - seed.state = "survived"; - seed.area = areaById.get(seed.id) || claimed; - promotedSeeds++; - currentCount++; - } - } - applyCompartmentOwners(adminId, compartments, owner); - return { changedCells, promotedSeeds }; -} - -function restoreSurvivedSeedsByCompartment(adminId, prefectureMask, sea, compartments, adminCenters, seedLifecycle, targetMinCount = 20) { - const areaById = municipalityAreaById(adminId, prefectureMask, sea); - let currentCount = areaById.size; - if (currentCount >= targetMinCount) return { changedCells: 0, restoredSeeds: 0 }; - const owner = dominantCompartmentOwners(compartments, adminId); - let changedCells = 0; - let restoredSeeds = 0; - const missing = seedLifecycle - .filter((seed) => (seed.state === "survived" || seed.protected) && (areaById.get(seed.id) || 0) === 0) - .sort((a, b) => (b.protected ? 1 : 0) - (a.protected ? 1 : 0)); - for (const seed of missing) { - if (currentCount >= targetMinCount) break; - const center = adminCenters[seed.id]; - if (!center || !inside(center.x, center.y)) continue; - const candidates = compartments - .filter((unit) => { - if (!unit || unit.area === 0 || unit.classId === 8 || unit.classId === 9) return false; - const currentOwner = owner[unit.id]; - if (currentOwner < 0 || currentOwner === seed.id) return false; - if ((areaById.get(currentOwner) || 0) - unit.area < 25) return false; - return Math.hypot(unit.x - center.x, unit.y - center.y) < 90 && ((unit.lowlandFitness || 0) > 0.08 || seed.protected); - }) - .map((unit) => ({ - unit, - score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 6 - (unit.urbanWeight || 0) * 2 + (unit.classId === 8 || unit.classId === 9 ? 20 : 0), - })) - .sort((a, b) => a.score - b.score); - if (candidates.length === 0) continue; - const unit = candidates[0].unit; - const oldOwner = owner[unit.id]; - if ((areaById.get(oldOwner) || 0) - unit.area < 25) continue; - owner[unit.id] = seed.id; - const claimed = unit.area; - areaById.set(oldOwner, (areaById.get(oldOwner) || 0) - claimed); - changedCells += claimed; - areaById.set(seed.id, claimed); - seed.area = claimed; - restoredSeeds++; - currentCount++; - } - applyCompartmentOwners(adminId, compartments, owner); - return { changedCells, restoredSeeds }; -} - -function municipalityCountBoundsForRegion(landCells, meta = {}) { - // Use the same administrative density curve for the highlighted prefecture - // and neighboring prefectures. Only clipped slivers get a low floor. - let min = 1; - if (landCells >= 360) min = 2; - if (landCells >= 750) min = 4; - if (landCells >= 1400) min = 7; - if (landCells >= 2400) min = 11; - if (landCells >= 3800) min = 16; - if (landCells >= 5600) min = 22; - const max = clamp(Math.round(landCells / 160 + 6), Math.max(min, 4), 72); - return { min, max }; -} - -function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages, adminRegionMeta = {} }) { - let landCells = 0; - let habitableCells = 0; - let lowlandCells = 0; - let coastlineComplexity = 0; - let mountainCells = 0; - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - if (!prefectureMask[i] || sea[i]) continue; - landCells++; - if (slope[i] < 0.42 && ridgeField[i] < 0.55 && (!elevation || elevation[i] < 0.72)) habitableCells++; - if ((coastalLowland[i] > 0.20 || basinField[i] > 0.24) && slope[i] < 0.36 && ridgeField[i] < 0.52) lowlandCells++; - if (ridgeField[i] > 0.52 || slope[i] > 0.48) mountainCells++; - for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) { - const ni = indexOf(nx, ny); - if (sea[ni]) { - coastlineComplexity += 1 + coastalLowland[i] * 0.8; - break; - } - } - } - } - const independentSatellites = (satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality").length; - const settlementWeight = modernCities.length * 1.7 + markets.length * 1.15 + ports.length * 0.8 + independentSatellites * 0.7 + villages.length * 0.32; - const basinBonus = Math.min(8, [...basinField].filter((v, i) => prefectureMask[i] && !sea[i] && v > 0.34).length / 520); - const mountainRatio = landCells ? mountainCells / landCells : 0; - const lowlandBonus = Math.min(7, lowlandCells / 430); - const rawTarget = Math.round(habitableCells / 150 + settlementWeight * 1.08 + coastlineComplexity * 0.035 + basinBonus * 0.75 + lowlandBonus * 1.25 - mountainRatio * 1.45); - const { min, max } = municipalityCountBoundsForRegion(landCells, adminRegionMeta); - return clamp(rawTarget, min, max); -} - -function lowlandAdminSeedScore(i, { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse }) { - const lowRelief = clamp((0.70 - elevation[i]) * 1.35) + clamp((0.34 - slope[i]) * 1.55) + clamp((0.48 - ridgeField[i]) * 1.10); - const landuseFit = [1, 2, 3, 4, 7, 8].includes(landuse[i]) ? 0.40 : landuse[i] === 5 || landuse[i] === 6 ? 0.12 : 0; - return clamp( - lowRelief * 0.25 + - plain[i] * 0.28 + - basinField[i] * 0.24 + - coastalLowland[i] * 0.24 + - settlementScore[i] * 0.30 + - populationDensity[i] * 0.32 + - roadInfluence[i] * 0.16 + - railInfluence2[i] * 0.16 + - (stationInfluence?.[i] || 0) * 0.18 + - landuseFit - - Math.max(0, elevation[i] - 0.62) * 1.2 - - Math.max(0, ridgeField[i] - 0.54) * 0.9 - ); -} - -function buildLowlandAdminSeeds({ - seed, - targetMunicipalityCount, - prefectureMask, - sea, - elevation, - slope, - ridgeField, - plain, - basinField, - coastalLowland, - settlementScore, - populationDensity, - roadInfluence, - railInfluence2, - stationInfluence, - landuse, - modernCities, - satelliteCities, - markets, - ports, - newTowns, - stations, -}) { - const fields = { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse }; - function validLowlandPoint(p, strict = true) { - if (!p || !inside(p.x, p.y)) return false; - const i = indexOf(p.x, p.y); - if (!prefectureMask[i] || sea[i]) return false; - const score = lowlandAdminSeedScore(i, fields); - const mountain = elevation[i] > 0.70 || slope[i] > 0.52 || ridgeField[i] > 0.62; - return score >= (strict ? 0.34 : 0.24) && (!mountain || p.isPrefecturalCapital || (p.population || 0) >= 220000 || p.portClass === "major"); - } - const realSeeds = []; - for (const city of modernCities || []) { - if (!validLowlandPoint(city, !city.isPrefecturalCapital)) continue; - if ((city.population || 0) < 45000) continue; - const i = indexOf(city.x, city.y); - realSeeds.push({ x: city.x, y: city.y, score: 1.35 + (city.population || 0) / 650000 + lowlandAdminSeedScore(i, fields), population: city.population || 0, protectedCity: city, seedKind: city.isPrefecturalCapital ? "capital" : "modernCity" }); - } - for (const city of satelliteCities || []) { - if (city.municipalityClass !== "independentSatelliteMunicipality" || !validLowlandPoint(city, false)) continue; - const i = indexOf(city.x, city.y); - realSeeds.push({ x: city.x, y: city.y, score: 0.92 + (city.population || 0) / 260000 + lowlandAdminSeedScore(i, fields), population: city.population || 0, protectedSatellite: city, seedKind: "satelliteCity" }); - } - for (const p of [...(markets || []), ...(ports || []), ...(newTowns || [])]) { - if (!validLowlandPoint(p, true)) continue; - const i = indexOf(p.x, p.y); - const portBonus = p.portClass === "major" ? 0.45 : p.portClass === "regional" ? 0.26 : 0; - realSeeds.push({ x: p.x, y: p.y, score: 0.74 + lowlandAdminSeedScore(i, fields) + portBonus + (p.population || 0) / 420000, population: p.population || 0, portClass: p.portClass, source: p, seedKind: p.portClass ? "port" : "marketTown" }); - } - const picked = pickEntities(realSeeds, { - max: targetMunicipalityCount, - minDistance: 5 + Math.floor(rand(seed, 1302) * 3), - threshold: 0.62, - seed: seed + 1300, - jitter: 0.025, - }); - const invisibleCandidates = []; - for (let y = 2; y < MAP_H - 2; y++) { - for (let x = 2; x < MAP_W - 2; x++) { - const i = indexOf(x, y); - if (!prefectureMask[i] || sea[i]) continue; - const score = lowlandAdminSeedScore(i, fields) + hash2(x, y, seed + 1311) * 0.045; - if (score < 0.48) continue; - const insideDenseCore = modernCities.some((city) => (city.population || 0) >= 180000 && Math.hypot(city.x - x, city.y - y) < Math.max(5, (city.coreRadius || 4) * 1.7)); - if (insideDenseCore) continue; - invisibleCandidates.push({ x, y, score, invisibleLowlandAdminSeed: true, seedKind: "invisibleLowland" }); - } - } - if (picked.length < targetMunicipalityCount) { - const extra = pickEntities(invisibleCandidates, { - max: targetMunicipalityCount - picked.length, - minDistance: 5, - threshold: 0.48, - seed: seed + 1304, - jitter: 0.02, - }); - for (const p of extra) if (picked.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 4)) picked.push(p); - } - if (picked.length < Math.min(targetMunicipalityCount, 20)) { - const relaxed = pickEntities(invisibleCandidates, { - max: Math.min(targetMunicipalityCount, 20) - picked.length, - minDistance: 4, - threshold: 0.38, - seed: seed + 1305, - jitter: 0.02, - }); - for (const p of relaxed) if (picked.length < targetMunicipalityCount && picked.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 3.5)) picked.push(p); - } - return picked.slice(0, targetMunicipalityCount); -} - -function estimateUrbanComponentArea(city, prefectureMask, sea, landuse, populationDensity) { - if (!city || !inside(city.x, city.y)) return 0; - const start = indexOf(city.x, city.y); - if (!prefectureMask[start] || sea[start]) return 0; - const radius = Math.ceil(Math.max(7, (city.urbanRadius || 6) * 1.7)); - const seen = new Uint8Array(SIZE); - const queue = [start]; - seen[start] = 1; - let area = 0; - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - const [x, y] = xyOf(cur); - const d = Math.hypot(x - city.x, y - city.y); - if (d > radius) continue; - const urban = (landuse[cur] >= 2 && landuse[cur] <= 4) || landuse[cur] === 7 || landuse[cur] === 8 || populationDensity[cur] > 0.18; - if (!urban) continue; - area++; - for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { - const nx = x + dx, ny = y + dy; - if (!inside(nx, ny)) continue; - const ni = indexOf(nx, ny); - if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue; - seen[ni] = 1; - queue.push(ni); - } - } - return area; -} - -function terrainSeparationBetween(a, b, ridgeField, river, flowAccum, populationDensity, landuse) { - if (!a || !b) return { separatedByBarrier: false, ruralGap: false, averageDensity: 0, maxBarrier: 0 }; - const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y))); - let maxBarrier = 0; - let lowUrbanRun = 0; - let bestLowUrbanRun = 0; - let densitySum = 0; - for (let s = 0; s <= steps; s++) { - const t = s / steps; - const x = Math.round(a.x + (b.x - a.x) * t); - const y = Math.round(a.y + (b.y - a.y) * t); - if (!inside(x, y)) continue; - const i = indexOf(x, y); - const barrier = Math.max(ridgeField[i] * 0.95, river[i] * 0.85, flowAccum[i] * 0.42); - maxBarrier = Math.max(maxBarrier, barrier); - densitySum += populationDensity[i]; - const urban = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.20; - if (urban) lowUrbanRun = 0; - else { - lowUrbanRun++; - bestLowUrbanRun = Math.max(bestLowUrbanRun, lowUrbanRun); - } - } - return { - separatedByBarrier: maxBarrier > 0.56, - ruralGap: bestLowUrbanRun >= 4, - averageDensity: densitySum / (steps + 1), - maxBarrier, - }; -} - -function classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, ridgeField, river, flowAccum) { - let independent = 0; - let attached = 0; - for (const sat of satelliteCities || []) { - if (!sat || !inside(sat.x, sat.y) || !prefectureMask[indexOf(sat.x, sat.y)] || sea[indexOf(sat.x, sat.y)]) continue; - const parent = modernCities[sat.parentCityIndex] || modernCities.slice().sort((a, b) => Math.hypot(a.x - sat.x, a.y - sat.y) - Math.hypot(b.x - sat.x, b.y - sat.y))[0]; - const parentDistance = parent ? Math.hypot(parent.x - sat.x, parent.y - sat.y) : 99; - const separation = terrainSeparationBetween(sat, parent, ridgeField, river, flowAccum, populationDensity, landuse); - const urbanArea = estimateUrbanComponentArea(sat, prefectureMask, sea, landuse, populationDensity); - const i = indexOf(sat.x, sat.y); - const continuousUrban = parent && parentDistance < Math.max(10, (parent.urbanRadius || 12) + (sat.urbanRadius || 5) + 5) && separation.averageDensity > 0.14 && !separation.ruralGap && !separation.separatedByBarrier; - const newTownLike = landuse[i] === 7 || (railInfluence2[i] > 0.22 && roadInfluence[i] > 0.12 && (sat.population || 0) < 70000); - let municipalityClass = "independentSatelliteMunicipality"; - if (continuousUrban && (sat.population || 0) < 90000) municipalityClass = "suburbanDistrictMergedWithParent"; - else if (newTownLike && (sat.population || 0) < 85000 && !separation.separatedByBarrier) municipalityClass = "newTownDistrict"; - else if ((sat.population || 0) < 42000 && urbanArea < 55 && !separation.separatedByBarrier) municipalityClass = "smallTownAttachedToRuralMunicipality"; - else if ((sat.population || 0) >= 60000 && urbanArea >= 42 && (separation.separatedByBarrier || separation.ruralGap || parentDistance > 15)) municipalityClass = "independentSatelliteMunicipality"; - - sat.municipalityClass = municipalityClass; - sat.parentX = parent?.x; - sat.parentY = parent?.y; - sat.parentAdminHint = -1; - sat.distinctUrbanComponentArea = urbanArea; - sat.separatedByBarrier = separation.separatedByBarrier || separation.ruralGap; - sat.satelliteMinArea = clamp(90 + Math.sqrt(sat.population || 24000) * 0.62 + (sat.urbanRadius || 5) * 12, 80, 360); - if (municipalityClass === "independentSatelliteMunicipality") independent++; - else attached++; - } - return { independent, attached }; -} - -function expandSatelliteMunicipalityCatchment(adminId, satellite, targetAdmin, context) { - const { prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities } = context; - if (!satellite || targetAdmin < 0 || !inside(satellite.x, satellite.y)) return 0; - const start = indexOf(satellite.x, satellite.y); - if (!prefectureMask[start] || sea[start]) return 0; - const targetAreaBase = clamp(90 + Math.sqrt(satellite.population || 24000) * 0.8 + (satellite.urbanRadius || 5) * 18, 120, 520); - const targetArea = satellite.municipalityClass === "smallTownAttachedToRuralMunicipality" - ? Math.min(130, targetAreaBase * 0.55) - : satellite.municipalityClass === "suburbanDistrictMergedWithParent" || satellite.municipalityClass === "newTownDistrict" - ? Math.min(190, targetAreaBase * 0.62) - : targetAreaBase; - const maxCost = satellite.municipalityClass === "independentSatelliteMunicipality" ? 46 : 32; - const heap = new MinHeap(); - const best = new Float32Array(SIZE); - best.fill(INF); - heap.push({ i: start, f: 0 }); - best[start] = 0; - const claimed = []; - while (heap.length > 0 && claimed.length < targetArea) { - const cur = heap.pop(); - if (!cur || cur.f > best[cur.i] + 1e-5 || cur.f > maxCost) continue; - const [x, y] = xyOf(cur.i); - const d = Math.hypot(x - satellite.x, y - satellite.y); - if (!prefectureMask[cur.i] || sea[cur.i]) continue; - let invadesOtherCore = false; - for (const city of modernCities || []) { - if (!city || (city.population || 0) < 140000) continue; - if (Math.hypot(city.x - satellite.x, city.y - satellite.y) < 4) continue; - if (Math.hypot(city.x - x, city.y - y) <= Math.max(3.5, (city.coreRadius || 4) * 1.25)) { - invadesOtherCore = true; - break; - } - } - if (invadesOtherCore) continue; - const compatible = d <= (satellite.urbanRadius || 5) * 1.25 || - [2, 3, 4, 7, 8].includes(landuse[cur.i]) || - populationDensity[cur.i] > 0.12 || - roadInfluence[cur.i] > 0.12 || - railInfluence2[cur.i] > 0.10 || - stationInfluence?.[cur.i] > 0.10 || - basinField[cur.i] > 0.22 || - valleyField[cur.i] > 0.24 || - coastalLowland[cur.i] > 0.20; - if (!compatible && claimed.length > targetArea * 0.55) continue; - claimed.push(cur.i); - for (const [dx, dy, step] of [[1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1], [1, 1, 1.41], [-1, 1, 1.41], [1, -1, 1.41], [-1, -1, 1.41]]) { - const nx = x + dx, ny = y + dy; - if (!inside(nx, ny)) continue; - const ni = indexOf(nx, ny); - if (!prefectureMask[ni] || sea[ni]) continue; - const barrier = ridgeField[ni] * 5.2 + Math.max(0, elevation[ni] - 0.58) * 4.0 + slope[ni] * 2.2 + (river[ni] > 0.55 || flowAccum[ni] > 0.70 ? 7.5 : river[ni] > 0.30 ? 2.8 : 0); - const living = ([2, 3, 4, 7, 8].includes(landuse[ni]) ? 2.2 : 0) + populationDensity[ni] * 2.0 + roadInfluence[ni] * 0.85 + railInfluence2[ni] * 0.95 + (stationInfluence?.[ni] || 0) * 1.2 + basinField[ni] * 0.42 + valleyField[ni] * 0.48 + coastalLowland[ni] * 0.32; - const distanceCost = Math.hypot(nx - satellite.x, ny - satellite.y) / Math.max(7, (satellite.urbanRadius || 5) * 1.9); - const nd = cur.f + Math.max(0.28, 1.05 + barrier - living + distanceCost) * step; - if (nd < best[ni]) { - best[ni] = nd; - heap.push({ i: ni, f: nd }); - } - } - } - let changed = 0; - for (const i of claimed) { - if (adminId[i] !== targetAdmin) changed++; - adminId[i] = targetAdmin; - } - return changed; -} - -function cityMinimumMunicipalityArea(city) { - const populationArea = Math.sqrt(city.population || 0) * 0.72; - const footprintArea = (city.urbanFootprintCells || 0) * 0.42; - return clamp(95 + populationArea + footprintArea, 130, (city.population || 0) >= 450000 ? 780 : 520); -} - -function enforceCityMunicipalityCatchments(adminId, cities, context) { - const { prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence } = context; - const areaById = municipalityAreaById(adminId, prefectureMask, sea); - let changed = 0; - let protectedCities = 0; - let tooSmall = 0; - for (const city of cities || []) { - if (!city || (city.population || 0) < 95000 || !inside(city.x, city.y)) continue; - const start = indexOf(city.x, city.y); - if (!prefectureMask[start] || sea[start]) continue; - const targetAdmin = adminId[start]; - if (targetAdmin < 0) continue; - protectedCities++; - const minArea = cityMinimumMunicipalityArea(city); - if ((areaById.get(targetAdmin) || 0) >= minArea) continue; - tooSmall++; - const heap = new MinHeap(); - const best = new Float32Array(SIZE); - best.fill(INF); - heap.push({ i: start, f: 0 }); - best[start] = 0; - const claimed = []; - const maxCost = (city.population || 0) >= 450000 ? 78 : 56; - let projectedArea = areaById.get(targetAdmin) || 0; - while (heap.length > 0 && projectedArea < minArea) { - const cur = heap.pop(); - if (!cur || cur.f > best[cur.i] + 1e-5 || cur.f > maxCost) continue; - const [x, y] = xyOf(cur.i); - if (!prefectureMask[cur.i] || sea[cur.i]) continue; - const d = Math.hypot(x - city.x, y - city.y); - const compatible = d <= Math.max(5, (city.coreRadius || 3) * 2.0) || - [2, 3, 4, 7, 8].includes(landuse[cur.i]) || - populationDensity[cur.i] > 0.10 || - roadInfluence[cur.i] > 0.10 || - railInfluence2[cur.i] > 0.10 || - (stationInfluence?.[cur.i] || 0) > 0.10 || - valleyField[cur.i] > 0.22 || - basinField[cur.i] > 0.20 || - coastalLowland[cur.i] > 0.18; - if (!compatible && claimed.length > minArea * 0.55) continue; - claimed.push(cur.i); - if (adminId[cur.i] !== targetAdmin) projectedArea++; - for (const [dx, dy, step] of [[1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1], [1, 1, 1.41], [-1, 1, 1.41], [1, -1, 1.41], [-1, -1, 1.41]]) { - const nx = x + dx, ny = y + dy; - if (!inside(nx, ny)) continue; - const ni = indexOf(nx, ny); - if (!prefectureMask[ni] || sea[ni]) continue; - const majorBarrier = river[ni] > 0.62 || flowAccum[ni] > 0.74 || ridgeField[ni] > 0.70; - const barrier = ridgeField[ni] * 5.4 + Math.max(0, elevation[ni] - 0.60) * 4.4 + slope[ni] * 2.6 + (majorBarrier ? 8.5 : river[ni] > 0.34 ? 2.6 : 0); - const fit = ([2, 3, 4, 7, 8].includes(landuse[ni]) ? 2.4 : 0) + populationDensity[ni] * 2.2 + roadInfluence[ni] * 0.88 + railInfluence2[ni] * 0.92 + (stationInfluence?.[ni] || 0) * 1.15 + valleyField[ni] * 0.56 + basinField[ni] * 0.42 + coastalLowland[ni] * 0.34; - const nd = cur.f + Math.max(0.30, 1.05 + barrier - fit + Math.hypot(nx - city.x, ny - city.y) / Math.max(8, (city.urbanRadius || 7) * 2.1)) * step; - if (nd < best[ni]) { - best[ni] = nd; - heap.push({ i: ni, f: nd }); - } - } - } - for (const i of claimed) { - const old = adminId[i]; - if (old === targetAdmin) continue; - if (old >= 0) areaById.set(old, Math.max(0, (areaById.get(old) || 0) - 1)); - adminId[i] = targetAdmin; - areaById.set(targetAdmin, (areaById.get(targetAdmin) || 0) + 1); - changed++; - } - city.municipalityMinArea = minArea; - } - return { changed, protectedCities, tooSmall }; -} +import { + changedCellsSince, + municipalityAreaById, + maskLandArea, + isProtectedAdminSeed, + buildSeedLifecycle, + activeSeedIds, +} from "./mapAdminShared.js"; +import { + compactWholeCompartmentMunicipalities, + enforceCompartmentMunicipalityOwnership, + enforceSimpleAdministrativeHierarchy, + lockCompactUrbanAreasToDominantAdmin, + repairAdminSingleOwnerEnclaves, + splitOversizedCompartmentMunicipalities, +} from "./mapAdminCompartmentRepair.js"; +import { generatePrefecturesFromMunicipalities } from "./mapPrefectureStage.js"; +import { + absorbSeedCompartments, + splitOversizedLowlandsWithPendingSeeds, + promotePendingSeedsForMunicipalityCount, + restoreSurvivedSeedsByCompartment, +} from "./mapAdminSeedLifecycle.js"; +import { + computeTargetMunicipalityCount, + buildLowlandAdminSeeds, +} from "./mapAdminTargets.js"; +import { + classifySatelliteMunicipalities, + expandSatelliteMunicipalityCatchment, + enforceCityMunicipalityCatchments, +} from "./mapAdminUrbanCatchments.js"; function generateAdminLayoutForMask({ seed, @@ -2077,15 +75,26 @@ function generateAdminLayoutForMask({ logisticsParks, naturalCompartmentId, naturalCompartments, + geography = null, + habitability = null, + accessibility = null, + centrality = null, + geographicBarrier = null, + geographicBarrierCost = null, + adminBoundaryPreference = null, + boundaryAvoidance = null, adminRegionMeta = {}, adminProgress = null, }) { + const unifiedBoundaryPreference = adminBoundaryPreference || geography?.adminBoundaryPreference || null; + const unifiedBoundaryAvoidance = boundaryAvoidance || geography?.boundaryAvoidance || null; + const unifiedGeographicBarrier = geographicBarrier || geography?.geographicBarrier || null; const boundaryRidgeField = naturalBarrierScore ? Float32Array.from(ridgeField, (value, i) => clamp(value * 0.72 + naturalBarrierScore[i] * 0.46)) : ridgeField; adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "classify satellites" }); const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, boundaryRidgeField, river, flowAccum); - const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages, adminRegionMeta }); + const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, habitability: habitability || geography?.habitability, centrality: centrality || geography?.centrality, accessibility: accessibility || geography?.accessibility, geographicBarrier: unifiedGeographicBarrier, modernCities, markets, ports, satelliteCities, villages, adminRegionMeta }); const compartmentMultiplier = clamp(4.6 + rand(seed, 1320) * 2.4, 4.6, 7.0); const regionLandArea = adminRegionMeta.landArea || maskLandArea(prefectureMask, sea); const minCompartmentTarget = clamp(Math.round(Math.max(targetMunicipalityCount * 3.4, regionLandArea / 78)), 22, 150); @@ -2108,6 +117,12 @@ function generateAdminLayoutForMask({ railInfluence2, stationInfluence, landuse, + habitability: habitability || geography?.habitability, + accessibility: accessibility || geography?.accessibility, + centrality: centrality || geography?.centrality, + boundaryAvoidance: unifiedBoundaryAvoidance, + adminBoundaryPreference: unifiedBoundaryPreference, + geographicBarrier: unifiedGeographicBarrier, modernCities, satelliteCities, markets, @@ -2125,6 +140,13 @@ function generateAdminLayoutForMask({ naturalCompartmentId, naturalCompartments, naturalBarrierScore, + geography, + habitability: habitability || geography?.habitability, + accessibility: accessibility || geography?.accessibility, + centrality: centrality || geography?.centrality, + boundaryAvoidance: unifiedBoundaryAvoidance, + adminBoundaryPreference: unifiedBoundaryPreference, + geographicBarrier: unifiedGeographicBarrier, progress: (step) => adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step }), }); const adminId = compartmentAssignment.adminId; @@ -2146,7 +168,8 @@ function generateAdminLayoutForMask({ const adminDebug = { ...compartmentAssignment.debug, simpleHierarchyPrototype: true, - administrativeHierarchySpec: "natural-compartments->municipalities->prefectures", + administrativeHierarchySpec: "unified-geography->natural-compartments->living-sphere-municipalities->prefectures", + unifiedGeographyAdministrativeBasis: true, naturalCompartmentsImmutable: true, municipalitiesAreCompartmentGroups: true, prefecturesAreMunicipalityGroups: true, @@ -2212,7 +235,9 @@ function generateAdminLayoutForMask({ changedAfterFinalMerge: 0, targetMunicipalityCount, actualMunicipalityCount: 0, - municipalityCountReason: "habitable cells, settlement weight, coastline complexity, basin/lowland bonus, and mountain-ratio adjustment", + municipalityCountReason: "unified habitability/accessibility, settlement hierarchy, coastline complexity, basin/lowland bonus, and natural-barrier adjustment", + unifiedGeographyAdministrativeBasis: true, + administrativeHierarchySpec: "unified-geography->natural-compartments->living-sphere-municipalities->prefectures", changedAfterCompartmentAssignment: compartmentAssignment.debug?.naturalCompartmentCount || 0, oversizedRuralSplits: 0, oversizedLowlandSplits: 0, diff --git a/mapAdminTargets.js b/mapAdminTargets.js new file mode 100644 index 0000000..ef7df12 --- /dev/null +++ b/mapAdminTargets.js @@ -0,0 +1,190 @@ +import { MAP_H, MAP_W, clamp, hash2, indexOf, inside, pickEntities, rand } from "./mapUtils.js"; + +export function municipalityCountBoundsForRegion(landCells, meta = {}) { + // Use the same administrative density curve for the highlighted prefecture + // and neighboring prefectures. Only clipped slivers get a low floor. + let min = 1; + if (landCells >= 360) min = 2; + if (landCells >= 750) min = 4; + if (landCells >= 1400) min = 7; + if (landCells >= 2400) min = 11; + if (landCells >= 3800) min = 16; + if (landCells >= 5600) min = 22; + const max = clamp(Math.round(landCells / 160 + 6), Math.max(min, 4), 72); + return { min, max }; +} + +export function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField, coastalLowland, basinField, habitability, centrality, accessibility, geographicBarrier, modernCities, markets, ports, satelliteCities, villages, adminRegionMeta = {} }) { + let landCells = 0; + let habitableCells = 0; + let lowlandCells = 0; + let coastlineComplexity = 0; + let mountainCells = 0; + let habitabilitySum = 0; + let centralitySum = 0; + let accessibleCells = 0; + let barrierCells = 0; + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i]) continue; + landCells++; + if (slope[i] < 0.42 && ridgeField[i] < 0.55 && (!elevation || elevation[i] < 0.72)) habitableCells++; + if ((coastalLowland[i] > 0.20 || basinField[i] > 0.24) && slope[i] < 0.36 && ridgeField[i] < 0.52) lowlandCells++; + if (ridgeField[i] > 0.52 || slope[i] > 0.48) mountainCells++; + habitabilitySum += habitability?.[i] || 0; + centralitySum += centrality?.[i] || 0; + if ((accessibility?.[i] || 0) > 0.36 || (centrality?.[i] || 0) > 0.38) accessibleCells++; + if ((geographicBarrier?.[i] || ridgeField[i]) > 0.58) barrierCells++; + for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) { + const ni = indexOf(nx, ny); + if (sea[ni]) { + coastlineComplexity += 1 + coastalLowland[i] * 0.8; + break; + } + } + } + } + const independentSatellites = (satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality").length; + const ruralVillageWeight = Math.sqrt(Math.max(0, villages.length || 0)) * 0.55; + const settlementWeight = modernCities.length * 1.7 + markets.length * 1.15 + ports.length * 0.8 + independentSatellites * 0.7 + ruralVillageWeight; + const basinBonus = Math.min(8, [...basinField].filter((v, i) => prefectureMask[i] && !sea[i] && v > 0.34).length / 520); + const mountainRatio = landCells ? mountainCells / landCells : 0; + const barrierRatio = landCells ? barrierCells / landCells : 0; + const avgHabitability = landCells ? habitabilitySum / landCells : 0; + const avgCentrality = landCells ? centralitySum / landCells : 0; + const lowlandBonus = Math.min(7, lowlandCells / 430); + const livingSphereBonus = Math.min(5.5, accessibleCells / 560 + avgCentrality * 3.2 + avgHabitability * 1.6); + const rawTarget = Math.round(habitableCells / 158 + settlementWeight * 1.02 + coastlineComplexity * 0.032 + basinBonus * 0.70 + lowlandBonus * 1.08 + livingSphereBonus - mountainRatio * 1.70 - barrierRatio * 1.15); + const { min, max } = municipalityCountBoundsForRegion(landCells, adminRegionMeta); + return clamp(rawTarget, min, max); +} + +export function lowlandAdminSeedScore(i, { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse, habitability, accessibility, centrality, boundaryAvoidance, adminBoundaryPreference, geographicBarrier }) { + const lowRelief = clamp((0.70 - elevation[i]) * 1.35) + clamp((0.34 - slope[i]) * 1.55) + clamp((0.48 - ridgeField[i]) * 1.10); + const landuseFit = [1, 2, 3, 4, 7, 8].includes(landuse[i]) ? 0.40 : landuse[i] === 5 || landuse[i] === 6 ? 0.12 : 0; + const unifiedNudge = clamp( + (habitability?.[i] || 0) * 0.05 + + (accessibility?.[i] || 0) * 0.04 + + (centrality?.[i] || 0) * 0.04 - + (adminBoundaryPreference?.[i] || 0) * 0.04 - + (geographicBarrier?.[i] || 0) * 0.03 + ) * 0.20; + return clamp( + lowRelief * 0.25 + + plain[i] * 0.28 + + basinField[i] * 0.24 + + coastalLowland[i] * 0.24 + + settlementScore[i] * 0.30 + + populationDensity[i] * 0.32 + + roadInfluence[i] * 0.16 + + railInfluence2[i] * 0.16 + + (stationInfluence?.[i] || 0) * 0.18 + + landuseFit + + unifiedNudge - + Math.max(0, elevation[i] - 0.62) * 1.2 - + Math.max(0, ridgeField[i] - 0.54) * 0.9 + ); +} + + +export function buildLowlandAdminSeeds({ + seed, + targetMunicipalityCount, + prefectureMask, + sea, + elevation, + slope, + ridgeField, + plain, + basinField, + coastalLowland, + settlementScore, + populationDensity, + roadInfluence, + railInfluence2, + stationInfluence, + landuse, + habitability, + accessibility, + centrality, + boundaryAvoidance, + adminBoundaryPreference, + geographicBarrier, + modernCities, + satelliteCities, + markets, + ports, + newTowns, + stations, +}) { + const fields = { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse, habitability, accessibility, centrality, boundaryAvoidance, adminBoundaryPreference, geographicBarrier }; + function validLowlandPoint(p, strict = true) { + if (!p || !inside(p.x, p.y)) return false; + const i = indexOf(p.x, p.y); + if (!prefectureMask[i] || sea[i]) return false; + const score = lowlandAdminSeedScore(i, fields); + const mountain = elevation[i] > 0.70 || slope[i] > 0.52 || ridgeField[i] > 0.62; + return score >= (strict ? 0.34 : 0.24) && (!mountain || p.isPrefecturalCapital || (p.population || 0) >= 220000 || p.portClass === "major"); + } + const realSeeds = []; + for (const city of modernCities || []) { + if (!validLowlandPoint(city, !city.isPrefecturalCapital)) continue; + if ((city.population || 0) < 45000) continue; + const i = indexOf(city.x, city.y); + realSeeds.push({ x: city.x, y: city.y, score: 1.35 + (city.population || 0) / 650000 + lowlandAdminSeedScore(i, fields), population: city.population || 0, protectedCity: city, seedKind: city.isPrefecturalCapital ? "capital" : "modernCity" }); + } + for (const city of satelliteCities || []) { + if (city.municipalityClass !== "independentSatelliteMunicipality" || !validLowlandPoint(city, false)) continue; + const i = indexOf(city.x, city.y); + realSeeds.push({ x: city.x, y: city.y, score: 0.92 + (city.population || 0) / 260000 + lowlandAdminSeedScore(i, fields), population: city.population || 0, protectedSatellite: city, seedKind: "satelliteCity" }); + } + for (const p of [...(markets || []), ...(ports || []), ...(newTowns || [])]) { + if (!validLowlandPoint(p, true)) continue; + const i = indexOf(p.x, p.y); + const portBonus = p.portClass === "major" ? 0.45 : p.portClass === "regional" ? 0.26 : 0; + realSeeds.push({ x: p.x, y: p.y, score: 0.74 + lowlandAdminSeedScore(i, fields) + portBonus + (p.population || 0) / 420000, population: p.population || 0, portClass: p.portClass, source: p, seedKind: p.portClass ? "port" : "marketTown" }); + } + const picked = pickEntities(realSeeds, { + max: targetMunicipalityCount, + minDistance: 5 + Math.floor(rand(seed, 1302) * 3), + threshold: 0.62, + seed: seed + 1300, + jitter: 0.025, + }); + const invisibleCandidates = []; + for (let y = 2; y < MAP_H - 2; y++) { + for (let x = 2; x < MAP_W - 2; x++) { + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i]) continue; + const score = lowlandAdminSeedScore(i, fields) + hash2(x, y, seed + 1311) * 0.045; + if (score < 0.48) continue; + const insideDenseCore = modernCities.some((city) => (city.population || 0) >= 180000 && Math.hypot(city.x - x, city.y - y) < Math.max(5, (city.coreRadius || 4) * 1.7)); + if (insideDenseCore) continue; + invisibleCandidates.push({ x, y, score, invisibleLowlandAdminSeed: true, seedKind: "invisibleLowland" }); + } + } + if (picked.length < targetMunicipalityCount) { + const extra = pickEntities(invisibleCandidates, { + max: targetMunicipalityCount - picked.length, + minDistance: 5, + threshold: 0.48, + seed: seed + 1304, + jitter: 0.02, + }); + for (const p of extra) if (picked.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 4)) picked.push(p); + } + if (picked.length < Math.min(targetMunicipalityCount, 20)) { + const relaxed = pickEntities(invisibleCandidates, { + max: Math.min(targetMunicipalityCount, 20) - picked.length, + minDistance: 4, + threshold: 0.38, + seed: seed + 1305, + jitter: 0.02, + }); + for (const p of relaxed) if (picked.length < targetMunicipalityCount && picked.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 3.5)) picked.push(p); + } + return picked.slice(0, targetMunicipalityCount); +} + + diff --git a/mapAdminUrbanCatchments.js b/mapAdminUrbanCatchments.js new file mode 100644 index 0000000..499d00b --- /dev/null +++ b/mapAdminUrbanCatchments.js @@ -0,0 +1,239 @@ +import { INF, SIZE, MinHeap, clamp, indexOf, inside, xyOf } from "./mapUtils.js"; +import { municipalityAreaById } from "./mapAdminShared.js"; + +export function estimateUrbanComponentArea(city, prefectureMask, sea, landuse, populationDensity) { + if (!city || !inside(city.x, city.y)) return 0; + const start = indexOf(city.x, city.y); + if (!prefectureMask[start] || sea[start]) return 0; + const radius = Math.ceil(Math.max(7, (city.urbanRadius || 6) * 1.7)); + const seen = new Uint8Array(SIZE); + const queue = [start]; + seen[start] = 1; + let area = 0; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + const [x, y] = xyOf(cur); + const d = Math.hypot(x - city.x, y - city.y); + if (d > radius) continue; + const urban = (landuse[cur] >= 2 && landuse[cur] <= 4) || landuse[cur] === 7 || landuse[cur] === 8 || populationDensity[cur] > 0.18; + if (!urban) continue; + area++; + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = x + dx, ny = y + dy; + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + return area; +} + +export function terrainSeparationBetween(a, b, ridgeField, river, flowAccum, populationDensity, landuse) { + if (!a || !b) return { separatedByBarrier: false, ruralGap: false, averageDensity: 0, maxBarrier: 0 }; + const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y))); + let maxBarrier = 0; + let lowUrbanRun = 0; + let bestLowUrbanRun = 0; + let densitySum = 0; + for (let s = 0; s <= steps; s++) { + const t = s / steps; + const x = Math.round(a.x + (b.x - a.x) * t); + const y = Math.round(a.y + (b.y - a.y) * t); + if (!inside(x, y)) continue; + const i = indexOf(x, y); + const barrier = Math.max(ridgeField[i] * 0.95, river[i] * 0.85, flowAccum[i] * 0.42); + maxBarrier = Math.max(maxBarrier, barrier); + densitySum += populationDensity[i]; + const urban = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.20; + if (urban) lowUrbanRun = 0; + else { + lowUrbanRun++; + bestLowUrbanRun = Math.max(bestLowUrbanRun, lowUrbanRun); + } + } + return { + separatedByBarrier: maxBarrier > 0.56, + ruralGap: bestLowUrbanRun >= 4, + averageDensity: densitySum / (steps + 1), + maxBarrier, + }; +} + +export function classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, ridgeField, river, flowAccum) { + let independent = 0; + let attached = 0; + for (const sat of satelliteCities || []) { + if (!sat || !inside(sat.x, sat.y) || !prefectureMask[indexOf(sat.x, sat.y)] || sea[indexOf(sat.x, sat.y)]) continue; + const parent = modernCities[sat.parentCityIndex] || modernCities.slice().sort((a, b) => Math.hypot(a.x - sat.x, a.y - sat.y) - Math.hypot(b.x - sat.x, b.y - sat.y))[0]; + const parentDistance = parent ? Math.hypot(parent.x - sat.x, parent.y - sat.y) : 99; + const separation = terrainSeparationBetween(sat, parent, ridgeField, river, flowAccum, populationDensity, landuse); + const urbanArea = estimateUrbanComponentArea(sat, prefectureMask, sea, landuse, populationDensity); + const i = indexOf(sat.x, sat.y); + const continuousUrban = parent && parentDistance < Math.max(10, (parent.urbanRadius || 12) + (sat.urbanRadius || 5) + 5) && separation.averageDensity > 0.14 && !separation.ruralGap && !separation.separatedByBarrier; + const newTownLike = landuse[i] === 7 || (railInfluence2[i] > 0.22 && roadInfluence[i] > 0.12 && (sat.population || 0) < 70000); + let municipalityClass = "independentSatelliteMunicipality"; + if (continuousUrban && (sat.population || 0) < 90000) municipalityClass = "suburbanDistrictMergedWithParent"; + else if (newTownLike && (sat.population || 0) < 85000 && !separation.separatedByBarrier) municipalityClass = "newTownDistrict"; + else if ((sat.population || 0) < 42000 && urbanArea < 55 && !separation.separatedByBarrier) municipalityClass = "smallTownAttachedToRuralMunicipality"; + else if ((sat.population || 0) >= 60000 && urbanArea >= 42 && (separation.separatedByBarrier || separation.ruralGap || parentDistance > 15)) municipalityClass = "independentSatelliteMunicipality"; + + sat.municipalityClass = municipalityClass; + sat.parentX = parent?.x; + sat.parentY = parent?.y; + sat.parentAdminHint = -1; + sat.distinctUrbanComponentArea = urbanArea; + sat.separatedByBarrier = separation.separatedByBarrier || separation.ruralGap; + sat.satelliteMinArea = clamp(90 + Math.sqrt(sat.population || 24000) * 0.62 + (sat.urbanRadius || 5) * 12, 80, 360); + if (municipalityClass === "independentSatelliteMunicipality") independent++; + else attached++; + } + return { independent, attached }; +} + +export function expandSatelliteMunicipalityCatchment(adminId, satellite, targetAdmin, context) { + const { prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities } = context; + if (!satellite || targetAdmin < 0 || !inside(satellite.x, satellite.y)) return 0; + const start = indexOf(satellite.x, satellite.y); + if (!prefectureMask[start] || sea[start]) return 0; + const targetAreaBase = clamp(90 + Math.sqrt(satellite.population || 24000) * 0.8 + (satellite.urbanRadius || 5) * 18, 120, 520); + const targetArea = satellite.municipalityClass === "smallTownAttachedToRuralMunicipality" + ? Math.min(130, targetAreaBase * 0.55) + : satellite.municipalityClass === "suburbanDistrictMergedWithParent" || satellite.municipalityClass === "newTownDistrict" + ? Math.min(190, targetAreaBase * 0.62) + : targetAreaBase; + const maxCost = satellite.municipalityClass === "independentSatelliteMunicipality" ? 46 : 32; + const heap = new MinHeap(); + const best = new Float32Array(SIZE); + best.fill(INF); + heap.push({ i: start, f: 0 }); + best[start] = 0; + const claimed = []; + while (heap.length > 0 && claimed.length < targetArea) { + const cur = heap.pop(); + if (!cur || cur.f > best[cur.i] + 1e-5 || cur.f > maxCost) continue; + const [x, y] = xyOf(cur.i); + const d = Math.hypot(x - satellite.x, y - satellite.y); + if (!prefectureMask[cur.i] || sea[cur.i]) continue; + let invadesOtherCore = false; + for (const city of modernCities || []) { + if (!city || (city.population || 0) < 140000) continue; + if (Math.hypot(city.x - satellite.x, city.y - satellite.y) < 4) continue; + if (Math.hypot(city.x - x, city.y - y) <= Math.max(3.5, (city.coreRadius || 4) * 1.25)) { + invadesOtherCore = true; + break; + } + } + if (invadesOtherCore) continue; + const compatible = d <= (satellite.urbanRadius || 5) * 1.25 || + [2, 3, 4, 7, 8].includes(landuse[cur.i]) || + populationDensity[cur.i] > 0.12 || + roadInfluence[cur.i] > 0.12 || + railInfluence2[cur.i] > 0.10 || + stationInfluence?.[cur.i] > 0.10 || + basinField[cur.i] > 0.22 || + valleyField[cur.i] > 0.24 || + coastalLowland[cur.i] > 0.20; + if (!compatible && claimed.length > targetArea * 0.55) continue; + claimed.push(cur.i); + for (const [dx, dy, step] of [[1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1], [1, 1, 1.41], [-1, 1, 1.41], [1, -1, 1.41], [-1, -1, 1.41]]) { + const nx = x + dx, ny = y + dy; + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + const barrier = ridgeField[ni] * 5.2 + Math.max(0, elevation[ni] - 0.58) * 4.0 + slope[ni] * 2.2 + (river[ni] > 0.55 || flowAccum[ni] > 0.70 ? 7.5 : river[ni] > 0.30 ? 2.8 : 0); + const living = ([2, 3, 4, 7, 8].includes(landuse[ni]) ? 2.2 : 0) + populationDensity[ni] * 2.0 + roadInfluence[ni] * 0.85 + railInfluence2[ni] * 0.95 + (stationInfluence?.[ni] || 0) * 1.2 + basinField[ni] * 0.42 + valleyField[ni] * 0.48 + coastalLowland[ni] * 0.32; + const distanceCost = Math.hypot(nx - satellite.x, ny - satellite.y) / Math.max(7, (satellite.urbanRadius || 5) * 1.9); + const nd = cur.f + Math.max(0.28, 1.05 + barrier - living + distanceCost) * step; + if (nd < best[ni]) { + best[ni] = nd; + heap.push({ i: ni, f: nd }); + } + } + } + let changed = 0; + for (const i of claimed) { + if (adminId[i] !== targetAdmin) changed++; + adminId[i] = targetAdmin; + } + return changed; +} + +export function cityMinimumMunicipalityArea(city) { + const populationArea = Math.sqrt(city.population || 0) * 0.72; + const footprintArea = (city.urbanFootprintCells || 0) * 0.42; + return clamp(95 + populationArea + footprintArea, 130, (city.population || 0) >= 450000 ? 780 : 520); +} + +export function enforceCityMunicipalityCatchments(adminId, cities, context) { + const { prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence } = context; + const areaById = municipalityAreaById(adminId, prefectureMask, sea); + let changed = 0; + let protectedCities = 0; + let tooSmall = 0; + for (const city of cities || []) { + if (!city || (city.population || 0) < 95000 || !inside(city.x, city.y)) continue; + const start = indexOf(city.x, city.y); + if (!prefectureMask[start] || sea[start]) continue; + const targetAdmin = adminId[start]; + if (targetAdmin < 0) continue; + protectedCities++; + const minArea = cityMinimumMunicipalityArea(city); + if ((areaById.get(targetAdmin) || 0) >= minArea) continue; + tooSmall++; + const heap = new MinHeap(); + const best = new Float32Array(SIZE); + best.fill(INF); + heap.push({ i: start, f: 0 }); + best[start] = 0; + const claimed = []; + const maxCost = (city.population || 0) >= 450000 ? 78 : 56; + let projectedArea = areaById.get(targetAdmin) || 0; + while (heap.length > 0 && projectedArea < minArea) { + const cur = heap.pop(); + if (!cur || cur.f > best[cur.i] + 1e-5 || cur.f > maxCost) continue; + const [x, y] = xyOf(cur.i); + if (!prefectureMask[cur.i] || sea[cur.i]) continue; + const d = Math.hypot(x - city.x, y - city.y); + const compatible = d <= Math.max(5, (city.coreRadius || 3) * 2.0) || + [2, 3, 4, 7, 8].includes(landuse[cur.i]) || + populationDensity[cur.i] > 0.10 || + roadInfluence[cur.i] > 0.10 || + railInfluence2[cur.i] > 0.10 || + (stationInfluence?.[cur.i] || 0) > 0.10 || + valleyField[cur.i] > 0.22 || + basinField[cur.i] > 0.20 || + coastalLowland[cur.i] > 0.18; + if (!compatible && claimed.length > minArea * 0.55) continue; + claimed.push(cur.i); + if (adminId[cur.i] !== targetAdmin) projectedArea++; + for (const [dx, dy, step] of [[1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1], [1, 1, 1.41], [-1, 1, 1.41], [1, -1, 1.41], [-1, -1, 1.41]]) { + const nx = x + dx, ny = y + dy; + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + const majorBarrier = river[ni] > 0.62 || flowAccum[ni] > 0.74 || ridgeField[ni] > 0.70; + const barrier = ridgeField[ni] * 5.4 + Math.max(0, elevation[ni] - 0.60) * 4.4 + slope[ni] * 2.6 + (majorBarrier ? 8.5 : river[ni] > 0.34 ? 2.6 : 0); + const fit = ([2, 3, 4, 7, 8].includes(landuse[ni]) ? 2.4 : 0) + populationDensity[ni] * 2.2 + roadInfluence[ni] * 0.88 + railInfluence2[ni] * 0.92 + (stationInfluence?.[ni] || 0) * 1.15 + valleyField[ni] * 0.56 + basinField[ni] * 0.42 + coastalLowland[ni] * 0.34; + const nd = cur.f + Math.max(0.30, 1.05 + barrier - fit + Math.hypot(nx - city.x, ny - city.y) / Math.max(8, (city.urbanRadius || 7) * 2.1)) * step; + if (nd < best[ni]) { + best[ni] = nd; + heap.push({ i: ni, f: nd }); + } + } + } + for (const i of claimed) { + const old = adminId[i]; + if (old === targetAdmin) continue; + if (old >= 0) areaById.set(old, Math.max(0, (areaById.get(old) || 0) - 1)); + adminId[i] = targetAdmin; + areaById.set(targetAdmin, (areaById.get(targetAdmin) || 0) + 1); + changed++; + } + city.municipalityMinArea = minArea; + } + return { changed, protectedCities, tooSmall }; +} + + diff --git a/mapFeatures.js b/mapFeatures.js index 1ec5e62..254c77d 100644 --- a/mapFeatures.js +++ b/mapFeatures.js @@ -1,7 +1,8 @@ import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, fbm, hash2, indexOf, inside, pickEntities, rand, valueNoise, xyOf } from "./mapUtils.js"; import { distanceToNearest, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js"; import { LANDUSE } from "./landuseCodes.js"; -import { createPathInfluenceCache, packDebugField, pathAverageField, pathLengthCells, routeQualityAcceptable } from "./mapTransport.js"; +import { buildDensityFlowRoadTransportSystem, createPathInfluenceCache, packDebugField, pathAverageField, pathLengthCells, routeQualityAcceptable } from "./mapTransport.js"; +import { buildUnifiedRailODNetwork } from "./mapTransportOD.js"; // Lightweight Human Geography V2 // -------------------------------- @@ -40,6 +41,21 @@ export function generateMapFeatures(seed, terrain) { naturalBarrierScore, } = terrain; + const geography = terrain.geography || {}; + const geoHabitability = geography.habitability || null; + const geoAccessibility = geography.accessibility || null; + const geoNaturalCentrality = geography.naturalCentrality || geography.centrality || null; + const geoLowlandCapacity = geography.lowlandCapacity || null; + const geoValleyAccess = geography.valleyAccess || null; + const geoCoastalAccess = geography.coastalAccess || null; + const geoBarrier = geography.geographicBarrier || naturalBarrierScore || null; + const geoCorridorSuitability = geography.corridorSuitability || null; + + function fieldValue(field, i, fallback = 0) { + const v = field?.[i]; + return Number.isFinite(v) ? v : fallback; + } + function regionIdAt(x, y) { if (!inside(x, y)) return -1; const i = indexOf(x, y); @@ -88,6 +104,7 @@ export function generateMapFeatures(seed, terrain) { corridorCost[i] = INF; continue; } + const naturalBarrier = naturalBarrierScore?.[i] || 0; const depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.62 + (deltaField?.[i] || 0) * 0.90; const highPenalty = Math.max(0, elevation[i] - 0.56); const lowSlope = clamp(1 - slope[i] * 2.3); @@ -96,7 +113,12 @@ export function generateMapFeatures(seed, terrain) { const spine = (arcSpineField?.[i] || 0) * 0.58 + (branchRidgeField?.[i] || 0) * 0.38; confluenceField[i] = confluence; - developable[i] = clamp( + const geoH = fieldValue(geoHabitability, i, 0); + const geoLow = fieldValue(geoLowlandCapacity, i, 0); + const geoValley = fieldValue(geoValleyAccess, i, 0); + const geoCoast = fieldValue(geoCoastalAccess, i, 0); + const geoB = fieldValue(geoBarrier, i, naturalBarrier); + const localDevelopable = clamp( plain[i] * 0.34 + agriculture[i] * 0.24 + basinField[i] * 0.24 + @@ -110,7 +132,8 @@ export function generateMapFeatures(seed, terrain) { highPenalty * 1.14 - floodplain[i] * 0.03 ); - valleySettlement[i] = clamp( + developable[i] = clamp(localDevelopable * 0.68 + geoH * 0.34 + geoLow * 0.16 - geoB * 0.05); + valleySettlement[i] = clamp(( valleyField[i] * 0.52 + river[i] * 0.08 + confluence * 0.38 + @@ -123,8 +146,8 @@ export function generateMapFeatures(seed, terrain) { spine * 0.16 - highPenalty * 0.70 - floodplain[i] * 0.10 - ); - coastalSettlement[i] = clamp( + ) * 0.74 + geoValley * 0.30 + geoH * 0.08 - geoB * 0.04); + coastalSettlement[i] = clamp(( coastalLowland[i] * 0.50 + (portSuitability?.[i] || 0) * 0.30 + (deltaField?.[i] || 0) * 0.20 + @@ -132,7 +155,7 @@ export function generateMapFeatures(seed, terrain) { slope[i] * 0.52 - ridgeField[i] * 0.24 - spine * 0.12 - ); + ) * 0.76 + geoCoast * 0.32 + geoH * 0.06 - geoB * 0.04); const clusterNoise = 0.72 + fbm(x * 0.34 + 13, y * 0.34 - 31, seed + 7001) * 0.46 + valueNoise(x, y, seed + 7002, 8) * 0.16; settlementCluster[i] = clamp((developable[i] * 0.42 + valleySettlement[i] * 0.12 + coastalSettlement[i] * 0.22 + agriculture[i] * 0.48 + plain[i] * 0.32 + openPlainPotential * 0.46) * clusterNoise); ruralSuitability[i] = clamp( @@ -158,8 +181,14 @@ export function generateMapFeatures(seed, terrain) { ridgeField[i] * 0.17 - spine * 0.10 ); - settlementScore[i] = clamp(ruralSuitability[i] * 0.58 + townSuitability[i] * 0.34 + confluence * 0.10); - const naturalBarrier = naturalBarrierScore?.[i] || 0; + settlementScore[i] = clamp( + ruralSuitability[i] * 0.48 + + townSuitability[i] * 0.30 + + confluence * 0.08 + + fieldValue(geoHabitability, i, developable[i]) * 0.18 + + fieldValue(geoNaturalCentrality, i, 0) * 0.12 - + fieldValue(geoBarrier, i, 0) * 0.06 + ); barrierCost[i] = 1 + slope[i] * 6.4 + ridgeField[i] * 3.2 + spine * 2.2 + highPenalty * 5.8 + river[i] * 0.25 + naturalBarrier * 1.2 - valleyField[i] * 0.55 - plain[i] * 0.30 - coastalLowland[i] * 0.14; corridorCost[i] = Math.max(0.25, barrierCost[i] - developable[i] * 0.42 - valleySettlement[i] * 0.36 - coastalSettlement[i] * 0.12 + hash2(x, y, seed + 7011) * 0.05); } @@ -179,6 +208,11 @@ export function generateMapFeatures(seed, terrain) { coastCells: 0, townCells: 0, plainCells: 0, + highCentralityCells: 0, + habitabilitySum: 0, + accessibilitySum: 0, + centralitySum: 0, + lowlandCapacitySum: 0, minX: MAP_W, minY: MAP_H, maxX: 0, @@ -196,12 +230,21 @@ export function generateMapFeatures(seed, terrain) { if (regionId < 0) continue; const st = ensureRegion(regionId); st.area++; + const gHabit = fieldValue(geoHabitability, i, developable[i]); + const gAccess = fieldValue(geoAccessibility, i, 0); + const gCentral = fieldValue(geoNaturalCentrality, i, townSuitability[i]); + const gLow = fieldValue(geoLowlandCapacity, i, plain[i]); st.developableSum += developable[i]; - if (developable[i] > 0.16) st.developableCells++; - if (valleySettlement[i] > 0.24) st.valleyCells++; - if (coastalSettlement[i] > 0.25) st.coastCells++; - if (townSuitability[i] > 0.28) st.townCells++; - if (plain[i] > 0.24) st.plainCells++; + st.habitabilitySum += gHabit; + st.accessibilitySum += gAccess; + st.centralitySum += gCentral; + st.lowlandCapacitySum += gLow; + if (developable[i] > 0.16 || gHabit > 0.24) st.developableCells++; + if (valleySettlement[i] > 0.24 || fieldValue(geoValleyAccess, i, 0) > 0.25) st.valleyCells++; + if (coastalSettlement[i] > 0.25 || fieldValue(geoCoastalAccess, i, 0) > 0.24) st.coastCells++; + if (townSuitability[i] > 0.28 || gCentral > 0.31) st.townCells++; + if (plain[i] > 0.24 || gLow > 0.26) st.plainCells++; + if (gCentral > 0.36 && gHabit > 0.18) st.highCentralityCells++; st.minX = Math.min(st.minX, x); st.minY = Math.min(st.minY, y); st.maxX = Math.max(st.maxX, x); @@ -305,24 +348,87 @@ export function generateMapFeatures(seed, terrain) { predicate: (x, y, i) => elevation[i] > 0.42 && !sea[i], }).map((p) => ({ ...p, kind: "Pass" })); + // Phase 2: provisional upper-tier settlement anchors are selected directly + // from the unified geography fields before lower-tier villages and market + // towns are placed. These anchors are not rendered as separate settlements; + // they guide city selection and lower-tier spacing. + const geographicUrbanAnchorScore = new Float32Array(SIZE); + for (let i = 0; i < SIZE; i++) { + if (sea[i]) continue; + const gHabit = fieldValue(geoHabitability, i, developable[i]); + const gAccess = fieldValue(geoAccessibility, i, 0); + const gCentral = fieldValue(geoNaturalCentrality, i, townSuitability[i]); + const gLow = fieldValue(geoLowlandCapacity, i, plain[i]); + const gValley = fieldValue(geoValleyAccess, i, valleySettlement[i]); + const gCoast = fieldValue(geoCoastalAccess, i, coastalSettlement[i]); + const gBarrier = fieldValue(geoBarrier, i, naturalBarrierScore?.[i] || 0); + geographicUrbanAnchorScore[i] = clamp( + gCentral * 0.58 + + gHabit * 0.34 + + gAccess * 0.28 + + gLow * 0.20 + + gValley * 0.08 + + gCoast * 0.10 + + (portSuitability?.[i] || 0) * 0.12 + + (crossingSuitability?.[i] || 0) * 0.08 + + confluenceField[i] * 0.06 - + gBarrier * 0.26 - + slope[i] * 0.10 + ); + } + const geographicUrbanAnchors = pickRegionalPoints(geographicUrbanAnchorScore, { + stride: 2, + threshold: 0.33 + rand(seed, 1026) * 0.025, + totalMax: 18, + minDistance: 24, + seedOffset: 1025, + kind: "Geographic Urban Anchor", + predicate: (x, y, i) => fieldValue(geoHabitability, i, developable[i]) > 0.15 && fieldValue(geoBarrier, i, 0) < 0.58 && slope[i] < 0.42, + quotaForRegion: (regionId, st) => { + if (!st || st.developableCells < 40) return 0; + const vf = visibilityFactor(regionId, st); + const centralCells = st.highCentralityCells || 0; + const raw = (centralCells / 180 + st.developableCells / 980 + 0.85) * vf; + const min = st.area > 1800 || st.developableCells > 260 ? 1 : 0; + const max = st.area > 4200 ? 4 : st.area > 2400 ? 3 : st.area > 900 ? 2 : 1; + return Math.round(clamp(raw + rand(seed, 1027 + regionId * 17) * 0.7, min, max)); + }, + extraScore: (x, y, i) => fieldValue(geoNaturalCentrality, i, 0) * 0.18 + fieldValue(geoAccessibility, i, 0) * 0.10, + }).map((p) => ({ ...p, candidateKind: "geographicAnchor", anchorScore: p.score })); + const geographicAnchorInfluence = influenceFromPoints(geographicUrbanAnchors, 20, (p) => clamp((p.anchorScore || p.score || 0.4) * 1.35, 0.45, 1.35)); + const villageScore = new Float32Array(SIZE); for (let i = 0; i < SIZE; i++) { if (sea[i]) continue; - villageScore[i] = clamp(ruralSuitability[i] * 0.50 + agriculture[i] * 0.48 + plain[i] * 0.42 + Math.max(0, plain[i] * 1.12 + agriculture[i] * 0.78 + basinField[i] * 0.30 - river[i] * 0.36 - valleyField[i] * 0.14 - flowAccum[i] * 0.10) * 0.58 + valleySettlement[i] * 0.06 + coastalSettlement[i] * 0.34 + settlementCluster[i] * 0.22 - river[i] * 0.10 - flowAccum[i] * 0.04); + villageScore[i] = clamp( + ruralSuitability[i] * 0.42 + + agriculture[i] * 0.42 + + plain[i] * 0.34 + + fieldValue(geoHabitability, i, developable[i]) * 0.22 + + fieldValue(geoLowlandCapacity, i, plain[i]) * 0.18 + + Math.max(0, plain[i] * 1.12 + agriculture[i] * 0.78 + basinField[i] * 0.30 - river[i] * 0.36 - valleyField[i] * 0.14 - flowAccum[i] * 0.10) * 0.48 + + valleySettlement[i] * 0.06 + + coastalSettlement[i] * 0.28 + + settlementCluster[i] * 0.18 - + geographicAnchorInfluence[i] * 0.08 - + fieldValue(geoBarrier, i, 0) * 0.10 - + river[i] * 0.10 - + flowAccum[i] * 0.04 + ); } let villages = pickRegionalPoints(villageScore, { stride: 2, threshold: 0.18 + rand(seed, 1031) * 0.030, - totalMax: 280, - minDistance: 4, + totalMax: 190, + minDistance: 6, seedOffset: 1030, kind: "Village", quotaForRegion: (regionId, st) => { if (!st || st.developableCells < 10) return 0; const vf = visibilityFactor(regionId, st); - const raw = (st.developableCells / 28 + st.plainCells / 42 + st.valleyCells / 34 + st.coastCells / 32 + 3.2) * vf; - const min = st.area > 2600 ? 20 : st.area > 1400 ? 12 : st.area > 520 ? 5 : st.area > 220 ? 2 : 0; - const max = st.area > 3600 ? 72 : st.area > 2200 ? 50 : st.area > 900 ? 25 : 10; + const raw = (st.developableCells / 40 + st.plainCells / 58 + st.valleyCells / 48 + st.coastCells / 46 + 2.1) * vf; + const min = st.area > 2600 ? 12 : st.area > 1400 ? 7 : st.area > 520 ? 3 : st.area > 220 ? 1 : 0; + const max = st.area > 3600 ? 46 : st.area > 2200 ? 32 : st.area > 900 ? 16 : 7; return Math.round(clamp(raw + rand(seed, 1033 + regionId * 19) * 1.5, min, max)); }, }).map((p, n) => { @@ -338,26 +444,26 @@ export function generateMapFeatures(seed, terrain) { for (let i = 0; i < SIZE; i++) { if (sea[i]) continue; const open = Math.max(0, plain[i] * 0.92 + agriculture[i] * 0.72 + basinField[i] * 0.26 + (depositionalLowland?.[i] || 0) * 0.20 - river[i] * 0.22 - valleyField[i] * 0.10 - flowAccum[i] * 0.08 - slope[i] * 0.24 - ridgeField[i] * 0.14); - openPlainVillageScore[i] = clamp(open + settlementCluster[i] * 0.16 + ruralSuitability[i] * 0.18); + openPlainVillageScore[i] = clamp(open * 0.82 + settlementCluster[i] * 0.14 + ruralSuitability[i] * 0.16 + fieldValue(geoHabitability, i, developable[i]) * 0.16 + fieldValue(geoLowlandCapacity, i, plain[i]) * 0.12 - geographicAnchorInfluence[i] * 0.05); } const supplementalPlainVillages = pickRegionalPoints(openPlainVillageScore, { stride: 2, threshold: 0.235 + rand(seed, 1036) * 0.020, - totalMax: 120, - minDistance: 5, + totalMax: 60, + minDistance: 7, seedOffset: 1035, kind: "Plain Village", predicate: (x, y, i) => plain[i] > 0.20 && agriculture[i] > 0.16 && river[i] < 0.30 && valleyField[i] < 0.52 && slope[i] < 0.32, quotaForRegion: (regionId, st) => { if (!st || st.plainCells < 24) return 0; const vf = visibilityFactor(regionId, st); - const raw = (st.plainCells / 62 + st.developableCells / 180 + 1.4) * vf; - const min = st.plainCells > 360 ? 6 : st.plainCells > 160 ? 3 : st.plainCells > 70 ? 1 : 0; - const max = st.plainCells > 720 ? 24 : st.plainCells > 360 ? 16 : st.plainCells > 140 ? 8 : 3; + const raw = (st.plainCells / 92 + st.developableCells / 260 + 0.9) * vf; + const min = st.plainCells > 360 ? 3 : st.plainCells > 160 ? 1 : st.plainCells > 90 ? 1 : 0; + const max = st.plainCells > 720 ? 12 : st.plainCells > 360 ? 8 : st.plainCells > 140 ? 4 : 2; return Math.round(clamp(raw + rand(seed, 1037 + regionId * 29) * 1.4, min, max)); }, extraScore: (x, y, i) => Math.max(0, plain[i] * 0.34 + agriculture[i] * 0.26 - river[i] * 0.20 - valleyField[i] * 0.12), - }).filter((p) => distanceToNearest(villages, p.x, p.y) >= 4.5) + }).filter((p) => distanceToNearest(villages, p.x, p.y) >= 6.5) .map((p, n) => { const i = indexOf(p.x, p.y); const population = Math.round((1100 + Math.pow(rand(seed, 18220 + n * 31 + p.x * 7 + p.y), 1.12) * 7600 + agriculture[i] * 3900 + plain[i] * 2200) / 100) * 100; @@ -365,7 +471,7 @@ export function generateMapFeatures(seed, terrain) { }); villages = [...villages, ...supplementalPlainVillages]; - const villageInfluence = influenceFromPoints(villages, 6, (v) => clamp((v.population || 1800) / 4200, 0.35, 1.2)); + let villageInfluence = influenceFromPoints(villages, 6, (v) => clamp((v.population || 1800) / 4200, 0.35, 1.2)); const marketScore = new Float32Array(SIZE); for (let y = 2; y < MAP_H - 2; y++) { @@ -380,19 +486,24 @@ export function generateMapFeatures(seed, terrain) { ); const valleyMouth = valleyField[i] > 0.22 && (plain[i] > 0.22 || basinField[i] > 0.18 || coastalLowland[i] > 0.18) ? 0.14 : 0; marketScore[i] = clamp( - townSuitability[i] * 0.50 + - agriculture[i] * 0.30 + - plain[i] * 0.28 + - openPlainMarket * 0.74 + - coastalSettlement[i] * 0.22 + - villageInfluence[i] * 0.28 + + townSuitability[i] * 0.38 + + fieldValue(geoNaturalCentrality, i, townSuitability[i]) * 0.36 + + fieldValue(geoAccessibility, i, 0) * 0.18 + + fieldValue(geoHabitability, i, developable[i]) * 0.18 + + agriculture[i] * 0.24 + + plain[i] * 0.22 + + openPlainMarket * 0.58 + + coastalSettlement[i] * 0.18 + + villageInfluence[i] * 0.24 + + geographicAnchorInfluence[i] * 0.08 + featurePull + valleyMouth + - basinField[i] * 0.14 + - plain[i] * 0.22 + - coastalLowland[i] * 0.08 - - slope[i] * 0.18 - - ridgeField[i] * 0.08 - + basinField[i] * 0.12 + + plain[i] * 0.16 + + coastalLowland[i] * 0.07 - + fieldValue(geoBarrier, i, 0) * 0.12 - + slope[i] * 0.16 - + ridgeField[i] * 0.07 - river[i] * 0.08 - flowAccum[i] * 0.035 ); @@ -402,16 +513,16 @@ export function generateMapFeatures(seed, terrain) { let markets = pickRegionalPoints(marketScore, { stride: 2, threshold: 0.245 + rand(seed, 1041) * 0.035, - totalMax: 110, - minDistance: 6, + totalMax: 70, + minDistance: 9, seedOffset: 1040, kind: "Market Town", quotaForRegion: (regionId, st) => { if (!st || st.townCells < 8) return 0; const vf = visibilityFactor(regionId, st); - const raw = (st.developableCells / 92 + st.plainCells / 108 + st.valleyCells / 92 + st.coastCells / 72 + 2.7) * vf; - const min = st.area > 2600 ? 9 : st.area > 1200 ? 5 : st.area > 520 ? 2 : 0; - const max = st.area > 3600 ? 30 : st.area > 2200 ? 22 : st.area > 800 ? 10 : 5; + const raw = (st.developableCells / 132 + st.plainCells / 148 + st.valleyCells / 128 + st.coastCells / 104 + 1.8) * vf; + const min = st.area > 2600 ? 5 : st.area > 1200 ? 3 : st.area > 520 ? 1 : 0; + const max = st.area > 3600 ? 20 : st.area > 2200 ? 14 : st.area > 800 ? 7 : 3; return Math.round(clamp(raw + rand(seed, 1043 + regionId * 23) * 0.8, min, max)); }, extraScore: (x, y, i) => (distanceToNearest(commercialPorts, x, y) < 10 ? 0.12 : 0) + coastalSettlement[i] * 0.08 + Math.max(0, plain[i] * 0.32 + agriculture[i] * 0.20 - river[i] * 0.16) * 0.09 + confluenceField[i] * 0.035, @@ -426,26 +537,26 @@ export function generateMapFeatures(seed, terrain) { for (let i = 0; i < SIZE; i++) { if (sea[i]) continue; const open = Math.max(0, plain[i] * 0.86 + agriculture[i] * 0.64 + basinField[i] * 0.28 + (depositionalLowland?.[i] || 0) * 0.22 - river[i] * 0.20 - valleyField[i] * 0.10 - flowAccum[i] * 0.08 - slope[i] * 0.24 - ridgeField[i] * 0.16); - openPlainMarketScore[i] = clamp(open + townSuitability[i] * 0.20 + villageInfluence[i] * 0.18 + settlementCluster[i] * 0.12); + openPlainMarketScore[i] = clamp(open * 0.78 + townSuitability[i] * 0.16 + fieldValue(geoNaturalCentrality, i, townSuitability[i]) * 0.18 + fieldValue(geoHabitability, i, developable[i]) * 0.12 + villageInfluence[i] * 0.16 + settlementCluster[i] * 0.10 + geographicAnchorInfluence[i] * 0.05); } const supplementalPlainMarkets = pickRegionalPoints(openPlainMarketScore, { stride: 2, threshold: 0.335 + rand(seed, 1046) * 0.025, - totalMax: 55, - minDistance: 9, + totalMax: 26, + minDistance: 12, seedOffset: 1045, kind: "Plain Market Town", predicate: (x, y, i) => plain[i] > 0.22 && agriculture[i] > 0.18 && river[i] < 0.28 && valleyField[i] < 0.50 && slope[i] < 0.30, quotaForRegion: (regionId, st) => { if (!st || st.plainCells < 60) return 0; const vf = visibilityFactor(regionId, st); - const raw = (st.plainCells / 260 + st.developableCells / 520 + 0.45) * vf; - const min = st.plainCells > 520 ? 2 : st.plainCells > 220 ? 1 : 0; - const max = st.plainCells > 900 ? 8 : st.plainCells > 420 ? 5 : st.plainCells > 160 ? 2 : 1; + const raw = (st.plainCells / 380 + st.developableCells / 720 + 0.25) * vf; + const min = st.plainCells > 520 ? 1 : st.plainCells > 260 ? 1 : 0; + const max = st.plainCells > 900 ? 4 : st.plainCells > 420 ? 3 : st.plainCells > 160 ? 1 : 1; return Math.round(clamp(raw + rand(seed, 1047 + regionId * 31) * 0.9, min, max)); }, extraScore: (x, y, i) => Math.max(0, plain[i] * 0.24 + agriculture[i] * 0.18 - river[i] * 0.14 - valleyField[i] * 0.08), - }).filter((p) => distanceToNearest(markets, p.x, p.y) >= 8.5 && distanceToNearest(villages, p.x, p.y) >= 3.5) + }).filter((p) => distanceToNearest(markets, p.x, p.y) >= 10.5 && distanceToNearest(villages, p.x, p.y) >= 4.5) .map((p, n) => { const i = indexOf(p.x, p.y); const population = Math.round((10000 + Math.pow(rand(seed, 18340 + n * 37 + p.x * 11 + p.y), 1.02) * 52000 + openPlainMarketScore[i] * 26000 + agriculture[i] * 9000 + plain[i] * 8000) / 1000) * 1000; @@ -453,6 +564,44 @@ export function generateMapFeatures(seed, terrain) { }); markets = [...markets, ...supplementalPlainMarkets]; + // Sparse-area towns: when a developable basin/plain/coast has few nearby towns, + // add a small market town candidate. This avoids large inhabited regions being + // empty while still keeping minimum spacing from existing settlements. + const existingTownInfluenceForSparseFill = influenceFromPoints([...markets, ...commercialPorts], 18, (p) => clamp((p.population || 9000) / 32000, 0.35, 1.25)); + const existingSettlementInfluenceForSparseFill = influenceFromPoints([...markets, ...villages, ...ports], 12, (p) => clamp((p.population || 1800) / 16000, 0.18, 1.0)); + const sparseTownScore = new Float32Array(SIZE); + for (let i = 0; i < SIZE; i++) { + if (sea[i]) continue; + const remoteness = clamp((0.30 - existingTownInfluenceForSparseFill[i]) / 0.30); + const settlementGap = clamp((0.42 - existingSettlementInfluenceForSparseFill[i]) / 0.42); + const livable = clamp(townSuitability[i] * 0.34 + fieldValue(geoNaturalCentrality, i, townSuitability[i]) * 0.22 + fieldValue(geoHabitability, i, developable[i]) * 0.20 + fieldValue(geoAccessibility, i, 0) * 0.12 + developable[i] * 0.22 + plain[i] * 0.24 + agriculture[i] * 0.18 + basinField[i] * 0.16 + coastalSettlement[i] * 0.14 + valleySettlement[i] * 0.12 - fieldValue(geoBarrier, i, 0) * 0.12 - slope[i] * 0.22 - ridgeField[i] * 0.10); + sparseTownScore[i] = clamp(livable * (0.42 + remoteness * 0.88) + settlementGap * 0.16); + } + const sparseMarkets = pickRegionalPoints(sparseTownScore, { + stride: 2, + threshold: 0.315 + rand(seed, 1049) * 0.025, + totalMax: 24, + minDistance: 15, + seedOffset: 1048, + kind: "Sparse Market Town", + predicate: (x, y, i) => existingTownInfluenceForSparseFill[i] < 0.34 && developable[i] > 0.12 && slope[i] < 0.36 && ridgeField[i] < 0.55, + quotaForRegion: (regionId, st) => { + if (!st || st.developableCells < 90) return 0; + const vf = visibilityFactor(regionId, st); + const underServed = clamp(1.0 - ((markets.filter((m) => m.regionId === regionId).length || 0) / Math.max(1, st.area / 850))); + const raw = (st.developableCells / 900 + st.plainCells / 720 + st.coastCells / 560 + 0.55) * vf * (0.55 + underServed * 0.75); + return Math.round(clamp(raw + rand(seed, 1050 + regionId * 37) * 0.45, 0, st.area > 2000 ? 2 : 1)); + }, + extraScore: (x, y, i) => clamp((0.34 - existingTownInfluenceForSparseFill[i]) * 0.38 + plain[i] * 0.10 + agriculture[i] * 0.08 + coastalSettlement[i] * 0.06), + }) + .filter((p) => distanceToNearest(markets, p.x, p.y) >= 12 && distanceToNearest(villages, p.x, p.y) >= 4.5) + .map((p, n) => { + const i = indexOf(p.x, p.y); + const population = Math.round((8000 + Math.pow(rand(seed, 18480 + n * 41 + p.x * 13 + p.y), 1.08) * 36000 + sparseTownScore[i] * 26000) / 1000) * 1000; + return { ...p, kind: "Sparse Market Town", population }; + }); + markets = [...markets, ...sparseMarkets]; + const defenseScore = new Float32Array(SIZE); for (let i = 0; i < SIZE; i++) { if (sea[i]) continue; @@ -498,10 +647,10 @@ export function generateMapFeatures(seed, terrain) { if (centerRegion >= 0 && regionIdAt(x, y) !== centerRegion) continue; const d = Math.hypot(dx, dy); if (d > radius) continue; - const dev = developable[i]; + const dev = clamp(developable[i] * 0.66 + fieldValue(geoHabitability, i, developable[i]) * 0.34); if (dev < 0.04) continue; const radial = clamp(1 - d / Math.max(1, radius)); - const terrainMultiplier = clamp(0.60 + plain[i] * 0.48 + agriculture[i] * 0.22 + basinField[i] * 0.28 + coastalLowland[i] * 0.18 + valleyField[i] * 0.08 - slope[i] * 0.42 - ridgeField[i] * 0.18, 0.26, 1.38); + const terrainMultiplier = clamp(0.58 + plain[i] * 0.40 + agriculture[i] * 0.18 + basinField[i] * 0.24 + coastalLowland[i] * 0.16 + valleyField[i] * 0.06 + fieldValue(geoLowlandCapacity, i, plain[i]) * 0.24 + fieldValue(geoAccessibility, i, 0) * 0.12 - fieldValue(geoBarrier, i, 0) * 0.18 - slope[i] * 0.34 - ridgeField[i] * 0.14, 0.24, 1.46); capacity += dev * (900 + 6200 * Math.pow(radial, 1.25)) * terrainMultiplier * densityBias; } } @@ -509,6 +658,7 @@ export function generateMapFeatures(seed, terrain) { } const urbanCandidates = [ + ...geographicUrbanAnchors.map((p) => ({ ...p, candidateKind: "geographicAnchor" })), ...markets.map((p) => ({ ...p, candidateKind: "town" })), ...castleTowns.map((p) => ({ ...p, candidateKind: "castleTown" })), ...commercialPorts.map((p) => ({ ...p, candidateKind: "port" })), @@ -524,12 +674,17 @@ export function generateMapFeatures(seed, terrain) { const cityRadius = st && st.area > 2400 ? 30 : st && st.area > 900 ? 26 : 22; const capacity = estimateUrbanCapacity(p, cityRadius, p.candidateKind === "town" ? 1.14 : p.candidateKind === "port" ? 1.10 : 1.06); const score = - Math.log10(capacity + 1) * 0.72 + - townSuitability[i] * 1.40 + - developable[i] * 1.05 + - confluenceField[i] * 0.22 + + Math.log10(capacity + 1) * 0.66 + + townSuitability[i] * 0.86 + + developable[i] * 0.68 + + fieldValue(geoNaturalCentrality, i, townSuitability[i]) * 1.34 + + fieldValue(geoHabitability, i, developable[i]) * 0.58 + + fieldValue(geoAccessibility, i, 0) * 0.44 + + confluenceField[i] * 0.18 + + (p.candidateKind === "geographicAnchor" ? 0.42 : 0) + (p.candidateKind === "port" ? 0.48 : 0) + - (p.candidateKind === "castleTown" ? 0.22 : 0) + + (p.candidateKind === "castleTown" ? 0.22 : 0) - + fieldValue(geoBarrier, i, 0) * 0.36 + hash2(p.x, p.y, seed + 12000) * 0.16; if (!cityCandidateByRegion.has(regionId)) cityCandidateByRegion.set(regionId, []); cityCandidateByRegion.get(regionId).push({ ...p, score, capacity, regionId }); @@ -542,19 +697,23 @@ export function generateMapFeatures(seed, terrain) { if (!st || st.developableCells < 30) continue; const vf = visibilityFactor(regionId, st); const maxCities = clamp( - Math.round((st.developableCells / 560 + 1.15) * vf + rand(seed, 12100 + regionId * 17) * 1.4), + Math.round((st.developableCells / 680 + (st.highCentralityCells || 0) / 360 + 0.95) * vf + rand(seed, 12100 + regionId * 17) * 1.1), (st.area > 900 || st.developableCells > 180) ? 1 : 0, st.area > 3600 ? 6 : st.area > 2200 ? 4 : st.area > 900 ? 3 : 1 ); const selected = pickEntities(list, { max: maxCities, - minDistance: 17, + minDistance: 24, threshold: 0, seed: seed + 12110 + regionId * 313, jitter: 0.02, }); for (const p of selected) { - if (usedCitySites.some((q) => Math.hypot(q.x - p.x, q.y - p.y) < 12)) continue; + const minD = p.capacity >= 420000 ? 34 : p.capacity >= 220000 ? 28 : p.capacity >= 110000 ? 23 : 20; + if (usedCitySites.some((q) => { + const qMinD = q.capacity >= 420000 ? 34 : q.capacity >= 220000 ? 28 : q.capacity >= 110000 ? 23 : 20; + return Math.hypot(q.x - p.x, q.y - p.y) < Math.max(minD, qMinD) * 0.82; + })) continue; usedCitySites.push(p); modernCities.push(p); } @@ -565,18 +724,30 @@ export function generateMapFeatures(seed, terrain) { // regional pass. modernCities.sort((a, b) => b.capacity - a.capacity || b.score - a.score); + const regionalCapitalSlots = Math.max(1, Math.min(4, Math.round(Math.sqrt(Math.max(1, modernCities.length))))); for (const [rank, city] of modernCities.entries()) { + const i = indexOf(city.x, city.y); + const st = regionStats.get(city.regionId); const isFirstInRegion = !modernCities.slice(0, rank).some((c) => c.regionId === city.regionId); const isPrefecturalCapital = inFocusedPrefecture(city) && !modernCities.slice(0, rank).some((c) => c.isPrefecturalCapital); - const isRegionalCapital = isFirstInRegion; + const geoTierScore = clamp( + fieldValue(geoNaturalCentrality, i, 0) * 0.48 + + fieldValue(geoHabitability, i, 0) * 0.24 + + fieldValue(geoAccessibility, i, 0) * 0.18 + + Math.log10((city.capacity || 26000) + 1) / 7 * 0.26 + ); + const isTopCenter = rank < regionalCapitalSlots || geoTierScore > 0.58; + const isRegionalCapital = isFirstInRegion && (isTopCenter || (city.capacity || 0) > 210000 || (st?.highCentralityCells || 0) > 220); const rawPop = isRegionalCapital - ? 300000 + rand(seed, 12201 + city.regionId * 17) * 740000 - : 68000 + Math.pow(rand(seed, 12202 + rank * 19 + city.x), 0.62) * 430000; - const capMultiplier = isRegionalCapital ? 1.68 : 1.28; + ? 180000 + rand(seed, 12201 + city.regionId * 17) * (isTopCenter ? 760000 : 360000) + : 52000 + Math.pow(rand(seed, 12202 + rank * 19 + city.x), 0.68) * 360000; + const capMultiplier = isRegionalCapital ? (isTopCenter ? 1.66 : 1.42) : 1.20; const population = Math.round(Math.min(rawPop, city.capacity * capMultiplier) / 1000) * 1000; - city.population = Math.max(isRegionalCapital ? 260000 : 52000, population); + const floor = isRegionalCapital ? (isTopCenter ? 210000 : 120000) : 42000; + city.population = Math.max(floor, population); city.isPrefecturalCapital = isPrefecturalCapital; city.isRegionalCapital = isRegionalCapital; + city.geographicTierScore = Math.round(geoTierScore * 1000) / 1000; city.rank = isPrefecturalCapital ? "Prefectural Capital" : isRegionalCapital ? "Regional Capital" : city.population >= 200000 ? "Regional City" : "Local City"; city.kind = city.rank; city.urbanRadius = clamp(5.8 + Math.sqrt(city.population) / 72, 8, isRegionalCapital ? 34 : 24); @@ -585,6 +756,40 @@ export function generateMapFeatures(seed, terrain) { city.urbanWeight = clamp(0.95 + Math.log10(Math.max(10000, city.population)) * 0.29, 1.08, 2.5); } + function urbanSettlementExclusionRadius(city, tier = "market") { + const pop = city?.population || 0; + const base = pop >= 500000 ? 9.5 : pop >= 240000 ? 7.6 : pop >= 110000 ? 6.0 : 4.8; + return tier === "village" ? base + 2.0 : base; + } + const marketsBeforeHierarchyFilter = markets.length; + const villagesBeforeHierarchyFilter = villages.length; + markets = markets.filter((m) => { + const nearestCity = modernCities.reduce((best, city) => { + const d = Math.hypot(m.x - city.x, m.y - city.y); + return d < best.d ? { city, d } : best; + }, { city: null, d: Infinity }); + if (!nearestCity.city) return true; + return nearestCity.d >= urbanSettlementExclusionRadius(nearestCity.city, "market"); + }); + villages = villages.filter((v) => { + const nearestCity = modernCities.reduce((best, city) => { + const d = Math.hypot(v.x - city.x, v.y - city.y); + return d < best.d ? { city, d } : best; + }, { city: null, d: Infinity }); + if (nearestCity.city && nearestCity.d < urbanSettlementExclusionRadius(nearestCity.city, "village")) return false; + return distanceToNearest(markets, v.x, v.y) >= 3.4; + }); + villageInfluence = influenceFromPoints(villages, 6, (v) => clamp((v.population || 1800) / 4200, 0.35, 1.2)); + const settlementHierarchyDebug = { + version: "phase2-unified-settlement-hierarchy", + geographicUrbanAnchors: geographicUrbanAnchors.length, + marketsBeforeHierarchyFilter, + marketsAfterHierarchyFilter: markets.length, + villagesBeforeHierarchyFilter, + villagesAfterHierarchyFilter: villages.length, + regionalCapitalSlots, + }; + function cityPopulationCap(city) { const radius = city?.isRegionalCapital ? 29 : city?.population >= 350000 ? 26 : 18; const bias = city?.isRegionalCapital ? 1.12 : 1.0; @@ -646,11 +851,19 @@ export function generateMapFeatures(seed, terrain) { return total > 0 ? sum / total : 0; } + function highAltitudeTransportClosed(i) { + // Above this contour the generator should treat mountains as no-road + // terrain. A strong mapped pass is the exception, so genuine saddle + // crossings can still exist without roads drilling through entire ranges. + return elevation[i] >= 0.70; + } + for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { const i = indexOf(x, y); - if (sea[i]) { + if (sea[i] || highAltitudeTransportClosed(i)) { expressway[i] = rail[i] = national[i] = local[i] = INF; + expresswayPotential[i] = railPotential[i] = nationalPotential[i] = localPotential[i] = 0; continue; } const density = settlementDemand[i]; @@ -661,17 +874,21 @@ export function generateMapFeatures(seed, terrain) { const crossing = crossingSuitability?.[i] || 0; const waterCrossingPenalty = river[i] > 0.18 ? (1 - crossing) * (0.72 + river[i] * 1.35) : 0; const seaNear = seaAdjacency(x, y, 1); - const seaBroad = seaAdjacency(x, y, 2); - const coastalTraversePenalty = clamp(seaBroad * 1.35 - coastalLowland[i] * 0.58 - (portSuitability?.[i] || 0) * 0.38); + const seaBroad = seaAdjacency(x, y, 3); + const seaWide = seaAdjacency(x, y, 5); + // Roads should use coastal lowlands when there is a settlement/port reason, + // but should not casually trace beaches or hop over small bays. + const coastalTraversePenalty = clamp(seaBroad * 1.72 + seaWide * 0.82 - coastalLowland[i] * 0.48 - (portSuitability?.[i] || 0) * 0.30); const highMountain = clamp((elevation[i] - 0.52) * 3.6 + slope[i] * 0.95 + ridgeField[i] * 1.05 - pass * 0.55 - valleyField[i] * 0.12); const extremeMountain = clamp((elevation[i] - 0.64) * 4.8 + slope[i] * 1.55 + ridgeField[i] * 1.45 - pass * 0.80); const denseCorePenalty = clamp((density - 0.66) / 0.28); const openPlainParallelPenalty = clamp(plain[i] * 0.48 + agriculture[i] * 0.26 - density * 0.20 - valleyField[i] * 0.20 - coastalLowland[i] * 0.12); + const boundaryRidgePenalty = Math.pow(naturalBarrierScore?.[i] || 0, 2); if (extremeMountain > 0.92 && pass < 0.34) { expressway[i] = rail[i] = INF; - national[i] = elevation[i] > 0.70 ? INF : 2.6 + extremeMountain * 2.4 + waterCrossingPenalty; - local[i] = 1.8 + extremeMountain * 1.8 + waterCrossingPenalty * 0.45; + national[i] = elevation[i] >= 0.68 ? INF : 2.9 + extremeMountain * 2.8 + waterCrossingPenalty; + local[i] = elevation[i] >= 0.69 ? INF : 2.2 + extremeMountain * 2.3 + waterCrossingPenalty * 0.55; expresswayPotential[i] = 0; railPotential[i] = 0; nationalPotential[i] = clamp(lowland * 0.18 + pass * 0.24 - extremeMountain * 0.55); @@ -733,9 +950,11 @@ export function generateMapFeatures(seed, terrain) { slope[i] * 5.4 + highMountain * 5.8 + extremeMountain * 4.2 + + boundaryRidgePenalty * 4.2 + waterCrossingPenalty * 2.1 + - coastalTraversePenalty * 1.8 + - seaNear * 1.7 + + coastalTraversePenalty * 2.65 + + seaNear * 2.35 + + seaWide * 1.10 + openPlainParallelPenalty * 0.12 + hash2(x, y, seed + 13301) * 0.04 ); @@ -744,9 +963,11 @@ export function generateMapFeatures(seed, terrain) { slope[i] * 7.2 + highMountain * 7.0 + extremeMountain * 4.8 + + boundaryRidgePenalty * 2.4 + waterCrossingPenalty * 1.7 + - coastalTraversePenalty * 1.2 + - seaNear * 1.1 + + coastalTraversePenalty * 1.75 + + seaNear * 1.50 + + seaWide * 0.70 + hash2(x, y, seed + 13302) * 0.03 ); national[i] = Math.max(0.16, @@ -755,10 +976,13 @@ export function generateMapFeatures(seed, terrain) { ridgeField[i] * 1.18 + Math.max(0, elevation[i] - 0.62) * 3.0 + highMountain * 2.9 + + boundaryRidgePenalty * 1.8 + waterCrossingPenalty * 1.25 - valleyField[i] * 0.18 - coastalLowland[i] * 0.08 + - coastalTraversePenalty * 0.95 - + coastalTraversePenalty * 1.55 + + seaNear * 0.84 + + seaWide * 0.48 - pass * 0.42 + hash2(x, y, seed + 13303) * 0.05 ); @@ -768,10 +992,13 @@ export function generateMapFeatures(seed, terrain) { ridgeField[i] * 0.82 + Math.max(0, elevation[i] - 0.68) * 1.9 + highMountain * 1.24 + + boundaryRidgePenalty * 0.72 + waterCrossingPenalty * 0.65 - valleyField[i] * 0.22 - coastalLowland[i] * 0.10 + - coastalTraversePenalty * 0.38 + + coastalTraversePenalty * 0.82 + + seaNear * 0.48 + + seaWide * 0.26 + hash2(x, y, seed + 13304) * 0.07 ); } @@ -947,6 +1174,27 @@ export function generateMapFeatures(seed, terrain) { } } + function densifyPathByCost(path, costField) { + if (!path || path.length < 2) return path || []; + const out = []; + for (let k = 0; k < path.length - 1; k++) { + const [x0, y0] = path[k]; + const [x1, y1] = path[k + 1]; + const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0))); + for (let s = 0; s <= steps; s++) { + if (k > 0 && s === 0) continue; + const t = s / steps; + const x = Math.round(x0 + (x1 - x0) * t); + const y = Math.round(y0 + (y1 - y0) * t); + if (!inside(x, y)) return []; + const i = indexOf(x, y); + if (sea[i] || costField?.[i] >= INF) return []; + if (!out.length || out[out.length - 1][0] !== x || out[out.length - 1][1] !== y) out.push([x, y]); + } + } + return out.length >= 2 ? out : []; + } + function relaxRouteToTerrain(path, costField, options = {}) { if (!path || path.length < 5) return path || []; const radius = options.radius ?? 2; @@ -1001,7 +1249,9 @@ export function generateMapFeatures(seed, terrain) { last = key; } } - return deduped.length >= 2 ? deduped : path; + if (deduped.length < 2) return path; + const dense = densifyPathByCost(deduped, costField); + return dense.length >= 2 ? dense : path; } function endpointFromPath(path) { @@ -1020,14 +1270,97 @@ export function generateMapFeatures(seed, terrain) { return { ...base, ...overrides }; } + function pathWaterCrossingStats(path) { + let seaCells = 0; + let maxSeaRun = 0; + let currentSeaRun = 0; + let sampled = 0; + for (let k = 1; k < (path?.length || 0); k++) { + const a = path[k - 1]; + const b = path[k]; + if (!a || !b) continue; + const steps = Math.max(1, Math.ceil(Math.hypot(a[0] - b[0], a[1] - b[1]))); + for (let s = 0; s <= steps; s++) { + const t = s / steps; + const x = Math.round(a[0] + (b[0] - a[0]) * t); + const y = Math.round(a[1] + (b[1] - a[1]) * t); + sampled++; + const isSea = !inside(x, y) || sea[indexOf(x, y)]; + if (isSea) { + seaCells++; + currentSeaRun++; + maxSeaRun = Math.max(maxSeaRun, currentSeaRun); + } else { + currentSeaRun = 0; + } + } + } + return { seaCells, maxSeaRun, seaShare: sampled ? seaCells / sampled : 0 }; + } + + function pathTunnelStats(path) { + let tunnelCells = 0; + let maxTunnelRun = 0; + let currentTunnelRun = 0; + let sampled = 0; + for (let k = 1; k < (path?.length || 0); k++) { + const a = path[k - 1]; + const b = path[k]; + if (!a || !b) continue; + const steps = Math.max(1, Math.ceil(Math.hypot(a[0] - b[0], a[1] - b[1]))); + for (let s = 0; s <= steps; s++) { + const t = s / steps; + const x = Math.round(a[0] + (b[0] - a[0]) * t); + const y = Math.round(a[1] + (b[1] - a[1]) * t); + if (!inside(x, y)) continue; + sampled++; + const i = indexOf(x, y); + const isTunnel = !sea[i] && ((elevation[i] >= 0.72 && ridgeField[i] >= 0.34) || naturalBarrierScore[i] >= 0.72); + if (isTunnel) { + tunnelCells++; + currentTunnelRun++; + maxTunnelRun = Math.max(maxTunnelRun, currentTunnelRun); + } else { + currentTunnelRun = 0; + } + } + } + return { tunnelCells, maxTunnelRun, tunnelShare: sampled ? tunnelCells / sampled : 0 }; + } + + function routePhysicalAcceptable(path, mode, overrides = {}) { + if (!path?.length) return false; + const water = pathWaterCrossingStats(path); + const tunnel = pathTunnelStats(path); + const bridgeLimit = overrides.bridgeLimit ?? (mode === "expressway" ? 20 : 10); + const tunnelLimit = overrides.tunnelLimit ?? (mode === "expressway" ? 10 : 0); + const maxSeaRun = overrides.maxSeaRun ?? bridgeLimit; + const maxTunnelRun = overrides.maxTunnelRun ?? tunnelLimit; + const maxSeaShare = overrides.maxSeaShare ?? (mode === "expressway" ? 0.22 : mode === "rail" ? 0.030 : mode === "national" ? 0.10 : 0.05); + const maxTunnelShare = overrides.maxTunnelShare ?? (mode === "expressway" ? 0.12 : 0); + if (water.maxSeaRun > maxSeaRun || water.seaShare > maxSeaShare) return false; + if (tunnel.maxTunnelRun > maxTunnelRun || tunnel.tunnelShare > maxTunnelShare) return false; + if (water.seaCells > 0) { + const first = path[0]; + const last = path[path.length - 1]; + const ai = inside(first?.[0], first?.[1]) ? indexOf(first[0], first[1]) : -1; + const bi = inside(last?.[0], last?.[1]) ? indexOf(last[0], last[1]) : -1; + const demand = clamp((ai >= 0 ? settlementDemand[ai] || 0 : 0) + (bi >= 0 ? settlementDemand[bi] || 0 : 0)); + const threshold = mode === "local" ? 0.62 : mode === "rail" ? 0.54 : 0.42; + if (demand < threshold && mode !== "national" && mode !== "expressway") return false; + } + return true; + } + function transportRouteAcceptable(path, mode, potentialField, penaltyField = null, overrides = {}) { + if (!routePhysicalAcceptable(path, mode, overrides)) return false; return routeQualityAcceptable(path, { sea, elevation, slope, potential: potentialField, penalty: penaltyField, - highElevationThreshold: mode === "local" ? 0.78 : 0.72, + highElevationThreshold: 0.70, steepThreshold: mode === "rail" ? 0.34 : mode === "expressway" ? 0.40 : 0.48, }, qualityLimitsForMode(mode, overrides)); } @@ -1313,6 +1646,35 @@ export function generateMapFeatures(seed, terrain) { return full; } + function sampledBarrierBetween(a, b) { + const steps = Math.max(1, Math.ceil(Math.hypot(a.cx - b.cx, a.cy - b.cy))); + let sum = 0; + let n = 0; + let seaHits = 0; + for (let s = 0; s <= steps; s++) { + const t = s / steps; + const x = Math.round(a.cx + (b.cx - a.cx) * t); + const y = Math.round(a.cy + (b.cy - a.cy) * t); + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i]) { seaHits++; sum += 1.25; n++; continue; } + sum += clamp((naturalBarrierScore?.[i] || 0) * 0.75 + ridgeField[i] * 0.28 + slope[i] * 0.25 + Math.max(0, elevation[i] - 0.58) * 0.45 - (passSuitability?.[i] || 0) * 0.35); + n++; + } + return n ? clamp(sum / n + seaHits / n) : 1; + } + + function canRepairConnection(a, b, mode, d) { + const demand = clamp((a.potential || 0) * 0.55 + (b.potential || 0) * 0.55 + Math.sqrt(Math.max(0, a.importance + b.importance)) / 11 - d / 180); + const barrier = sampledBarrierBetween(a, b); + if (mode === "rail" && demand < 0.45) return false; + if (mode === "expressway" && demand < 0.55) return false; + if (mode === "national" && demand < 0.24 && d > 48) return false; + if (barrier > 0.66 && demand < 0.72) return false; + if (d > 90 && demand < 0.75) return false; + return true; + } + function repairTransportConnectivity(paths, mode, costField, potentialField, options = {}) { const debug = { mode, components: [], repairs: [] }; const raster = rasterizeNetworkComponents(paths, mode, potentialField); @@ -1345,7 +1707,9 @@ export function generateMapFeatures(seed, terrain) { if (ca.repairCount >= 2 || cb.repairCount >= 2) continue; const d = Math.hypot(ca.cx - cb.cx, ca.cy - cb.cy); if (d < (options.minRepairDistance ?? 14) || d > (options.maxRepairDistance ?? 120)) continue; - const score = d / Math.sqrt(ca.importance + cb.importance) + (ca.repairCount + cb.repairCount) * 18; + if (!canRepairConnection(ca, cb, mode, d)) continue; + const barrier = sampledBarrierBetween(ca, cb); + const score = d / Math.sqrt(ca.importance + cb.importance) + barrier * 22 + (ca.repairCount + cb.repairCount) * 18; if (score < bestScore) { bestScore = score; bestPair = [ca, cb]; @@ -1564,85 +1928,48 @@ export function generateMapFeatures(seed, terrain) { if (!inside(start.x, start.y) || !inside(target.x, target.y)) return []; if (sea[indexOf(start.x, start.y)] || sea[indexOf(target.x, target.y)]) return []; const d = Math.hypot(start.x - target.x, start.y - target.y); - const snap = options.snapRadius ?? (mode === "expressway" ? 4 : mode === "rail" ? 3 : 3); + const snap = options.snapRadius ?? (mode === "expressway" ? 3 : mode === "rail" ? 2.5 : 2.25); + const searchPad = options.searchPad ?? Math.ceil(Math.max(18, Math.min(58, d * (mode === "expressway" ? 0.46 : mode === "rail" ? 0.42 : 0.36)))); + const bounds = options.bounds || { + minX: Math.max(0, Math.min(start.x, target.x) - searchPad), + maxX: Math.min(MAP_W - 1, Math.max(start.x, target.x) + searchPad), + minY: Math.max(0, Math.min(start.y, target.y) - searchPad), + maxY: Math.min(MAP_H - 1, Math.max(start.y, target.y) + searchPad), + }; const path = traceCorridorByCost( start, (x, y) => Math.hypot(x - target.x, y - target.y) <= snap, costField, penaltyField || null, { - curvePenalty: options.curvePenalty ?? (mode === "expressway" ? 0.10 : mode === "rail" ? 0.13 : 0.055), - penaltyStrength: options.penaltyStrength ?? (mode === "expressway" ? 1.4 : mode === "rail" ? 1.35 : 0.86), - minGoalDistance: Math.min(8, Math.max(3, d * 0.10)), + curvePenalty: options.curvePenalty ?? (mode === "expressway" ? 0.12 : mode === "rail" ? 0.15 : mode === "national" ? 0.070 : 0.040), + penaltyStrength: options.penaltyStrength ?? (mode === "expressway" ? 1.9 : mode === "rail" ? 1.35 : mode === "national" ? 1.05 : 0.78), + minGoalDistance: Math.min(8, Math.max(2, d * 0.08)), keepRegion: false, - maxExpanded: Math.min(SIZE, Math.max(2200, Math.floor(d * d * (mode === "expressway" ? 4.6 : 5.8)))), - terrainFlowBias: options.terrainFlowBias ?? (mode === "expressway" ? 0.12 : mode === "rail" ? 0.12 : 0.24), - surfaceGrain: options.surfaceGrain ?? (mode === "national" ? 0.035 : 0.012), + maxExpanded: Math.min(SIZE, Math.max(1800, Math.floor(d * d * (mode === "expressway" ? 3.8 : mode === "rail" ? 4.6 : 5.2)))), + terrainFlowBias: options.terrainFlowBias ?? (mode === "expressway" ? 0.10 : mode === "rail" ? 0.12 : mode === "national" ? 0.24 : 0.30), + surfaceGrain: options.surfaceGrain ?? (mode === "national" ? 0.030 : mode === "local" ? 0.042 : 0.010), + bounds, + goalHint: options.goalHint || target, + heuristicWeight: options.heuristicWeight ?? (mode === "expressway" ? 0.66 : mode === "rail" ? 0.54 : mode === "national" ? 0.46 : 0.30), } ); if (path.length < 4) return []; + if (options.maxPathLength && pathLengthCells(path) > options.maxPathLength) return []; const relaxed = relaxRouteToTerrain(path, costField, { radius: options.relaxRadius ?? (mode === "national" ? 2 : 1), - lineWeight: options.relaxLineWeight ?? (mode === "national" ? 0.34 : 0.52), + lineWeight: options.relaxLineWeight ?? (mode === "national" ? 0.30 : mode === "expressway" ? 0.24 : 0.48), grain: options.surfaceGrain ?? 0.020, iterations: 1, }); - return relaxed.length >= Math.max(3, path.length * 0.55) ? relaxed : path; + const crossesSea = (candidate) => (candidate || []).some(([x, y]) => !inside(x, y) || sea[indexOf(x, y)] || costField[indexOf(x, y)] >= INF); + const chosen = relaxed.length >= Math.max(3, path.length * 0.55) ? relaxed : path; + if (crossesSea(chosen)) return crossesSea(path) ? [] : path; + if (!routePhysicalAcceptable(chosen, mode, options)) return routePhysicalAcceptable(path, mode, options) ? path : []; + return chosen; } - function addCandidateKnnNetwork(paths, mode, costField, potentialField, options = {}) { - const debug = { mode, candidates: 0, added: [], skipped: 0 }; - const candidates = transportCandidatePoints(mode, potentialField, options); - debug.candidates = candidates.length; - if (candidates.length < 2) return debug; - const penalty = cachedInfluenceFromPaths(paths, options.parallelRadius ?? (mode === "expressway" ? 10 : mode === "rail" ? 7 : 6), `${mode}:knn`); - const degree = new Map(); - const edgeUsed = new Set(); - const ordered = candidates.slice().sort((a, b) => b.score - a.score); - const maxAdded = options.maxAdded ?? (mode === "expressway" ? 4 : mode === "rail" ? 8 : 18); - const k = options.k ?? (mode === "expressway" ? 3 : mode === "rail" ? 4 : 5); - const maxDegree = options.maxDegree ?? (mode === "expressway" ? 2 : 3); - for (const a of ordered) { - if (debug.added.length >= maxAdded) break; - const aid = `${a.x},${a.y}`; - if ((degree.get(aid) || 0) >= maxDegree) continue; - const near = candidates - .filter((b) => b !== a && (!options.sameRegionOnly || b.regionId === a.regionId)) - .map((b) => { - const d = Math.hypot(a.x - b.x, a.y - b.y); - const bearingJitter = hash2(a.x + b.x, a.y + b.y, seed + 17333) * 0.04; - const desire = Math.sqrt(Math.max(0.2, a.score) * Math.max(0.2, b.score)); - return { b, d, sortScore: d / desire + bearingJitter }; - }) - .filter((e) => e.d >= (options.minDistance ?? (mode === "expressway" ? 26 : mode === "rail" ? 18 : 12)) && e.d <= (options.maxDistance ?? (mode === "expressway" ? 112 : mode === "rail" ? 92 : 78))) - .sort((x, y) => x.sortScore - y.sortScore) - .slice(0, k); - for (const { b, d } of near) { - if (debug.added.length >= maxAdded) break; - const bid = `${b.x},${b.y}`; - if ((degree.get(bid) || 0) >= maxDegree) continue; - const key = aid < bid ? `${aid}|${bid}` : `${bid}|${aid}`; - if (edgeUsed.has(key)) continue; - edgeUsed.add(key); - const path = routeBetweenTrafficCandidates(a, b, mode, costField, penalty, options); - const len = pathLengthCells(path); - if (len < (options.minPathLength ?? Math.max(8, d * 0.45)) || len > (options.maxPathLength ?? d * 2.75 + 34)) { debug.skipped++; continue; } - const avgPenalty = pathAverageField(path, penalty); - const avgPotential = pathAverageField(path, potentialField); - if (avgPenalty > (options.maxParallelInfluence ?? (mode === "expressway" ? 0.39 : mode === "rail" ? 0.42 : 0.47)) && avgPotential < (options.minPotentialIfParallel ?? 0.38)) { debug.skipped++; continue; } - if (!transportRouteAcceptable(path, mode, potentialField, penalty, { minLength: options.minPathLength ?? 6, maxLength: options.maxPathLength ?? d * 2.75 + 34, maxAvgPenalty: options.maxParallelInfluence ?? Infinity })) { debug.skipped++; continue; } - paths.push(path); - debug.added.push({ mode: `${mode}-knn`, path, from: a.role || "candidate", to: b.role || "candidate" }); - degree.set(aid, (degree.get(aid) || 0) + 1); - degree.set(bid, (degree.get(bid) || 0) + 1); - addCorridorInfluencePenalty(penalty, path, options.parallelRadius ?? 6, options.addedPenalty ?? 0.30); - if ((degree.get(aid) || 0) >= maxDegree) break; - } - } - return debug; - } - - function pruneParallelSameMode(paths, mode, potentialField, options = {}) { +function pruneParallelSameMode(paths, mode, potentialField, options = {}) { if (!paths?.length) return { mode, pruned: 0, kept: 0 }; const scored = paths.map((path, originalIndex) => { const len = pathLengthCells(path); @@ -1790,30 +2117,7 @@ export function generateMapFeatures(seed, terrain) { return debug; } - function rebalanceTransportNetworks() { - const debug = { candidateGraphs: [], parallelPruning: [], endpointRepairs: [], prunedDanglingSegments: [] }; - debug.candidateGraphs.push(addCandidateKnnNetwork(nationalRoads, "national", transportFields.national, transportFields.nationalPotential, { - maxCells: 30, maxSettlements: 46, maxTotal: 60, k: 4, maxDegree: 3, maxAdded: 13, minDistance: 12, maxDistance: 82, sameRegionOnly: false, parallelRadius: 6, maxParallelInfluence: 0.48, curvePenalty: 0.050, terrainFlowBias: 0.24, surfaceGrain: 0.038, relaxRadius: 2, relaxLineWeight: 0.33, - })); - debug.candidateGraphs.push(addCandidateKnnNetwork(railways, "rail", transportFields.rail, transportFields.railPotential, { - maxCells: 16, maxSettlements: 24, maxTotal: 34, k: 3, maxDegree: 2, maxAdded: 4, minDistance: 22, maxDistance: 86, sameRegionOnly: false, parallelRadius: 7, maxParallelInfluence: 0.40, curvePenalty: 0.16, terrainFlowBias: 0.12, surfaceGrain: 0.010, relaxRadius: 1, relaxLineWeight: 0.52, - })); - debug.candidateGraphs.push(addCandidateKnnNetwork(expressways, "expressway", transportFields.expressway, transportFields.expresswayPotential, { - maxCells: 10, maxSettlements: 12, maxTotal: 20, k: 2, maxDegree: 2, maxAdded: 2, minDistance: 36, maxDistance: 112, sameRegionOnly: false, parallelRadius: 10, maxParallelInfluence: 0.34, curvePenalty: 0.10, terrainFlowBias: 0.11, surfaceGrain: 0.008, relaxRadius: 1, relaxLineWeight: 0.58, - })); - debug.endpointRepairs.push(repairDanglingTransportEndpoints(nationalRoads, "national", transportFields.national, [...externalRoads, ...railways], transportFields.nationalPotential, { maxAdded: 12, maxTargetDistance: 44, curvePenalty: 0.055, terrainFlowBias: 0.24, surfaceGrain: 0.038, relaxRadius: 2, relaxLineWeight: 0.33 })); - debug.endpointRepairs.push(repairDanglingTransportEndpoints(railways, "rail", transportFields.rail, [...nationalRoads, ...externalRailways], transportFields.railPotential, { maxAdded: 3, maxTargetDistance: 44, curvePenalty: 0.16, terrainFlowBias: 0.12, surfaceGrain: 0.010 })); - debug.endpointRepairs.push(repairDanglingTransportEndpoints(expressways, "expressway", transportFields.expressway, [...nationalRoads, ...externalExpressways], transportFields.expresswayPotential, { maxAdded: 1, maxTargetDistance: 60, curvePenalty: 0.10, terrainFlowBias: 0.11, surfaceGrain: 0.008 })); - debug.prunedDanglingSegments.push(pruneDanglingTerminalSegments(nationalRoads, "national", [...externalRoads, ...railways, ...minorRoads], { oneInvalidMax: 20, bothInvalidMax: 36, minKeep: 10 })); - debug.prunedDanglingSegments.push(pruneDanglingTerminalSegments(railways, "rail", [...nationalRoads, ...externalRailways], { oneInvalidMax: 26, bothInvalidMax: 42, minKeep: 3 })); - debug.prunedDanglingSegments.push(pruneDanglingTerminalSegments(expressways, "expressway", [...nationalRoads, ...externalExpressways], { oneInvalidMax: 38, bothInvalidMax: 58, minKeep: 1 })); - debug.parallelPruning.push(pruneParallelSameMode(nationalRoads, "national", transportFields.nationalPotential, { threshold: 0.64, radius: 2, minKeep: 8, shortLength: 20 })); - debug.parallelPruning.push(pruneParallelSameMode(railways, "rail", transportFields.railPotential, { threshold: 0.58, radius: 2, minKeep: 3, shortLength: 28 })); - debug.parallelPruning.push(pruneParallelSameMode(expressways, "expressway", transportFields.expresswayPotential, { threshold: 0.50, radius: 3, minKeep: 1, shortLength: 42 })); - return debug; - } - - const premodernRoads = []; +const premodernRoads = []; const nationalRoads = []; const minorRoads = []; const railways = []; @@ -1838,86 +2142,13 @@ export function generateMapFeatures(seed, terrain) { } } - nationalRoads.push(...generateCorridorsFromField({ - mode: "national", - potentialField: transportFields.nationalPotential, - costField: transportFields.national, - spacing: 17, - maxCount: 42, - threshold: 0.30, - minLength: 20, - penaltyRadius: 6, - penaltyStrength: 0.34, - curvePenalty: 0.045, - terrainFlowBias: 0.22, - surfaceGrain: 0.040, - relaxRadius: 2, - relaxLineWeight: 0.32, - seedOffset: 13400, - startPredicate: (x, y, i) => transportFields.nationalPotential[i] > 0.27 && regionIdAt(x, y) >= 0, - goalPredicate: (start, x, y, i, used) => { - if (regionIdAt(x, y) !== start.regionId) return false; - if (transportFields.nationalPotential[i] < 0.34) return false; - if (!naturalEndpoint(x, y, i, "national")) return false; - if (distanceToNearest(used, x, y) < 11) return false; - const d = Math.hypot(x - start.x, y - start.y); - return d > 22 && d < 76; - }, - })); + // National roads, expressways, and rail are generated from unified OD demand. + // Roads are built by the density-flow portal system below; rail waits until + // external gateways exist so the node model can include outside-region demand. - railways.push(...generateCorridorsFromField({ - mode: "rail", - potentialField: transportFields.railPotential, - costField: transportFields.rail, - spacing: 24, - maxCount: 18, - threshold: 0.30, - minLength: 24, - penaltyRadius: 7, - penaltyStrength: 0.42, - curvePenalty: 0.16, - terrainFlowBias: 0.15, - surfaceGrain: 0.012, - relaxRadius: 1, - relaxLineWeight: 0.52, - seedOffset: 13500, - startPredicate: (x, y, i) => transportFields.railPotential[i] > 0.26 && settlementDemand[i] > 0.16 && regionIdAt(x, y) >= 0, - goalPredicate: (start, x, y, i, used) => { - if (regionIdAt(x, y) !== start.regionId) return false; - if (transportFields.railPotential[i] < 0.30 || settlementDemand[i] < 0.18) return false; - if (!naturalEndpoint(x, y, i, "rail")) return false; - if (distanceToNearest(used, x, y) < 15) return false; - const d = Math.hypot(x - start.x, y - start.y); - return d > 28 && d < 88; - }, - })); + let railODDebug = null; - expressways.push(...generateCorridorsFromField({ - mode: "expressway", - potentialField: transportFields.expresswayPotential, - costField: transportFields.expressway, - spacing: 36, - maxCount: 5, - threshold: 0.35, - minLength: 34, - penaltyRadius: 10, - penaltyStrength: 0.72, - curvePenalty: 0.08, - terrainFlowBias: 0.12, - surfaceGrain: 0.010, - relaxRadius: 1, - relaxLineWeight: 0.58, - seedOffset: 13600, - startPredicate: (x, y, i) => transportFields.expresswayPotential[i] > 0.33 && settlementDemand[i] > 0.12 && regionIdAt(x, y) >= 0, - goalPredicate: (start, x, y, i, used) => { - if (regionIdAt(x, y) !== start.regionId) return false; - if (transportFields.expresswayPotential[i] < 0.35 || settlementDemand[i] < 0.10) return false; - if (!naturalEndpoint(x, y, i, "expressway")) return false; - if (distanceToNearest(used, x, y) < 22) return false; - const d = Math.hypot(x - start.x, y - start.y); - return d > 42 && d < 112; - }, - })); + // Expressways are generated later by the density-flow portal system. // External gateways at land edges; used by naming/UI and later transport work. for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) { @@ -1951,193 +2182,33 @@ export function generateMapFeatures(seed, terrain) { } } - const transportDebugLayers = { - packedHeatmaps: true, - expresswayPotential: packDebugField(transportFields.expresswayPotential), - railPotential: packDebugField(transportFields.railPotential), - nationalRoadPotential: packDebugField(transportFields.nationalPotential), - slopeSeaPenalty: (() => { - const src = new Float32Array(SIZE); - for (let i = 0; i < SIZE; i++) src[i] = sea[i] ? 1 : clamp(slope[i] * 1.55 + Math.max(0, elevation[i] - 0.58) * 2.2 + ridgeField[i] * 0.42); - return packDebugField(src); - })(), - components: [], - repairedSegments: [], - unservedSettlements: [], - }; - for (const repair of [ - repairTransportConnectivity(expressways, "expressway", transportFields.expressway, transportFields.expresswayPotential, { - minImportance: 10.0, - minComponentCells: 22, - maxComponents: 5, - maxRepairs: 2, - maxRepairDistance: 116, - penaltyRadius: 10, - penaltyStrength: 2.35, - curvePenalty: 0.18, - highPotentialThreshold: 0.40, - maxAddedLength: 112, - }), - repairTransportConnectivity(railways, "rail", transportFields.rail, transportFields.railPotential, { - minImportance: 10.5, - minComponentCells: 18, - maxComponents: 6, - maxRepairs: 3, - maxRepairDistance: 105, - penaltyRadius: 7, - penaltyStrength: 2.0, - curvePenalty: 0.16, - terrainFlowBias: 0.13, - surfaceGrain: 0.008, - relaxRadius: 1, - relaxLineWeight: 0.50, - highPotentialThreshold: 0.36, - }), - repairTransportConnectivity(nationalRoads, "national", transportFields.national, transportFields.nationalPotential, { - minImportance: 6.4, - minComponentCells: 10, - maxComponents: 16, - maxRepairs: 10, - maxRepairDistance: 138, - penaltyRadius: 6, - penaltyStrength: 1.18, - curvePenalty: 0.10, - highPotentialThreshold: 0.28, - maxAddedLength: 138, - }), - ]) { - transportDebugLayers.components.push(...repair.components); - transportDebugLayers.repairedSegments.push(...repair.repairs); - } - - const graphNetworkDebug = rebalanceTransportNetworks(); - transportDebugLayers.graphCandidateNetworks = graphNetworkDebug.candidateGraphs.map((item) => ({ - mode: item.mode, - candidates: item.candidates, - skipped: item.skipped, - addedCount: item.added?.length || 0, - })); - transportDebugLayers.parallelPruning = graphNetworkDebug.parallelPruning; - transportDebugLayers.endpointRepairs = graphNetworkDebug.endpointRepairs.map((item) => ({ - mode: item.mode, - checked: item.checked, - addedCount: item.added?.length || 0, - })); - transportDebugLayers.prunedDanglingSegments = graphNetworkDebug.prunedDanglingSegments; - for (const item of graphNetworkDebug.candidateGraphs || []) { - transportDebugLayers.repairedSegments.push(...(item.added || [])); - } - for (const item of graphNetworkDebug.endpointRepairs || []) { - transportDebugLayers.repairedSegments.push(...(item.added || [])); - } - - function generateLocalRoadsForUnservedSettlements() { - const trunkInfluence = cachedInfluenceFromPaths([...nationalRoads, ...railways, ...externalRoads], 8, "local:trunk"); - const candidates = [...villages, ...markets, ...ports] - .filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)]) - .map((p) => { - const i = indexOf(p.x, p.y); - const settlementWeight = p.portClass ? 1.2 : p.population ? clamp(p.population / 18000, 0.35, 1.4) : 0.45; - return { ...p, score: settlementWeight + transportFields.localPotential[i] * 0.65 - trunkInfluence[i] * 1.15 }; - }) - .filter((p) => p.score > 0.06 && trunkInfluence[indexOf(p.x, p.y)] < 0.34) - .sort((a, b) => b.score - a.score) - .slice(0, 150); - const localPenalty = new Float32Array(SIZE); - const paths = []; - const served = []; - for (const start of candidates) { - if (paths.length >= 115) break; - if (distanceToNearest(served, start.x, start.y) < 4.5) continue; - let path = traceCorridorByCost( - start, - (x, y, i) => trunkInfluence[i] > 0.18 || (paths.length > 6 && localPenalty[i] > 0.045), - transportFields.local, - localPenalty, - { curvePenalty: 0.08, penaltyStrength: 1.00, minGoalDistance: 5, regionId: start.regionId, maxExpanded: SIZE } - ); - if (path.length < 4 || path.length > 86) continue; - if (!transportRouteAcceptable(path, "local", transportFields.localPotential, localPenalty, { minLength: 4, maxLength: 86 })) continue; - paths.push(path); - served.push(start); - addCorridorInfluencePenalty(localPenalty, path, 4, 0.22); - } - return paths; - } - - function runLocalAccessPass({ - candidates, - accessInfluence, - localPenalty, - maxAdded = 80, - minSpacing = 3.5, - maxLength = 82, - debugMode = "local-access", - from = "unserved", - to = "network", - targetPredicate = null, - }) { - const served = []; - let added = 0; - for (const start of candidates) { - if (added >= maxAdded) break; - if (distanceToNearest(served, start.x, start.y) < minSpacing) continue; - let path = traceCorridorByCost( - start, - targetPredicate || ((x, y, i) => accessInfluence[i] > 0.16 || localPenalty[i] > 0.075), - transportFields.local, - localPenalty, - { curvePenalty: 0.020, penaltyStrength: 0.90, minGoalDistance: 3, regionId: start.regionId, maxExpanded: Math.floor(SIZE * 0.72), terrainFlowBias: 0.26, surfaceGrain: 0.042 } - ); - path = relaxRouteToTerrain(path, transportFields.local, { radius: 2, lineWeight: 0.25, grain: 0.048, iterations: 1 }); - const ok = path.length >= 4 && path.length <= maxLength && transportRouteAcceptable(path, "local", transportFields.localPotential, localPenalty, { minLength: 4, maxLength }); - transportDebugLayers.unservedSettlements.push({ x: start.x, y: start.y, kind: start.kind || "Unserved Settlement", mode: debugMode, repaired: ok }); - if (!ok) continue; - minorRoads.push(path); - served.push(start); - added++; - transportDebugLayers.repairedSegments.push({ mode: "local", path, from, to }); - addCorridorInfluencePenalty(localPenalty, path, 4, 0.18); - if (accessInfluence) addCorridorInfluencePenalty(accessInfluence, path, 5, 0.22); - } - return added; - } - - minorRoads.push(...generateLocalRoadsForUnservedSettlements()); - const localEndpointRepair = repairDanglingTransportEndpoints(minorRoads, "local", transportFields.local, [...nationalRoads, ...externalRoads, ...railways], transportFields.localPotential, { - maxAdded: 36, - maxTargetDistance: 34, - curvePenalty: 0.055, - terrainFlowBias: 0.26, - surfaceGrain: 0.048, - relaxRadius: 2, - relaxLineWeight: 0.30, - targetRadius: 5, + const railOD = buildUnifiedRailODNetwork({ + seed, + sea, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, naturalBarrierScore, passSuitability, + transportFields, settlementDemand, preliminaryUrbanInfluence, preliminaryTownInfluence, preliminaryVillageInfluence, + modernCities, markets, ports, commercialPorts, externalGateways, geographicUrbanAnchors, + regionIdAt, routeBetweenTrafficCandidates, addCorridorInfluencePenalty, transportRouteAcceptable, pruneParallelSameMode, cachedInfluenceFromPaths, }); - transportDebugLayers.endpointRepairs.push(localEndpointRepair); - transportDebugLayers.repairedSegments.push(...localEndpointRepair.added); - const localDanglingPrune = pruneDanglingTerminalSegments(minorRoads, "local", [...nationalRoads, ...externalRoads, ...railways], { - oneInvalidMax: 11, - bothInvalidMax: 18, - minKeep: 24, + railways.push(...railOD.railways); + branchRailways.push(...railOD.branchRailways); + railODDebug = railOD.debug; + + + const { transportDebugLayers, runLocalAccessPass, stitchRasterNearContacts, stitchLongLocalBranches, sanitizeLocalRoads, downgradeShortNationalRoads, connectAllRoadNetworksFinal } = buildDensityFlowRoadTransportSystem({ + seed, + sea, elevation, slope, ridgeField, valleyField, coastalLowland, naturalBarrierScore, + agriculture, basinField, plain, passSuitability, crossingSuitability, + settlementDemand, preliminaryVillageInfluence, preliminaryTownInfluence, + logisticsPreSuitability, urbanEdge, + transportFields, cachedInfluenceFromPaths, + nationalRoads, minorRoads, railways, externalRoads, externalRailways, + expressways, externalExpressways, icAccessRoads, interchanges, externalGateways, + modernCities, markets, villages, ports, commercialPorts, passes, regionStats, + regionIdAt, inFocusedPrefecture, importantNodesForRegion, dedupePointCandidates, + routeBetweenTrafficCandidates, traceCorridorByCost, addCorridorInfluencePenalty, + relaxRouteToTerrain, transportRouteAcceptable, repairTransportConnectivity, + repairDanglingTransportEndpoints, pruneDanglingTerminalSegments, pruneParallelSameMode, }); - transportDebugLayers.prunedDanglingSegments.push(localDanglingPrune); - const localParallelPruning = pruneParallelSameMode(minorRoads, "local", transportFields.localPotential, { - threshold: 0.70, - radius: 1, - minKeep: 28, - shortLength: 13, - }); - transportDebugLayers.parallelPruning.push(localParallelPruning); - for (const path of expressways) { - for (const p of samplePath(path, 24)) { - const i = indexOf(p.x, p.y); - if (sea[i] || transportFields.expresswayPotential[i] < 0.24) continue; - if (interchanges.every((q) => Math.hypot(q.x - p.x, q.y - p.y) > 14)) { - interchanges.push({ x: p.x, y: p.y, kind: "Interchange", score: transportFields.expresswayPotential[i], regionId: regionIdAt(p.x, p.y) }); - } - } - } // Land-use road influence intentionally excludes expressways. Expressways // are through-corridors here, not automatic suburbanization generators. @@ -2152,14 +2223,41 @@ export function generateMapFeatures(seed, terrain) { const usedStationKeys = new Set(); function addStation(x, y, kind = "Station", score = 1) { x = Math.round(x); y = Math.round(y); - if (!inside(x, y) || sea[indexOf(x, y)]) return; + if (!inside(x, y) || sea[indexOf(x, y)]) return false; const key = `${x},${y}`; - if (usedStationKeys.has(key)) return; + if (usedStationKeys.has(key)) return false; usedStationKeys.add(key); stations.push({ x, y, kind, score, regionId: regionIdAt(x, y) }); + return true; + } + function stationIntervalForCell(i) { + const urbanDensity = preliminaryUrbanInfluence[i] || 0; + const ruralDensity = Math.max(preliminaryTownInfluence[i] || 0, preliminaryVillageInfluence[i] || 0); + return urbanDensity > 0.50 ? 6 : ruralDensity > 0.24 ? 12 : 20; + } + function shouldPlaceRailStation(x, y, i) { + const nearCity = (preliminaryUrbanInfluence[i] || 0) > 0.12 || distanceToNearest(modernCities, x, y) < 4.8; + const nearTown = (preliminaryTownInfluence[i] || 0) > 0.13 || distanceToNearest(markets, x, y) < 4.2; + const nearVillage = (preliminaryVillageInfluence[i] || 0) > 0.20 || distanceToNearest(villages, x, y) < 3.4; + const lowland = plain[i] > 0.16 || coastalLowland[i] > 0.16 || basinField[i] > 0.20 || valleyField[i] > 0.22; + const terrainOk = slope[i] < 0.42 && ridgeField[i] < 0.60 && elevation[i] < 0.76; + return terrainOk && lowland && (nearCity || nearTown || nearVillage); } for (const city of modernCities) addStation(city.x, city.y, city.isPrefecturalCapital || city.isRegionalCapital ? "Major Station" : "Station", 1.5); - for (const path of railways) for (const p of samplePath(path, 14)) addStation(p.x, p.y, "Station", 0.8); + for (const path of [...railways, ...branchRailways]) { + let lastStation = null; + for (const p of samplePath(path, 5)) { + const x = Math.round(p.x); + const y = Math.round(p.y); + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i] || !shouldPlaceRailStation(x, y, i)) continue; + const interval = stationIntervalForCell(i); + if (lastStation && Math.hypot(x - lastStation.x, y - lastStation.y) < interval) continue; + if (distanceToNearest(stations, x, y) < Math.max(4.5, interval * 0.38)) continue; + if (addStation(x, y, "Station", 0.8)) lastStation = { x, y }; + } + } const stationInfluence = influenceFromPoints(stations, 7, (s) => s.kind === "Major Station" ? 1.35 : 0.85); const stationDensityInfluence = influenceFromPoints(stations, 10, (s) => s.kind === "Major Station" ? 1.85 : 1.05); @@ -2324,6 +2422,314 @@ export function generateMapFeatures(seed, terrain) { } addFinalLocalAccessForUnservedSettlements(); addRuralRoadMeshConnectors(); + transportDebugLayers.contactStitches = stitchRasterNearContacts(); + transportDebugLayers.longLocalStitches = stitchLongLocalBranches(); + transportDebugLayers.shortNationalDowngradeFinal = downgradeShortNationalRoads(18, 10); + transportDebugLayers.localSanitizationFinal = sanitizeLocalRoads(); + transportDebugLayers.contactStitchesAfterConnectivity = stitchRasterNearContacts(); + // Keep this as the final road topology operation. Later sanitization can cut + // the short connectors that intentionally merge isolated components. + transportDebugLayers.finalRoadNetworkConnectivity = connectAllRoadNetworksFinal(96); + + function dedupeTransportPathSet(paths, options = {}) { + const before = paths.length; + const minLength = options.minLength ?? 0; + const minPoints = options.minPoints ?? 2; + const sampleStep = Math.max(1, options.sampleStep ?? 1); + const seen = new Set(); + const kept = []; + let removedDuplicates = 0; + let removedTooShort = 0; + for (const path of paths) { + if (!path || path.length < minPoints) { removedTooShort++; continue; } + const cleaned = []; + for (const pt of path) { + if (!pt || pt.length < 2) continue; + const x = Math.round(pt[0]); + const y = Math.round(pt[1]); + if (!cleaned.length || cleaned[cleaned.length - 1][0] !== x || cleaned[cleaned.length - 1][1] !== y) cleaned.push([x, y]); + } + if (cleaned.length < minPoints || pathLengthCells(cleaned) < minLength) { removedTooShort++; continue; } + const sample = (candidate) => candidate + .map((pt, idx) => (idx % sampleStep === 0 || idx === candidate.length - 1) ? `${pt[0]},${pt[1]}` : '') + .filter(Boolean) + .join('|'); + const forward = sample(cleaned); + const backward = sample([...cleaned].reverse()); + const sig = forward < backward ? forward : backward; + if (seen.has(sig)) { removedDuplicates++; continue; } + seen.add(sig); + kept.push(cleaned); + } + paths.length = 0; + paths.push(...kept); + return { before, after: kept.length, removedDuplicates, removedTooShort }; + } + + function pruneTransportPathSet(paths, minLength = 0, minKeep = 0) { + const ranked = (paths || []) + .map((path) => ({ path, len: pathLengthCells(path) })) + .filter((row) => row.path?.length >= 2) + .sort((a, b) => b.len - a.len); + const kept = []; + let pruned = 0; + for (const row of ranked) { + if (row.len >= minLength || kept.length < minKeep) kept.push(row.path); + else pruned++; + } + paths.length = 0; + paths.push(...kept); + return { before: ranked.length, after: kept.length, pruned, minLength, minKeep }; + } + + function downgradeBranchNationalSpurs(maxLength = 30, importantRadius = 6.5, junctionRadius = 2.6) { + const importantNodes = [ + ...modernCities.filter((p) => p.isPrefecturalCapital || p.isRegionalCapital || (p.population || 0) >= 90000), + ...ports.filter((p) => p.portClass === 'major' || p.portClass === 'regional'), + ...externalGateways, + ]; + const otherTrunks = [...externalRoads, ...expressways, ...externalExpressways]; + const kept = []; + const downgraded = []; + function endpointNearImportant(endpoint) { + return importantNodes.some((node) => Math.hypot(node.x - endpoint[0], node.y - endpoint[1]) <= importantRadius); + } + function endpointTouchesOtherTrunk(endpoint, currentPath) { + const [ex, ey] = endpoint; + for (const path of [...nationalRoads, ...otherTrunks]) { + if (path === currentPath) continue; + for (const pt of path) { + if (Math.hypot(pt[0] - ex, pt[1] - ey) <= junctionRadius) return true; + } + } + return false; + } + for (const path of nationalRoads) { + if (!path || path.length < 2) continue; + const len = pathLengthCells(path); + const a = path[0]; + const b = path[path.length - 1]; + const aImportant = endpointNearImportant(a); + const bImportant = endpointNearImportant(b); + const aTouch = endpointTouchesOtherTrunk(a, path); + const bTouch = endpointTouchesOtherTrunk(b, path); + const oneSidedBranch = (aTouch && !bTouch) || (!aTouch && bTouch); + const importantEndpoints = Number(aImportant) + Number(bImportant); + if (oneSidedBranch && len <= maxLength && importantEndpoints <= 1 && !(aImportant && bImportant)) downgraded.push(path); + else kept.push(path); + } + nationalRoads.length = 0; + nationalRoads.push(...kept); + minorRoads.push(...downgraded); + return { threshold: maxLength, downgraded: downgraded.length, kept: kept.length }; + } + + transportDebugLayers.postConnectivityDedup = { + national: dedupeTransportPathSet(nationalRoads, { minLength: 0.95, sampleStep: 1 }), + externalRoads: dedupeTransportPathSet(externalRoads, { minLength: 1.5, sampleStep: 1 }), + minor: dedupeTransportPathSet(minorRoads, { minLength: 0.95, sampleStep: 2 }), + expressways: dedupeTransportPathSet(expressways, { minLength: 4, sampleStep: 2 }), + externalExpressways: dedupeTransportPathSet(externalExpressways, { minLength: 4, sampleStep: 2 }), + }; + transportDebugLayers.postConnectivityShortNationalDowngrade = downgradeShortNationalRoads(32, 7); + transportDebugLayers.postConnectivityBranchNationalDowngrade = downgradeBranchNationalSpurs(42); + transportDebugLayers.postConnectivityNationalPrune = pruneTransportPathSet(nationalRoads, 18, 7); + transportDebugLayers.postConnectivityMinorDedup = dedupeTransportPathSet(minorRoads, { minLength: 0.95, sampleStep: 2 }); + transportDebugLayers.postConnectivityLocalSanitization = sanitizeLocalRoads(); + + function smoothRasterPath(path, passes = 1) { + let current = (path || []).map(([x, y]) => [Math.round(x), Math.round(y)]); + for (let pass = 0; pass < passes; pass++) { + if (current.length < 3) break; + const next = [current[0]]; + for (let i = 1; i < current.length - 1; i++) { + const [ax, ay] = current[i - 1]; + const [bx, by] = current[i]; + const [cx, cy] = current[i + 1]; + const nx = Math.round((ax + bx * 2 + cx) / 4); + const ny = Math.round((ay + by * 2 + cy) / 4); + if (next[next.length - 1][0] !== nx || next[next.length - 1][1] !== ny) next.push([nx, ny]); + } + next.push(current[current.length - 1]); + current = next; + } + return current; + } + + function directBridgeTunnelConnector(a, b, maxSegment = 20) { + if (!a || !b) return []; + const d = Math.hypot(a.x - b.x, a.y - b.y); + const steps = Math.max(2, Math.ceil(d)); + const path = []; + for (let s = 0; s <= steps; s++) { + const t = s / steps; + const x = Math.round(a.x + (b.x - a.x) * t); + const y = Math.round(a.y + (b.y - a.y) * t); + if (!path.length || path[path.length - 1][0] !== x || path[path.length - 1][1] !== y) path.push([x, y]); + } + return routePhysicalAcceptable(path, 'expressway', { maxSeaRun: maxSegment, maxTunnelRun: Math.min(10, maxSegment), maxSeaShare: 0.70, maxTunnelShare: 0.18 }) ? path : []; + } + + function ensureInterchangePoint(x, y, kind = 'Interchange', source = 'expressway-endpoint') { + x = Math.round(x); y = Math.round(y); + if (!inside(x, y) || sea[indexOf(x, y)]) return false; + if ((interchanges || []).some((p) => Math.hypot(p.x - x, p.y - y) <= 2.5)) return false; + interchanges.push({ x, y, kind, score: 1, source }); + return true; + } + + function ensureExpresswayEndpointsHaveICs() { + let added = 0; + for (const path of [...expressways, ...externalExpressways]) { + if (!path || path.length < 2) continue; + const endpoints = [path[0], path[path.length - 1]]; + for (const [x, y] of endpoints) if (ensureInterchangePoint(x, y)) added++; + } + return { added, total: interchanges.length }; + } + + function ensureNationalRoadCoverageForTowns(minPopulation = 5000) { + const nationalInfluence = cachedInfluenceFromPaths([...nationalRoads, ...externalRoads], 3.2, 'final-national:coverage'); + const towns = dedupePointCandidates([ + ...modernCities.filter((p) => (p.population || 0) >= minPopulation), + ...markets.filter((p) => (p.population || 0) >= minPopulation), + ...ports.filter((p) => (p.population || 0) >= minPopulation || p.portClass === 'regional' || p.portClass === 'major'), + ], 3.5); + const debug = { minPopulation, checked: towns.length, added: 0 }; + const nationalPenalty = cachedInfluenceFromPaths([...nationalRoads, ...externalRoads], 6, 'final-national:penalty'); + for (const town of towns) { + const ti = indexOf(town.x, town.y); + if ((nationalInfluence[ti] || 0) > 0.24) continue; + const candidates = dedupePointCandidates([...modernCities, ...externalGateways, ...ports, ...markets], 6) + .filter((q) => q !== town) + .map((q) => ({ q, d: Math.hypot(q.x - town.x, q.y - town.y) })) + .filter((row) => row.d >= 10 && row.d <= 90) + .sort((a, b) => a.d - b.d); + let addedPath = null; + for (const cand of candidates.slice(0, 8)) { + const path = routeBetweenTrafficCandidates(town, cand.q, 'national', transportFields.national, nationalPenalty, { + curvePenalty: 0.055, + penaltyStrength: 0.60, + terrainFlowBias: 0.18, + surfaceGrain: 0.018, + relaxRadius: 2, + relaxLineWeight: 0.35, + maxPathLength: cand.d * 2.8 + 40, + maxSeaRun: 10, + maxTunnelRun: 10, + }); + if (path.length >= 4 && transportRouteAcceptable(path, 'national', transportFields.national, nationalPenalty, { minLength: 4, maxLength: cand.d * 3.0 + 48, maxSeaRun: 10, maxTunnelRun: 10 })) { + addedPath = path; + break; + } + } + if (addedPath) { + nationalRoads.push(addedPath); + debug.added++; + } + } + return debug; + } + + function ensureMajorCityExpresswayConnections(minPopulation = 100000) { + const majorCities = modernCities + .filter((c) => (c.population || 0) >= minPopulation) + .sort((a, b) => (b.population || 0) - (a.population || 0)); + const debug = { minPopulation, cityCount: majorCities.length, added: 0, pairs: [] }; + if (majorCities.length < 2) return debug; + + const permissiveExpresswayCost = new Float32Array(SIZE); + for (let i = 0; i < SIZE; i++) { + const terrainBase = Number.isFinite(transportFields.expressway[i]) && transportFields.expressway[i] < INF + ? transportFields.expressway[i] + : 0.28 + Math.max(0, slope[i] - 0.18) * 0.9 + Math.max(0, elevation[i] - 0.58) * 1.6 + ridgeField[i] * 0.45; + permissiveExpresswayCost[i] = sea[i] + ? 0.72 + (naturalBarrierScore?.[i] || 0) * 0.16 + : terrainBase + Math.max(0, elevation[i] - 0.70) * 1.7; + } + + const componentOfCities = () => { + const parent = new Map(); + const keyOf = (city) => city.name || `${city.x},${city.y}`; + function find(k) { + const p = parent.get(k); + if (p === k) return k; + const r = find(p); + parent.set(k, r); + return r; + } + function union(a, b) { + const ra = find(a); const rb = find(b); + if (ra !== rb) parent.set(ra, rb); + } + for (const city of majorCities) parent.set(keyOf(city), keyOf(city)); + const paths = [...expressways, ...externalExpressways]; + for (const path of paths) { + const near = majorCities.filter((city) => path.some(([x, y]) => Math.hypot(city.x - x, city.y - y) <= 7.5)); + if (near.length >= 2) { + const k0 = keyOf(near[0]); + for (let i = 1; i < near.length; i++) union(k0, keyOf(near[i])); + } + } + return new Map(majorCities.map((city) => [keyOf(city), find(keyOf(city))])); + }; + + const pairPriority = (a, b) => { + const d = Math.hypot(a.x - b.x, a.y - b.y); + const pop = Math.sqrt((a.population || minPopulation) * (b.population || minPopulation)); + return d / Math.max(1, Math.log2(pop)); + }; + + for (let iter = 0; iter < majorCities.length * 2; iter++) { + const comps = componentOfCities(); + const reps = new Set(comps.values()); + if (reps.size <= 1) break; + let best = null; + for (const a of majorCities) { + for (const b of majorCities) { + if (a === b) continue; + const ka = a.name || `${a.x},${a.y}`; + const kb = b.name || `${b.x},${b.y}`; + if (comps.get(ka) === comps.get(kb)) continue; + const score = pairPriority(a, b); + if (!best || score < best.score) best = { a, b, score, d: Math.hypot(a.x - b.x, a.y - b.y) }; + } + } + if (!best) break; + let path = routeBetweenTrafficCandidates(best.a, best.b, 'expressway', permissiveExpresswayCost, null, { + curvePenalty: 0.045, + penaltyStrength: 0.18, + terrainFlowBias: 0.03, + surfaceGrain: 0.001, + relaxRadius: 2, + relaxLineWeight: 0.15, + maxPathLength: best.d * 3.5 + 110, + snapRadius: 4.0, + searchPad: Math.ceil(Math.max(20, Math.min(96, best.d * 0.55 + 16))), + heuristicWeight: 0.78, + maxSeaRun: 20, + maxTunnelRun: 20, + maxSeaShare: 0.45, + maxTunnelShare: 0.45, + }); + if (!path.length) path = directBridgeTunnelConnector(best.a, best.b, 20); + if (path.length >= 2) { + path = smoothRasterPath(path, 2); + expressways.push(path); + debug.added++; + debug.pairs.push({ from: best.a.name, to: best.b.name, distance: Math.round(best.d), length: Math.round(pathLengthCells(path)) }); + } else { + break; + } + } + debug.finalExpresswayCount = expressways.length; + return debug; + } + + transportDebugLayers.postConnectivityNationalCoverage = ensureNationalRoadCoverageForTowns(5000); + transportDebugLayers.postConnectivityMajorCityExpresswayGuarantee = ensureMajorCityExpresswayConnections(100000); + transportDebugLayers.postConnectivityExpresswayDedup = dedupeTransportPathSet(expressways, { minLength: 8, sampleStep: 2 }); + transportDebugLayers.postConnectivityExpresswayEndpointICs = ensureExpresswayEndpointsHaveICs(); var landuse = new Uint8Array(SIZE); // Re-run land-use classification after landuse allocation. The loop above is @@ -2389,7 +2795,7 @@ export function generateMapFeatures(seed, terrain) { transport * 0.05 + agrarianDensity * 0.34 ); - maxDensity = Math.max(maxDensity, populationDensity[i]); + maxDensity = Math.max(maxDensity[i]); if (elevation[i] > 0.67 || (slope[i] > 0.56 && ridgeField[i] > 0.30) || ridgeField[i] > 0.70) { landuse[i] = LANDUSE.FOREST; @@ -2526,7 +2932,10 @@ export function generateMapFeatures(seed, terrain) { humanStageVersion: "v2-sparse-raster", aStarRoutes: 0, regionalNodeCount: [...regionStats.keys()].reduce((sum, regionId) => sum + importantNodesForRegion(regionId).length, 0), - fieldCorridorTransport: true, + fieldCorridorTransport: false, + unifiedODTransport: true, + settlementHierarchy: settlementHierarchyDebug, + railODTransport: railODDebug, expresswayFieldCorridors: expressways.length, railFieldCorridors: railways.length, nationalRoadFieldCorridors: nationalRoads.length, @@ -2538,6 +2947,7 @@ export function generateMapFeatures(seed, terrain) { return { ports, + geographicUrbanAnchors, crossings, passes, settlementCluster, diff --git a/mapGeography.js b/mapGeography.js new file mode 100644 index 0000000..c2328a3 --- /dev/null +++ b/mapGeography.js @@ -0,0 +1,387 @@ +import { INF, MAP_H, MAP_W, SIZE, clamp, indexOf, inside } from "./mapUtils.js"; +import { influenceFromPoints } from "./mapGeneratorHelpers.js"; + +const GEOGRAPHY_VERSION = "unified-geography-v1"; + +function localConfluenceScore(x, y, river) { + let arms = 0; + let strong = 0; + for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) { + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny)) continue; + const rv = river?.[indexOf(nx, ny)] || 0; + if (rv > 0.18) arms++; + if (rv > 0.34) strong++; + } + return clamp((arms >= 3 ? 0.22 : arms === 2 ? 0.09 : 0) + strong * 0.04); +} + +function summarizeField(field, sea = null) { + let min = INF; + let max = -INF; + let sum = 0; + let count = 0; + for (let i = 0; i < SIZE; i++) { + if (sea?.[i]) continue; + const v = field?.[i]; + if (!Number.isFinite(v)) continue; + min = Math.min(min, v); + max = Math.max(max, v); + sum += v; + count++; + } + return { + min: count ? Math.round(min * 1000) / 1000 : 0, + max: count ? Math.round(max * 1000) / 1000 : 0, + mean: count ? Math.round((sum / count) * 1000) / 1000 : 0, + }; +} + +function buildProfiles(idField, terrain, fields) { + if (!idField) return []; + const { sea } = terrain; + const rows = new Map(); + for (let i = 0; i < SIZE; i++) { + if (sea?.[i]) continue; + const id = idField[i]; + if (id < 0) continue; + let row = rows.get(id); + if (!row) { + row = { + id, + area: 0, + habitableCells: 0, + lowlandCells: 0, + barrierCells: 0, + habitabilitySum: 0, + accessibilitySum: 0, + centralitySum: 0, + barrierSum: 0, + sx: 0, + sy: 0, + }; + rows.set(id, row); + } + const x = i % MAP_W; + const y = Math.floor(i / MAP_W); + const h = fields.habitability?.[i] || 0; + const a = fields.accessibility?.[i] || 0; + const c = fields.centrality?.[i] || fields.naturalCentrality?.[i] || 0; + const b = fields.geographicBarrier?.[i] || 0; + row.area++; + row.habitabilitySum += h; + row.accessibilitySum += a; + row.centralitySum += c; + row.barrierSum += b; + row.sx += x; + row.sy += y; + if (h > 0.26) row.habitableCells++; + if ((terrain.plain?.[i] || 0) > 0.24 || (terrain.basinField?.[i] || 0) > 0.24 || (terrain.coastalLowland?.[i] || 0) > 0.22) row.lowlandCells++; + if (b > 0.52) row.barrierCells++; + } + return [...rows.values()] + .map((row) => ({ + id: row.id, + area: row.area, + cx: Math.round((row.sx / Math.max(1, row.area)) * 10) / 10, + cy: Math.round((row.sy / Math.max(1, row.area)) * 10) / 10, + habitableRatio: Math.round((row.habitableCells / Math.max(1, row.area)) * 1000) / 1000, + lowlandRatio: Math.round((row.lowlandCells / Math.max(1, row.area)) * 1000) / 1000, + barrierRatio: Math.round((row.barrierCells / Math.max(1, row.area)) * 1000) / 1000, + avgHabitability: Math.round((row.habitabilitySum / Math.max(1, row.area)) * 1000) / 1000, + avgAccessibility: Math.round((row.accessibilitySum / Math.max(1, row.area)) * 1000) / 1000, + avgCentrality: Math.round((row.centralitySum / Math.max(1, row.area)) * 1000) / 1000, + avgBarrier: Math.round((row.barrierSum / Math.max(1, row.area)) * 1000) / 1000, + })) + .sort((a, b) => b.area - a.area || a.id - b.id); +} + +export function buildGeographicBasis(seed, terrain) { + const { + elevation, + slope, + sea, + river, + floodplain, + plain, + agriculture, + ridgeField, + valleyField, + basinField, + coastalLowland, + flowAccum, + naturalBarrierScore, + portSuitability, + crossingSuitability, + passSuitability, + depositionalLowland, + alluvialFanField, + deltaField, + naturalCompartmentId, + watershedId, + } = terrain; + + const habitability = new Float32Array(SIZE); + const lowlandCapacity = new Float32Array(SIZE); + const valleyAccess = new Float32Array(SIZE); + const coastalAccess = new Float32Array(SIZE); + const geographicBarrier = new Float32Array(SIZE); + const geographicBarrierCost = new Float32Array(SIZE); + const corridorSuitability = new Float32Array(SIZE); + const accessibility = new Float32Array(SIZE); + const naturalCentrality = new Float32Array(SIZE); + const centrality = new Float32Array(SIZE); + const adminBoundaryPreference = new Float32Array(SIZE); + const boundaryAvoidance = new Float32Array(SIZE); + + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (sea?.[i]) { + geographicBarrier[i] = 1; + geographicBarrierCost[i] = INF; + continue; + } + + const depositional = + (depositionalLowland?.[i] || 0) * 0.92 + + (alluvialFanField?.[i] || 0) * 0.56 + + (deltaField?.[i] || 0) * 0.86; + const highElevation = clamp(((elevation?.[i] || 0) - 0.54) / 0.34); + const lowSlope = clamp(1 - (slope?.[i] || 0) * 2.25); + const confluence = x > 0 && y > 0 && x < MAP_W - 1 && y < MAP_H - 1 ? localConfluenceScore(x, y, river) : 0; + const naturalBarrier = naturalBarrierScore?.[i] || 0; + const pass = passSuitability?.[i] || 0; + const crossing = crossingSuitability?.[i] || 0; + const port = portSuitability?.[i] || 0; + + lowlandCapacity[i] = clamp( + (plain?.[i] || 0) * 0.38 + + (agriculture?.[i] || 0) * 0.28 + + (basinField?.[i] || 0) * 0.24 + + (coastalLowland?.[i] || 0) * 0.18 + + depositional * 0.20 + + lowSlope * 0.13 - + (slope?.[i] || 0) * 0.55 - + (ridgeField?.[i] || 0) * 0.38 - + highElevation * 0.62 + ); + valleyAccess[i] = clamp( + (valleyField?.[i] || 0) * 0.48 + + confluence * 0.40 + + crossing * 0.22 + + pass * 0.18 + + (flowAccum?.[i] || 0) * 0.08 + + (basinField?.[i] || 0) * 0.12 - + (slope?.[i] || 0) * 0.42 - + (ridgeField?.[i] || 0) * 0.22 + ); + coastalAccess[i] = clamp( + (coastalLowland?.[i] || 0) * 0.46 + + port * 0.34 + + (deltaField?.[i] || 0) * 0.22 + + (plain?.[i] || 0) * 0.10 - + (slope?.[i] || 0) * 0.42 - + (ridgeField?.[i] || 0) * 0.20 + ); + geographicBarrier[i] = clamp( + (slope?.[i] || 0) * 0.60 + + (ridgeField?.[i] || 0) * 0.52 + + naturalBarrier * 0.58 + + highElevation * 0.54 + + Math.max(0, (elevation?.[i] || 0) - 0.68) * 0.90 - + (valleyField?.[i] || 0) * 0.18 - + (basinField?.[i] || 0) * 0.12 - + pass * 0.30 - + (plain?.[i] || 0) * 0.10 - + (coastalLowland?.[i] || 0) * 0.06 + ); + geographicBarrierCost[i] = 1 + geographicBarrier[i] * 8.5 + (slope?.[i] || 0) * 3.0 + highElevation * 4.2; + habitability[i] = clamp( + lowlandCapacity[i] * 0.70 + + valleyAccess[i] * 0.22 + + coastalAccess[i] * 0.26 + + (agriculture?.[i] || 0) * 0.16 + + confluence * 0.07 - + geographicBarrier[i] * 0.38 - + (floodplain?.[i] || 0) * 0.04 + ); + corridorSuitability[i] = clamp( + (valleyField?.[i] || 0) * 0.30 + + (coastalLowland?.[i] || 0) * 0.23 + + (plain?.[i] || 0) * 0.18 + + (basinField?.[i] || 0) * 0.18 + + pass * 0.22 + + crossing * 0.12 + + habitability[i] * 0.20 - + geographicBarrier[i] * 0.32 - + (slope?.[i] || 0) * 0.20 + ); + accessibility[i] = clamp( + corridorSuitability[i] * 0.44 + + port * 0.16 + + crossing * 0.12 + + pass * 0.10 + + habitability[i] * 0.22 - + geographicBarrier[i] * 0.16 + ); + naturalCentrality[i] = clamp( + habitability[i] * 0.50 + + accessibility[i] * 0.30 + + (basinField?.[i] || 0) * 0.14 + + (plain?.[i] || 0) * 0.10 + + (coastalLowland?.[i] || 0) * 0.08 + + port * 0.08 + + confluence * 0.06 - + geographicBarrier[i] * 0.20 + ); + centrality[i] = naturalCentrality[i]; + adminBoundaryPreference[i] = clamp( + naturalBarrier * 0.54 + + (ridgeField?.[i] || 0) * 0.32 + + (river?.[i] || 0) * 0.12 + + (flowAccum?.[i] || 0) * 0.08 - + naturalCentrality[i] * 0.20 - + habitability[i] * 0.08 + ); + boundaryAvoidance[i] = clamp( + naturalCentrality[i] * 0.54 + + habitability[i] * 0.24 + + (plain?.[i] || 0) * 0.12 + + (basinField?.[i] || 0) * 0.08 - + geographicBarrier[i] * 0.18 + ); + } + } + + const fields = { + version: GEOGRAPHY_VERSION, + habitability, + lowlandCapacity, + valleyAccess, + coastalAccess, + geographicBarrier, + geographicBarrierCost, + barrierCost: geographicBarrierCost, + corridorSuitability, + accessibility, + naturalCentrality, + centrality, + adminBoundaryPreference, + boundaryAvoidance, + }; + + const geographyDebug = { + version: GEOGRAPHY_VERSION, + stage: "terrain-derived", + fields: { + habitability: summarizeField(habitability, sea), + accessibility: summarizeField(accessibility, sea), + centrality: summarizeField(centrality, sea), + geographicBarrier: summarizeField(geographicBarrier, sea), + corridorSuitability: summarizeField(corridorSuitability, sea), + }, + naturalCompartmentCount: new Set([...naturalCompartmentId || []].filter((id, i) => id >= 0 && !sea?.[i])).size, + watershedCount: new Set([...watershedId || []].filter((id, i) => id >= 0 && !sea?.[i])).size, + }; + + return { + ...fields, + compartmentProfiles: buildProfiles(naturalCompartmentId, terrain, fields), + watershedProfiles: buildProfiles(watershedId, terrain, fields), + geographyDebug, + }; +} + +export function finalizeGeographicBasis(seed, terrain, features, baseGeography) { + const base = baseGeography || buildGeographicBasis(seed, terrain); + const { sea } = terrain; + const { + roadInfluence, + railInfluence2, + stationInfluence, + populationDensity, + ports = [], + modernCities = [], + markets = [], + } = features || {}; + + const portInfluence = influenceFromPoints(ports, 16, (p) => p.portClass === "major" ? 1.1 : p.portClass === "regional" ? 0.78 : 0.34); + const cityInfluence = influenceFromPoints(modernCities, 24, (p) => clamp((p.population || 50000) / 240000, 0.35, 2.4)); + const marketInfluence = influenceFromPoints(markets, 13, (p) => clamp((p.population || 10000) / 48000, 0.18, 1.0)); + + const accessibility = new Float32Array(SIZE); + const transportAccessibility = new Float32Array(SIZE); + const centrality = new Float32Array(SIZE); + const humanCentrality = new Float32Array(SIZE); + const boundaryAvoidance = new Float32Array(SIZE); + const adminBoundaryPreference = new Float32Array(SIZE); + + for (let i = 0; i < SIZE; i++) { + if (sea?.[i]) continue; + transportAccessibility[i] = clamp( + (roadInfluence?.[i] || 0) * 0.36 + + (railInfluence2?.[i] || 0) * 0.28 + + (stationInfluence?.[i] || 0) * 0.20 + + portInfluence[i] * 0.16 + ); + accessibility[i] = clamp( + (base.accessibility?.[i] || 0) * 0.46 + + transportAccessibility[i] * 0.48 + + (base.corridorSuitability?.[i] || 0) * 0.10 - + (base.geographicBarrier?.[i] || 0) * 0.10 + ); + humanCentrality[i] = clamp( + (populationDensity?.[i] || 0) * 0.38 + + cityInfluence[i] * 0.30 + + marketInfluence[i] * 0.14 + + transportAccessibility[i] * 0.18 + ); + centrality[i] = clamp( + (base.naturalCentrality?.[i] || 0) * 0.44 + + accessibility[i] * 0.26 + + humanCentrality[i] * 0.36 - + (base.geographicBarrier?.[i] || 0) * 0.08 + ); + boundaryAvoidance[i] = clamp( + (base.boundaryAvoidance?.[i] || 0) * 0.52 + + centrality[i] * 0.42 + + transportAccessibility[i] * 0.10 + ); + adminBoundaryPreference[i] = clamp( + (base.adminBoundaryPreference?.[i] || 0) * 0.84 - + centrality[i] * 0.12 - + transportAccessibility[i] * 0.08 + ); + } + + const finalFields = { + ...base, + accessibility, + transportAccessibility, + centrality, + humanCentrality, + boundaryAvoidance, + adminBoundaryPreference, + }; + + return { + ...finalFields, + compartmentProfiles: buildProfiles(terrain.naturalCompartmentId, terrain, finalFields), + watershedProfiles: buildProfiles(terrain.watershedId, terrain, finalFields), + geographyDebug: { + ...(base.geographyDebug || {}), + stage: "finalized-with-human-network", + fields: { + ...(base.geographyDebug?.fields || {}), + accessibility: summarizeField(accessibility, sea), + transportAccessibility: summarizeField(transportAccessibility, sea), + centrality: summarizeField(centrality, sea), + humanCentrality: summarizeField(humanCentrality, sea), + adminBoundaryPreference: summarizeField(adminBoundaryPreference, sea), + boundaryAvoidance: summarizeField(boundaryAvoidance, sea), + }, + }, + }; +} diff --git a/mapOutput.js b/mapOutput.js index d384c6f..5cf4f3a 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -25,10 +25,7 @@ function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) { if (!value) value = `自治${ordinal + 1}`; value = value.replace(/[市町村区駅港城跡宿]$/gu, ""); const fallback = String(center?.generatedMunicipalityName || "里").replace(/[市町村区駅港城跡宿]$/gu, ""); - if (Array.from(value).length < 2) value = `${value}${fallback || "里"}`; - // Municipality roots should be at most two toponymic elements. The admin - // suffix is separate; avoid direction+root+suffix three-element names. - value = Array.from(value).slice(0, 2).join(""); + if (!value) value = fallback || "里"; return `${value}${municipalitySuffixForCenter(center, fields, seed, ordinal)}`; } @@ -37,15 +34,19 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields, settlement if (!adminCenters?.length || !adminId) return; const totals = new Float64Array(adminCenters.length); const settlementTotals = new Float64Array(adminCenters.length); + const landCells = new Uint32Array(adminCenters.length); + const inhabitedCells = new Uint32Array(adminCenters.length); for (let i = 0; i < adminId.length; i++) { const id = adminId[i]; if (id < 0 || id >= totals.length || fields.sea?.[i]) continue; + landCells[id]++; const density = fields.populationDensity?.[i] || 0; const lu = fields.landuse?.[i] ?? 0; const plain = fields.plain?.[i] || 0; const agri = fields.agriculture?.[i] || 0; const builtWeight = lu === 3 ? 520 : lu === 2 ? 360 : lu === 4 || lu === 7 || lu === 8 ? 260 : lu === 5 || lu === 6 ? 160 : lu === 1 ? 82 : 22; const ruralFloor = lu === 1 ? 10 + agri * 18 + plain * 10 : 0; + if (density > 0.006 || ruralFloor > 0 || lu > 0) inhabitedCells[id]++; totals[id] += density * builtWeight + ruralFloor; } // Population-bearing generated settlements are canonical entities, so add @@ -78,8 +79,17 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields, settlement } for (let id = 0; id < adminCenters.length; id++) { const raw = (totals[id] || 0) + (settlementTotals[id] || 0); - const rounded = raw >= 10000 ? Math.round(raw / 1000) * 1000 : Math.round(raw / 100) * 100; - adminCenters[id].municipalityPopulation = Math.max(0, rounded); + const minimumResidentPopulation = landCells[id] > 0 + ? Math.round(Math.max(100, Math.min(3800, 80 + landCells[id] * 9 + inhabitedCells[id] * 16)) / 100) * 100 + : 0; + const adjustedRaw = Math.max(raw, minimumResidentPopulation); + const rounded = adjustedRaw >= 10000 ? Math.round(adjustedRaw / 1000) * 1000 : Math.max(minimumResidentPopulation, Math.round(adjustedRaw / 100) * 100); + const safePopulation = Math.max(landCells[id] > 0 ? 100 : 0, rounded); + adminCenters[id].municipalityPopulation = safePopulation; + // Some consumers still read the generic `population` field from municipal + // centers. Mirror the municipality total there so no municipality is shown + // as 0人 merely because it is not a canonical city/market entity. + adminCenters[id].population = Math.max(adminCenters[id].population || 0, safePopulation); adminCenters[id].municipalitySettlementPopulation = Math.max(0, Math.round((settlementTotals[id] || 0) / 100) * 100); adminCenters[id].municipalitySkippedDuplicateSettlementPopulation = Math.max(0, Math.round(skippedDuplicateSettlementPopulation / 100) * 100); } @@ -93,6 +103,33 @@ function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCiti const id = prefectureRegionId[i]; if (!sea[i] && id >= 0) prefIds.add(id); } + const prefProfiles = new Map(); + for (let i = 0; i < prefectureRegionId.length; i++) { + const prefId = prefectureRegionId[i]; + if (sea[i] || prefId < 0) continue; + const profile = prefProfiles.get(prefId) || { landCells: 0, habitableCells: 0, lowlandCells: 0, densitySum: 0 }; + profile.landCells++; + const slopeV = fields.slope?.[i] || 0; + const ridgeV = fields.ridgeField?.[i] || 0; + const elevV = fields.elevation?.[i] || 0; + const lowland = ((fields.plain?.[i] || 0) > 0.24 || (fields.basinField?.[i] || 0) > 0.26 || (fields.coastalLowland?.[i] || 0) > 0.22) && slopeV < 0.38 && ridgeV < 0.55; + if (slopeV < 0.42 && ridgeV < 0.58 && elevV < 0.74) profile.habitableCells++; + if (lowland) profile.lowlandCells++; + profile.densitySum += fields.populationDensity?.[i] || 0; + prefProfiles.set(prefId, profile); + } + function capitalFloorForPref(prefId) { + const p = prefProfiles.get(prefId) || { landCells: 0, habitableCells: 0, lowlandCells: 0, densitySum: 0 }; + const lowlandRatio = p.landCells ? p.lowlandCells / p.landCells : 0; + const densityBoost = clamp((p.densitySum / Math.max(1, p.landCells) - 0.16) / 0.42); + let base = 45000; + if (p.lowlandCells > 900 || (p.lowlandCells > 650 && lowlandRatio > 0.28)) base = minPopulation * 0.90; + else if (p.lowlandCells > 520) base = 160000; + else if (p.lowlandCells > 260) base = 110000; + else if (p.lowlandCells > 120 || p.habitableCells > 420) base = 70000; + const adjusted = base + densityBoost * 50000; + return Math.round(clamp(adjusted, 42000, minPopulation + 45000) / 1000) * 1000; + } let promoted = 0; function prefAt(p) { if (!p || !inside(p.x, p.y)) return -1; @@ -130,7 +167,10 @@ function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCiti } } if (!target) continue; - const promotedPopulation = Math.round((minPopulation + rand(seed + 52000, prefId * 37 + 11) * 120000) / 1000) * 1000; + const regionalFloor = capitalFloorForPref(prefId); + const candidateCapacity = Number.isFinite(target.capacity) ? Math.max(42000, target.capacity * 1.18) : Infinity; + const randomizedFloor = Math.round((regionalFloor + rand(seed + 52000, prefId * 37 + 11) * Math.max(12000, regionalFloor * 0.28)) / 1000) * 1000; + const promotedPopulation = Math.max(42000, Math.round(Math.min(randomizedFloor, candidateCapacity) / 1000) * 1000); if ((target.population || 0) < promotedPopulation) { target.population = promotedPopulation; promoted++; @@ -251,6 +291,7 @@ export function finishMapOutput({ terrain, features, admin, + geography = null, }) { const { terrainTemplate, @@ -272,6 +313,7 @@ export function finishMapOutput({ basinField, coastalLowland, flowAccum, + watershedId, erosionField, depositionField, arcSpineField, @@ -296,6 +338,7 @@ export function finishMapOutput({ railInfluence2, settlementCluster, villages: inputVillages, + geographicUrbanAnchors: inputGeographicUrbanAnchors = [], ports: inputPorts, crossings: inputCrossings, passes: inputPasses, @@ -342,6 +385,7 @@ export function finishMapOutput({ } = admin; let villages = inputVillages; + let geographicUrbanAnchors = inputGeographicUrbanAnchors; let ports = inputPorts; let crossings = inputCrossings; let passes = inputPasses; @@ -386,6 +430,7 @@ export function finishMapOutput({ outputProgress("feature naming"); villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed, null, nameFields, usedNames, nameDebug); + geographicUrbanAnchors = attachIdsAndNames(tagInsidePrefecture(geographicUrbanAnchors, prefectureMask), "geoAnchor", seed, null, nameFields, usedNames, nameDebug); ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed, null, nameFields, usedNames, nameDebug); crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed, null, nameFields, usedNames, nameDebug); passes = attachIdsAndNames(tagInsidePrefecture(passes, prefectureMask), "pass", seed, null, nameFields, usedNames, nameDebug); @@ -458,7 +503,7 @@ export function finishMapOutput({ ].filter((v) => Array.from(v).length >= 2); for (let attempt = 0; attempt < alternates.length + 12 && usedAdminNames.has(candidate); attempt++) { const root = attempt < alternates.length ? alternates[attempt] : `第${(index + attempt) % 10}`; - candidate = `${Array.from(root).slice(0, 2).join("")}${suffix}`; + candidate = `${root}${suffix}`; } } center.name = candidate; @@ -615,6 +660,161 @@ export function finishMapOutput({ } } addMunicipalCenterLocalAccess(); + + function pruneIsolatedFinalRoadComponents() { + const debugLayers = transportDebug ? (transportDebug.layers || (transportDebug.layers = {})) : {}; + const groups = [ + ["minor", minorRoads], + ["national", nationalRoads], + ["external", externalRoads], + ["expressway", expressways], + ["externalExpressway", externalExpressways], + ]; + function rasterize(path, fn) { + for (let k = 0; k < (path?.length || 0); k++) { + const [x0, y0] = path[k]; + const [x1, y1] = path[Math.min(k + 1, path.length - 1)]; + const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0))); + for (let s = 0; s <= steps; s++) { + const t = s / steps; + const x = Math.round(x0 + (x1 - x0) * t); + const y = Math.round(y0 + (y1 - y0) * t); + fn(x, y); + } + } + } + function splitPathOnSea(path) { + const chunks = []; + let chunk = []; + function pushPoint(x, y) { + if (!inside(x, y) || sea[indexOf(x, y)]) { + if (chunk.length >= 2) chunks.push(chunk); + chunk = []; + return; + } + if (!chunk.length || chunk[chunk.length - 1][0] !== x || chunk[chunk.length - 1][1] !== y) chunk.push([x, y]); + } + for (let k = 0; k < (path?.length || 0); k++) { + const [x0, y0] = path[k]; + const [x1, y1] = path[Math.min(k + 1, path.length - 1)]; + const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0))); + for (let s = 0; s <= steps; s++) { + const t = s / steps; + pushPoint(Math.round(x0 + (x1 - x0) * t), Math.round(y0 + (y1 - y0) * t)); + } + } + if (chunk.length >= 2) chunks.push(chunk); + return chunks; + } + // Keep sea-crossing cells in the stored path so the renderer can draw + // explicit bridge overlays. Connectivity analysis below ignores sea cells + // when rasterizing components, so preserving them here does not make islands + // falsely connected by ordinary land roads. + function pathNearAdminCenter(path, radius = 0.75) { + for (const center of adminCenters || []) { + if (!center || !inside(center.x, center.y)) continue; + for (const [x, y] of path || []) { + if (Math.hypot(center.x - x, center.y - y) <= radius) return true; + } + } + return false; + } + + function components() { + const occ = new Uint8Array(MAP_W * MAP_H); + for (const [, paths] of groups) for (const path of paths || []) rasterize(path, (x, y) => { + if (inside(x, y) && !sea[indexOf(x, y)]) occ[indexOf(x, y)] = 1; + }); + const seen = new Uint8Array(MAP_W * MAP_H); + const out = []; + for (let i = 0; i < occ.length; i++) { + if (!occ[i] || seen[i]) continue; + const queue = [i]; + const cells = []; + seen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + cells.push(cur); + const [x, y] = xyOf(cur); + for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) { + if (!dx && !dy) continue; + if (dx * dx + dy * dy > 5) continue; + const nx = x + dx, ny = y + dy; + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (!occ[ni] || seen[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + out.push({ cells, size: cells.length }); + } + return out.sort((a, b) => b.size - a.size); + } + let comps = components(); + const before = comps.length; + const pruned = { minor: 0, national: 0, external: 0, expressway: 0, externalExpressway: 0 }; + for (let pass = 0; pass < 4 && comps.length > 1; pass++) { + const mainMask = new Uint8Array(MAP_W * MAP_H); + for (const ci of comps[0].cells) { + const [cx, cy] = xyOf(ci); + for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) { + if (dx * dx + dy * dy > 5) continue; + const nx = cx + dx, ny = cy + dy; + if (inside(nx, ny)) mainMask[indexOf(nx, ny)] = 1; + } + } + function touchesMain(path) { + let hit = 0, n = 0; + rasterize(path, (x, y) => { + if (!inside(x, y) || sea[indexOf(x, y)]) return; + n++; + if (mainMask[indexOf(x, y)]) hit++; + }); + return n > 0 && hit / n >= (pass === 0 ? 0.10 : 0.01); + } + for (const [key, paths] of groups) { + const kept = []; + for (const path of paths || []) { + if (touchesMain(path) || pathNearAdminCenter(path)) kept.push(path); + else pruned[key]++; + } + paths.length = 0; + paths.push(...kept); + } + comps = components(); + } + debugLayers.finalOutputRoadConnectivity = { beforeComponents: before, afterComponents: comps.length, pruned }; + } + pruneIsolatedFinalRoadComponents(); + + function ensureAdminCenterCellsAfterOutputPrune() { + let added = 0; + function roadTouches(center) { + for (const path of [...minorRoads, ...nationalRoads, ...externalRoads]) { + for (const [x, y] of path || []) if (Math.hypot(center.x - x, center.y - y) <= 0.65) return true; + } + return false; + } + for (const center of adminCenters || []) { + if (!center || !inside(center.x, center.y) || sea[indexOf(center.x, center.y)]) continue; + if (roadTouches(center)) continue; + const x = Math.round(center.x); + const y = Math.round(center.y); + const horizontal = [[Math.max(0, x - 1), y], [x, y], [Math.min(MAP_W - 1, x + 1), y]] + .filter((pt, idx, arr) => idx === 0 || pt[0] !== arr[idx - 1][0] || pt[1] !== arr[idx - 1][1]); + const vertical = [[x, Math.max(0, y - 1)], [x, y], [x, Math.min(MAP_H - 1, y + 1)]] + .filter((pt, idx, arr) => idx === 0 || pt[0] !== arr[idx - 1][0] || pt[1] !== arr[idx - 1][1]); + minorRoads.push(horizontal.length >= 2 ? horizontal : vertical); + added++; + } + if (transportDebug) { + transportDebug.layers ||= {}; + transportDebug.layers.adminCenterFinalStubs = added; + } + } + ensureAdminCenterCellsAfterOutputPrune(); + nameDebug.maxDerivedPerBase = 0; const prefectureRegions = buildPrefectureRegions(prefectureRegionId, sea, nameFields, seed, usedNames, nameDebug, { modernCities, adminCenters }); const regionalPrefectureBorders = adminRegionalPrefectureBorders || []; @@ -657,6 +857,25 @@ export function finishMapOutput({ regionalDebug, terrainDebug, regionalPrefectureBorders, + geography, + geographyDebug: geography?.geographyDebug || null, + habitability: geography?.habitability || null, + accessibility: geography?.accessibility || null, + centrality: geography?.centrality || null, + naturalCentrality: geography?.naturalCentrality || null, + humanCentrality: geography?.humanCentrality || null, + transportAccessibility: geography?.transportAccessibility || null, + geographicBarrier: geography?.geographicBarrier || null, + geographicBarrierCost: geography?.geographicBarrierCost || geography?.barrierCost || null, + barrierCost: geography?.barrierCost || geography?.geographicBarrierCost || null, + corridorSuitability: geography?.corridorSuitability || null, + adminBoundaryPreference: geography?.adminBoundaryPreference || null, + boundaryAvoidance: geography?.boundaryAvoidance || null, + lowlandCapacity: geography?.lowlandCapacity || null, + valleyAccess: geography?.valleyAccess || null, + coastalAccess: geography?.coastalAccess || null, + geographicCompartmentProfiles: geography?.compartmentProfiles || [], + watershedProfiles: geography?.watershedProfiles || [], elevation, moisture, slope, @@ -675,6 +894,7 @@ export function finishMapOutput({ basinField, coastalLowland, flowAccum, + watershedId, erosionField, depositionField, arcSpineField, @@ -686,6 +906,7 @@ export function finishMapOutput({ naturalCompartmentId, naturalCompartments, villages, + geographicUrbanAnchors, ports, crossings, passes, diff --git a/mapPipeline.js b/mapPipeline.js index ad8eb92..45aa2b4 100644 --- a/mapPipeline.js +++ b/mapPipeline.js @@ -3,6 +3,8 @@ import { generateTerrainAndRivers } from "./mapTerrain.js"; import { generateMapFeatures } from "./mapFeatures.js"; import { finishMapOutput } from "./mapOutput.js"; import { generateAdminLayout } from "./mapAdminStage.js"; +import { buildGeographicBasis, finalizeGeographicBasis } from "./mapGeography.js"; +import { finalizeAdminAwareTransport } from "./mapPostAdminTransport.js"; export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js"; @@ -69,7 +71,10 @@ export function generateMap(seedInput = 114514, options = {}) { naturalCompartments, } = terrain; - const features = stage("human", "Human geography and transport", () => generateMapFeatures(seed, terrain)); + const geographyBasis = stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain)); + const terrainWithGeography = { ...terrain, geography: geographyBasis }; + + const features = stage("settlements", "Settlements, towns, ports, and land-use demand", () => generateMapFeatures(seed, terrainWithGeography)); const { settlementScore, villages, @@ -89,8 +94,11 @@ export function generateMap(seedInput = 114514, options = {}) { villageInfluence, } = features; + const geography = stage("geographyFinal", "Unified accessibility and centrality fields", () => finalizeGeographicBasis(seed, terrain, features, geographyBasis)); + const admin = stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({ seed, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, + geography, habitability: geography.habitability, accessibility: geography.accessibility, centrality: geography.centrality, geographicBarrier: geography.geographicBarrier, geographicBarrierCost: geography.geographicBarrierCost, adminBoundaryPreference: geography.adminBoundaryPreference, boundaryAvoidance: geography.boundaryAvoidance, settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks, adminProgress: (event) => options?.onProgress?.({ ...event, @@ -104,12 +112,15 @@ export function generateMap(seedInput = 114514, options = {}) { }), })); + stage("transport", "Administrative-aware final transport network", () => finalizeAdminAwareTransport({ seed, terrain, features, admin, geography })); + const output = stage("output", "Names, population totals, and final packaging", () => finishMapOutput({ seed, options, terrain, features, admin, + geography, })); output.generationTimings = generationTimings; output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0); @@ -143,7 +154,10 @@ export async function generateMapAsync(seedInput = 114514, options = {}) { naturalCompartments, } = terrain; - const features = await stage("human", "Human geography and transport", () => generateMapFeatures(seed, terrain)); + const geographyBasis = await stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain)); + const terrainWithGeography = { ...terrain, geography: geographyBasis }; + + const features = await stage("settlements", "Settlements, towns, ports, and land-use demand", () => generateMapFeatures(seed, terrainWithGeography)); const { settlementScore, villages, @@ -163,8 +177,11 @@ export async function generateMapAsync(seedInput = 114514, options = {}) { villageInfluence, } = features; + const geography = await stage("geographyFinal", "Unified accessibility and centrality fields", () => finalizeGeographicBasis(seed, terrain, features, geographyBasis)); + const admin = await stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({ seed, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, + geography, habitability: geography.habitability, accessibility: geography.accessibility, centrality: geography.centrality, geographicBarrier: geography.geographicBarrier, geographicBarrierCost: geography.geographicBarrierCost, adminBoundaryPreference: geography.adminBoundaryPreference, boundaryAvoidance: geography.boundaryAvoidance, settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks, adminProgress: (event) => options?.onProgress?.({ ...event, @@ -178,12 +195,15 @@ export async function generateMapAsync(seedInput = 114514, options = {}) { }), })); + await stage("transport", "Administrative-aware final transport network", () => finalizeAdminAwareTransport({ seed, terrain, features, admin, geography })); + const output = await stage("output", "Names, population totals, and final packaging", () => finishMapOutput({ seed, options, terrain, features, admin, + geography, })); output.generationTimings = generationTimings; output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0); diff --git a/mapPostAdminTransport.js b/mapPostAdminTransport.js new file mode 100644 index 0000000..ca18d4d --- /dev/null +++ b/mapPostAdminTransport.js @@ -0,0 +1,312 @@ +import { MAP_W, MAP_H, SIZE, indexOf, inside } from "./mapUtils.js"; +import { pathLengthCells } from "./mapTransport.js"; + +function cellKey(x, y) { return `${Math.round(x)},${Math.round(y)}`; } + +function pathTouchesCell(path, x, y, radius = 0.65) { + if (!path || path.length < 1) return false; + for (const [px, py] of path) if (Math.hypot(px - x, py - y) <= radius) return true; + return false; +} + +function anyPathTouches(paths, p, radius = 0.65) { + return (paths || []).some((path) => pathTouchesCell(path, p.x, p.y, radius)); +} + +function pathTerrainRuns(path, terrain = null) { + const sea = terrain?.sea; + const elevation = terrain?.elevation; + const ridgeField = terrain?.ridgeField; + const naturalBarrierScore = terrain?.naturalBarrierScore; + let seaRun = 0, maxSeaRun = 0, tunnelRun = 0, maxTunnelRun = 0; + for (const [x, y] of path || []) { + if (!inside(x, y)) continue; + const i = indexOf(x, y); + const isSea = Boolean(sea?.[i]); + const isTunnel = !isSea && (((elevation?.[i] || 0) >= 0.74 && (ridgeField?.[i] || 0) >= 0.46) || (naturalBarrierScore?.[i] || 0) >= 0.82); + if (isSea) { seaRun++; maxSeaRun = Math.max(maxSeaRun, seaRun); } else seaRun = 0; + if (isTunnel) { tunnelRun++; maxTunnelRun = Math.max(maxTunnelRun, tunnelRun); } else tunnelRun = 0; + } + return { maxSeaRun, maxTunnelRun }; +} + +function directPath(a, b, options = {}) { + if (!a || !b) return []; + const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y))); + const out = []; + for (let s = 0; s <= steps; s++) { + const t = s / steps; + const x = Math.round(a.x + (b.x - a.x) * t); + const y = Math.round(a.y + (b.y - a.y) * t); + if (!inside(x, y)) return []; + if (!out.length || out[out.length - 1][0] !== x || out[out.length - 1][1] !== y) out.push([x, y]); + } + if (options.maxLength && pathLengthCells(out) > options.maxLength) return []; + if (options.terrain) { + const runs = pathTerrainRuns(out, options.terrain); + if (Number.isFinite(options.maxSeaRun) && runs.maxSeaRun > options.maxSeaRun) return []; + if (Number.isFinite(options.maxTunnelRun) && runs.maxTunnelRun > options.maxTunnelRun) return []; + } + return out; +} + +function nearestPointOnPaths(paths, p, maxDistance = Infinity) { + let best = null; + for (const path of paths || []) { + for (const [x, y] of path || []) { + const d = Math.hypot(p.x - x, p.y - y); + if (d <= maxDistance && (!best || d < best.d)) best = { x, y, d }; + } + } + return best; +} + +function nearestEntity(entities, p, maxDistance = Infinity) { + let best = null; + for (const q of entities || []) { + if (!q || !Number.isFinite(q.x) || !Number.isFinite(q.y) || (q.x === p.x && q.y === p.y)) continue; + const d = Math.hypot(q.x - p.x, q.y - p.y); + if (d <= maxDistance && (!best || d < best.d)) best = { ...q, d }; + } + return best; +} + +function dedupePaths(paths, sampleStep = 2) { + const seen = new Set(); + const kept = []; + for (const path of paths || []) { + if (!path || path.length < 2) continue; + const cleaned = []; + for (const pt of path) { + const x = Math.round(pt[0]); + const y = Math.round(pt[1]); + if (!inside(x, y)) continue; + if (!cleaned.length || cleaned[cleaned.length - 1][0] !== x || cleaned[cleaned.length - 1][1] !== y) cleaned.push([x, y]); + } + if (cleaned.length < 2) continue; + const sigFor = (arr) => arr.map((p, i) => (i % sampleStep === 0 || i === arr.length - 1) ? `${p[0]},${p[1]}` : "").filter(Boolean).join("|"); + const f = sigFor(cleaned); + const r = sigFor([...cleaned].reverse()); + const sig = f < r ? f : r; + if (seen.has(sig)) continue; + seen.add(sig); + kept.push(cleaned); + } + return kept; +} + +function addInterchange(interchanges, x, y, source = "post-admin-expressway-endpoint") { + x = Math.round(x); y = Math.round(y); + if (!inside(x, y)) return false; + if ((interchanges || []).some((p) => Math.hypot(p.x - x, p.y - y) <= 2.5)) return false; + interchanges.push({ x, y, kind: "Interchange", score: 1, source }); + return true; +} + +function smoothPath(path, passes = 1) { + let cur = (path || []).map(([x, y]) => [Math.round(x), Math.round(y)]); + for (let pass = 0; pass < passes; pass++) { + if (cur.length < 3) break; + const next = [cur[0]]; + for (let i = 1; i < cur.length - 1; i++) { + const [ax, ay] = cur[i - 1]; + const [bx, by] = cur[i]; + const [cx, cy] = cur[i + 1]; + const x = Math.round((ax + bx * 2 + cx) / 4); + const y = Math.round((ay + by * 2 + cy) / 4); + if (!next.length || next[next.length - 1][0] !== x || next[next.length - 1][1] !== y) next.push([x, y]); + } + next.push(cur[cur.length - 1]); + cur = next; + } + return cur; +} + +function rebuildInfluence(paths, radius = 5) { + const field = new Float32Array(SIZE); + const r = Math.ceil(radius); + for (const path of paths || []) { + for (const [px, py] of path || []) { + for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) { + const x = px + dx, y = py + dy; + if (!inside(x, y)) continue; + const d = Math.hypot(dx, dy); + if (d > radius) continue; + const i = indexOf(x, y); + field[i] = Math.max(field[i], Math.max(0, 1 - d / Math.max(0.001, radius))); + } + } + } + return field; +} + +export function finalizeAdminAwareTransport({ seed, terrain, features, admin, geography = null }) { + if (!features || !admin) return features; + const minorRoads = features.minorRoads || []; + const nationalRoads = features.nationalRoads || []; + const externalRoads = features.externalRoads || []; + const expressways = features.expressways || []; + const externalExpressways = features.externalExpressways || []; + const interchanges = features.interchanges || []; + const adminCenters = admin.adminCentersRaw || features.adminCenters || []; + const townsForNational = [ + ...(features.modernCities || []).filter((p) => (p.population || 0) >= 5000), + ...(features.markets || []).filter((p) => (p.population || 0) >= 5000), + ...(features.villages || []).filter((p) => (p.population || 0) >= 5000), + ...(features.ports || []).filter((p) => (p.population || 0) >= 5000 || p.portClass === "major" || p.portClass === "regional" || p.portClass === "fishing"), + ]; + const debug = { order: "settlements -> administration -> transport", adminCentersChecked: 0, adminLocalRoadsAdded: 0, nationalTownSpursAdded: 0, nationalTownChainsAdded: 0, nationalTownChainTownsCovered: 0, expresswayEndpointInterchangesAdded: 0, expresswaysSmoothed: 0 }; + + // Local roads after admin: every municipal office cell should lie on a road. + const settlementTargets = [...(features.modernCities || []), ...(features.markets || []), ...(features.villages || []), ...(features.ports || [])]; + for (const center of adminCenters || []) { + if (!center || !inside(center.x, center.y)) continue; + debug.adminCentersChecked++; + const roadSet = [...minorRoads, ...nationalRoads, ...externalRoads]; + if (anyPathTouches(roadSet, center, 0.65)) continue; + const nearRoad = nearestPointOnPaths(roadSet, center, 22); + const nearSettlement = nearestEntity(settlementTargets, center, 18); + const target = nearRoad || nearSettlement; + let path = target ? directPath(center, target, { maxLength: 34 }) : []; + if (!path.length) { + const x = center.x, y = center.y; + const a = { x: Math.max(0, x - 2), y }; + const b = { x: Math.min(MAP_W - 1, x + 2), y }; + path = directPath(a, b, { maxLength: 8 }); + } + if (path.length >= 2 && pathTouchesCell(path, center.x, center.y, 0.65)) { + minorRoads.push(path); + debug.adminLocalRoadsAdded++; + } + } + + // National roads after admin/settlements: try to cover red-dot towns by chain routes instead of one spur per town. + function concatPaths(parts) { + const out = []; + for (const part of parts || []) { + if (!part || part.length < 2) continue; + for (const pt of part) { + if (!out.length || out[out.length - 1][0] !== pt[0] || out[out.length - 1][1] !== pt[1]) out.push(pt); + } + } + return out; + } + function townWeight(p) { + return (p.population || 0) + (p.isPrefecturalCapital ? 800000 : 0) + (p.isRegionalCapital ? 350000 : 0) + (p.portClass === "major" ? 220000 : p.portClass === "regional" ? 120000 : p.portClass === "fishing" ? 45000 : 0); + } + function nearestTrunkOrHub(p, maxDistance = 85) { + const trunk = nearestPointOnPaths([...nationalRoads, ...externalRoads], p, maxDistance); + if (trunk) return trunk; + return nearestEntity([...(features.modernCities || []), ...(features.ports || []), ...(features.markets || []), ...(features.externalGateways || [])], p, maxDistance); + } + function buildTownChain(start, pool, maxHops = 7) { + const chain = [start]; + let cur = start; + for (let hop = 1; hop < maxHops; hop++) { + let best = null; + for (const town of pool) { + if (chain.includes(town)) continue; + const d = Math.hypot(cur.x - town.x, cur.y - town.y); + if (d > 42) continue; + const score = d - Math.min(18, Math.sqrt(Math.max(0, townWeight(town))) / 70); + if (!best || score < best.score) best = { town, d, score }; + } + if (!best) break; + chain.push(best.town); + cur = best.town; + } + return chain; + } + function addNationalTownChains() { + let uncovered = townsForNational + .filter((town) => town && inside(town.x, town.y) && !anyPathTouches([...nationalRoads, ...externalRoads], town, 0.65)) + .sort((a, b) => townWeight(b) - townWeight(a)); + let chainsAdded = 0; + let townsCovered = 0; + while (uncovered.length) { + const start = uncovered.shift(); + const chain = buildTownChain(start, uncovered, 7); + uncovered = uncovered.filter((town) => !chain.includes(town)); + const parts = []; + const before = nearestTrunkOrHub(chain[0], 80); + if (before) { + const p = directPath(before, chain[0], { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 0 }); + if (p.length) parts.push(p); + } + for (let i = 1; i < chain.length; i++) { + const d = Math.hypot(chain[i - 1].x - chain[i].x, chain[i - 1].y - chain[i].y); + const p = directPath(chain[i - 1], chain[i], { maxLength: Math.max(28, d * 1.35 + 8), terrain, maxSeaRun: 10, maxTunnelRun: 0 }); + if (p.length) parts.push(p); + } + const after = chain.length >= 2 ? nearestTrunkOrHub(chain[chain.length - 1], 80) : null; + if (after) { + const p = directPath(chain[chain.length - 1], after, { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 0 }); + if (p.length) parts.push(p); + } + let path = concatPaths(parts); + if (path.length < 2) { + const target = nearestTrunkOrHub(chain[0], 90); + path = target ? directPath(chain[0], target, { maxLength: 100, terrain, maxSeaRun: 10, maxTunnelRun: 0 }) : []; + } + if (path.length >= 2 && chain.some((town) => pathTouchesCell(path, town.x, town.y, 0.65))) { + nationalRoads.push(path); + chainsAdded++; + townsCovered += chain.filter((town) => pathTouchesCell(path, town.x, town.y, 0.65)).length; + } + } + return { chainsAdded, townsCovered }; + } + const chainDebug = addNationalTownChains(); + debug.nationalTownChainsAdded = chainDebug.chainsAdded; + debug.nationalTownChainTownsCovered = chainDebug.townsCovered; + debug.nationalTownSpursAdded = chainDebug.chainsAdded; + + // Expressway finalization after administration: smooth and ensure both endpoints are ICs. + for (let i = 0; i < expressways.length; i++) { + const smoothed = smoothPath(expressways[i], 2); + if (smoothed.length >= 2) { + expressways[i] = smoothed; + debug.expresswaysSmoothed++; + } + } + for (const path of [...expressways, ...externalExpressways]) { + if (!path || path.length < 2) continue; + const a = path[0]; + const b = path[path.length - 1]; + if (addInterchange(interchanges, a[0], a[1])) debug.expresswayEndpointInterchangesAdded++; + if (addInterchange(interchanges, b[0], b[1])) debug.expresswayEndpointInterchangesAdded++; + } + + features.minorRoads = dedupePaths(minorRoads, 2); + features.nationalRoads = dedupePaths(nationalRoads, 1); + features.externalRoads = dedupePaths(externalRoads, 1); + features.expressways = dedupePaths(expressways, 2); + features.externalExpressways = dedupePaths(externalExpressways, 2); + features.interchanges = interchanges; + + // Final invariant: after all dedupe passes, every municipal office cell has at least a local road cell on it. + let finalAdminStubsAdded = 0; + for (const center of adminCenters || []) { + if (!center || !inside(center.x, center.y)) continue; + if (anyPathTouches([...features.minorRoads, ...features.nationalRoads, ...features.externalRoads], center, 0.65)) continue; + const x = Math.round(center.x), y = Math.round(center.y); + const candidates = [ + [{ x: Math.max(0, x - 1), y }, { x, y }, { x: Math.min(MAP_W - 1, x + 1), y }], + [{ x, y: Math.max(0, y - 1) }, { x, y }, { x, y: Math.min(MAP_H - 1, y + 1) }], + ]; + const stub = candidates + .map((cand) => cand.map((p) => [p.x, p.y]).filter(([px, py], idx, arr) => idx === 0 || px !== arr[idx - 1][0] || py !== arr[idx - 1][1])) + .find((p) => p.length >= 2) || [[x, y], [Math.min(MAP_W - 1, x + 1), y]]; + features.minorRoads.push(stub); + finalAdminStubsAdded++; + } + debug.finalAdminStubsAdded = finalAdminStubsAdded; + features.roadInfluence = rebuildInfluence([...features.nationalRoads, ...(features.ringRoads || []), ...features.externalRoads, ...features.minorRoads], 5.0); + features.roadDensityInfluence = rebuildInfluence([...features.nationalRoads, ...(features.ringRoads || []), ...features.externalRoads, ...features.minorRoads], 9.0); + features.transportDebug = { + ...(features.transportDebug || {}), + generationOrder: debug.order, + postAdminTransportFinalization: debug, + }; + return features; +} diff --git a/mapPrefectureStage.js b/mapPrefectureStage.js new file mode 100644 index 0000000..ce1c31e --- /dev/null +++ b/mapPrefectureStage.js @@ -0,0 +1,863 @@ +import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, xyOf } from "./mapUtils.js"; + +export function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity, settlementFeatures = [], geography = {}) { + const nodes = new Map(); + const edges = new Map(); + for (let i = 0; i < SIZE; i++) { + if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; + const id = adminId[i]; + if (!nodes.has(id)) nodes.set(id, { id, area: 0, population: 0, cityPopulation: 0, settlementPopulation: 0, majorCityCount: 0, sx: 0, sy: 0, touchesOutside: false, habitability: 0, accessibility: 0, centrality: 0, boundaryAvoidance: 0, adminBoundaryPreference: 0, geographicBarrier: 0 }); + const node = nodes.get(id); + const [x, y] = xyOf(i); + if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) node.touchesOutside = true; + for (const [ox, oy] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) { + if (!inside(ox, oy)) { node.touchesOutside = true; continue; } + const oi = indexOf(ox, oy); + if (!prefectureMask[oi] || sea[oi]) node.touchesOutside = true; + } + node.area++; + node.population += populationDensity?.[i] || 0; + node.habitability += geography.habitability?.[i] || 0; + node.accessibility += geography.accessibility?.[i] || 0; + node.centrality += geography.centrality?.[i] || 0; + node.boundaryAvoidance += geography.boundaryAvoidance?.[i] || 0; + node.adminBoundaryPreference += geography.adminBoundaryPreference?.[i] || 0; + node.geographicBarrier += geography.geographicBarrier?.[i] || 0; + node.sx += x; + node.sy += y; + for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni] || adminId[ni] < 0 || adminId[ni] === id) continue; + const a = Math.min(id, adminId[ni]); + const b = Math.max(id, adminId[ni]); + const key = `${a}:${b}`; + const edge = edges.get(key) || { a, b, count: 0, barrier: 0, adminBoundaryPreference: 0, boundaryAvoidance: 0, centrality: 0, accessibility: 0 }; + edge.count++; + edge.barrier += ((naturalBarrierScore?.[i] || 0) + (naturalBarrierScore?.[ni] || 0)) * 0.5; + edge.adminBoundaryPreference += ((geography.adminBoundaryPreference?.[i] || 0) + (geography.adminBoundaryPreference?.[ni] || 0)) * 0.5; + edge.boundaryAvoidance += ((geography.boundaryAvoidance?.[i] || 0) + (geography.boundaryAvoidance?.[ni] || 0)) * 0.5; + edge.centrality += ((geography.centrality?.[i] || 0) + (geography.centrality?.[ni] || 0)) * 0.5; + edge.accessibility += ((geography.accessibility?.[i] || 0) + (geography.accessibility?.[ni] || 0)) * 0.5; + edges.set(key, edge); + } + } + for (const feature of settlementFeatures || []) { + if (!feature || !inside(feature.x, feature.y)) continue; + const i = indexOf(feature.x, feature.y); + if (!prefectureMask[i] || sea[i]) continue; + const id = adminId[i]; + const node = nodes.get(id); + if (!node) continue; + const pop = Math.max(0, feature.population || 0); + node.settlementPopulation += pop; + if (feature.kind === "Regional Capital" || feature.kind === "Prefectural Capital" || feature.kind === "Regional City" || feature.kind === "Local City" || feature.isRegionalCapital || feature.isPrefecturalCapital) { + node.cityPopulation += pop; + node.majorCityCount += pop >= 120000 ? 1 : 0; + } + node.population += pop / 16000; + } + for (const node of nodes.values()) { + node.x = node.sx / Math.max(1, node.area); + node.y = node.sy / Math.max(1, node.area); + node.habitability /= Math.max(1, node.area); + node.accessibility /= Math.max(1, node.area); + node.centrality /= Math.max(1, node.area); + node.boundaryAvoidance /= Math.max(1, node.area); + node.adminBoundaryPreference /= Math.max(1, node.area); + node.geographicBarrier /= Math.max(1, node.area); + node.adjacent = new Map(); + } + for (const edge of edges.values()) { + edge.barrier /= Math.max(1, edge.count); + edge.adminBoundaryPreference /= Math.max(1, edge.count); + edge.boundaryAvoidance /= Math.max(1, edge.count); + edge.centrality /= Math.max(1, edge.count); + edge.accessibility /= Math.max(1, edge.count); + // Prefecture grouping should pay the same natural-compartment crossing + // cost that municipality generation uses: ridges, rivers, valley walls and + // other strong natural dividers should be expensive to cross. Short shared + // boundaries are also unstable, so they get a small extra penalty. + edge.crossingCost = Math.max(0.22, + 1.0 + + edge.barrier * 7.2 + + edge.adminBoundaryPreference * 6.4 - + edge.boundaryAvoidance * 2.6 - + edge.centrality * 1.4 - + edge.accessibility * 0.9 + + 2.6 / Math.sqrt(Math.max(1, edge.count)) + ); + nodes.get(edge.a)?.adjacent.set(edge.b, edge); + nodes.get(edge.b)?.adjacent.set(edge.a, edge); + } + return { nodes, edges }; +} + +export function choosePrefectureMunicipalitySeeds(nodes, seed) { + const active = [...nodes.values()].filter((node) => node.area > 0).sort((a, b) => b.area - a.area || a.id - b.id); + const totalArea = active.reduce((sum, node) => sum + node.area, 0); + // Real Japan's smallest prefecture by municipality count is roughly Toyama's 15. + // Keep generated prefectures near that scale by limiting prefecture count unless + // enough municipalities exist to give each prefecture a meaningful set. + const minMunicipalitiesPerPrefecture = 14; + const maxByMunicipalityCount = Math.max(3, Math.floor(active.length / minMunicipalitiesPerPrefecture)); + const areaBased = clamp(Math.round(totalArea / 7800), 3, 6); + const targetCount = clamp(Math.min(areaBased, maxByMunicipalityCount || areaBased), 3, 6); + const seeds = []; + const minSpacing = Math.max(18, Math.sqrt(totalArea / Math.max(1, targetCount)) * 0.46); + function tryAdd(node, relaxed = false) { + if (!node || seeds.includes(node) || seeds.length >= targetCount) return false; + const nearest = seeds.length ? Math.min(...seeds.map((s) => Math.hypot(node.x - s.x, node.y - s.y))) : INF; + if (!relaxed && nearest < minSpacing) return false; + seeds.push(node); + return true; + } + const capitalLike = active + .filter((node) => (node.cityPopulation || 0) >= 90000 || node.majorCityCount > 0 || (node.settlementPopulation || 0) >= 140000) + .sort((a, b) => ((b.cityPopulation || 0) + (b.settlementPopulation || 0) * 0.35) - ((a.cityPopulation || 0) + (a.settlementPopulation || 0) * 0.35) || b.population - a.population || a.id - b.id); + // Prefectures should grow outward from municipalities that already look like + // future prefectural capitals. This keeps the generated prefecture shape from + // starting at arbitrary peripheral municipalities. + for (const node of capitalLike) tryAdd(node, false); + for (const node of capitalLike) tryAdd(node, true); + if (!seeds.length) { + const first = active.slice().sort((a, b) => (b.population * 1.8 + b.area * 0.08) - (a.population * 1.8 + a.area * 0.08) || a.id - b.id)[0]; + if (first) seeds.push(first); + } + while (seeds.length < targetCount) { + let best = null, bestScore = -INF; + for (const node of active) { + if (seeds.includes(node)) continue; + const nearest = Math.min(...seeds.map((s) => Math.hypot(node.x - s.x, node.y - s.y))); + const capitalBonus = Math.sqrt(Math.max(0, node.cityPopulation || 0)) * 0.05 + (node.majorCityCount || 0) * 8; + const livingCoreBonus = (node.centrality || 0) * 9.5 + (node.accessibility || 0) * 4.0 + (node.habitability || 0) * 2.0 - (node.geographicBarrier || 0) * 3.8; + const score = nearest * (1.0 + hash2(seed, node.id) * 0.08) + Math.sqrt(node.area) * 0.12 + node.population * 0.24 + capitalBonus + livingCoreBonus; + if (score > bestScore) { bestScore = score; best = node; } + } + if (!best) break; + seeds.push(best); + } + seeds.minMunicipalitiesPerPrefecture = minMunicipalitiesPerPrefecture; + return seeds; +} + + +export function chooseSecondStagePrefectureMunicipalitySeeds(nodes, initialOwner, initialSeeds, context = {}) { + const { seed = 0, allowOffscreenCapitals = true } = context; + const prefIds = [...new Set(initialOwner.values())].sort((a, b) => a - b); + const seeds = []; + const usedNodeIds = new Set(); + const offscreenPrefectureSeeds = []; + function scoreNode(node, prefId) { + if (!node) return -INF; + const populationCore = (node.cityPopulation || 0) * 1.55 + (node.settlementPopulation || 0) * 0.42 + (node.population || 0) * 900; + const civicCore = (node.majorCityCount || 0) * 420000 + (node.centrality || 0) * 76000 + (node.accessibility || 0) * 52000 + (node.habitability || 0) * 18000; + const terrainPenalty = (node.geographicBarrier || 0) * 62000; + const edgeBonus = allowOffscreenCapitals && node.touchesOutside ? 95000 : 0; + const jitter = hash2(seed + 331, node.id * 17 + prefId * 41) * 2500; + return populationCore + civicCore + edgeBonus - terrainPenalty + jitter; + } + for (const prefId of prefIds) { + const members = [...nodes.values()].filter((node) => initialOwner.get(node.id) === prefId); + if (!members.length) continue; + const insideCapitalCandidate = members + .filter((node) => (node.cityPopulation || 0) >= 45000 || (node.settlementPopulation || 0) >= 70000 || (node.majorCityCount || 0) > 0) + .sort((a, b) => scoreNode(b, prefId) - scoreNode(a, prefId))[0] || null; + const edgeCandidate = allowOffscreenCapitals + ? members + .filter((node) => node.touchesOutside && node.area >= 8) + .sort((a, b) => scoreNode(b, prefId) - scoreNode(a, prefId))[0] || null + : null; + const useOffscreen = edgeCandidate && (!insideCapitalCandidate || scoreNode(edgeCandidate, prefId) > scoreNode(insideCapitalCandidate, prefId) + 35000); + const chosen = useOffscreen ? edgeCandidate : (insideCapitalCandidate || edgeCandidate || members.sort((a, b) => scoreNode(b, prefId) - scoreNode(a, prefId))[0]); + if (chosen && !usedNodeIds.has(chosen.id)) { + seeds.push(chosen); + usedNodeIds.add(chosen.id); + if (useOffscreen) offscreenPrefectureSeeds.push({ prefId, nodeId: chosen.id, x: Math.round(chosen.x), y: Math.round(chosen.y) }); + } + } + if (!seeds.length) seeds.push(...(initialSeeds || []).filter(Boolean)); + seeds.minMunicipalitiesPerPrefecture = initialSeeds?.minMunicipalitiesPerPrefecture || 14; + seeds.offscreenPrefectureSeeds = offscreenPrefectureSeeds; + seeds.secondStage = true; + return seeds; +} + +export function assignMunicipalitiesToPrefectures(nodes, seeds) { + const owner = new Map(); + const area = new Map(); + const heap = new MinHeap(); + seeds.forEach((node, id) => { + owner.set(node.id, id); + area.set(id, node.area); + heap.push({ i: node.id, id, f: 0 }); + }); + const totalArea = [...nodes.values()].reduce((sum, node) => sum + node.area, 0); + const maxArea = Math.max(900, totalArea * 0.30); + while (heap.length) { + const cur = heap.pop(); + if (!cur || owner.get(cur.i) !== cur.id) continue; + const node = nodes.get(cur.i); + if (!node) continue; + for (const [nextId, edge] of node.adjacent) { + if (owner.has(nextId)) continue; + const next = nodes.get(nextId); + if (!next) continue; + const areaPressure = Math.max(0, ((area.get(cur.id) || 0) + next.area - maxArea) / Math.max(1, maxArea)); + const cost = cur.f + (edge.crossingCost ?? (1.0 + edge.barrier * 8.5)) + areaPressure * 14 + hash2(cur.id, nextId) * 0.05; + owner.set(nextId, cur.id); + area.set(cur.id, (area.get(cur.id) || 0) + next.area); + heap.push({ i: nextId, id: cur.id, f: cost }); + } + } + let fallback = 0; + for (const id of [...nodes.keys()].sort((a, b) => a - b)) { + if (!owner.has(id)) owner.set(id, fallback++ % Math.max(1, seeds.length)); + } + return owner; +} + + +export function chooseSecondStagePrefectureSeeds(nodes, firstOwner, originalSeeds = [], seed = 0) { + const prefIds = [...new Set(firstOwner.values())].filter((id) => id >= 0).sort((a, b) => a - b); + const used = new Set(); + const seeds = []; + const offscreenCapitalPrefectures = []; + for (const prefId of prefIds) { + const members = [...nodes.values()].filter((node) => firstOwner.get(node.id) === prefId && node.area > 0); + if (!members.length) continue; + const membersSortedByCapital = members.slice().sort((a, b) => { + const as = (a.cityPopulation || 0) * 1.45 + (a.settlementPopulation || 0) * 0.36 + (a.population || 0) * 900 + (a.centrality || 0) * 5200 + (a.accessibility || 0) * 2600 + Math.sqrt(a.area || 1) * 45; + const bs = (b.cityPopulation || 0) * 1.45 + (b.settlementPopulation || 0) * 0.36 + (b.population || 0) * 900 + (b.centrality || 0) * 5200 + (b.accessibility || 0) * 2600 + Math.sqrt(b.area || 1) * 45; + return bs - as || a.id - b.id; + }); + const capitalCandidate = membersSortedByCapital.find((node) => !used.has(node.id) && ((node.cityPopulation || 0) >= 90000 || (node.settlementPopulation || 0) >= 130000 || (node.population || 0) >= 15)); + let chosen = capitalCandidate; + if (!chosen) { + const edgeCandidate = members + .filter((node) => !used.has(node.id) && node.touchesOutside) + .sort((a, b) => { + const as = Math.sqrt(a.area || 1) * 0.7 + (a.habitability || 0) * 8 + (a.accessibility || 0) * 6 - (a.geographicBarrier || 0) * 4 + hash2(seed + 7100, a.id) * 0.2; + const bs = Math.sqrt(b.area || 1) * 0.7 + (b.habitability || 0) * 8 + (b.accessibility || 0) * 6 - (b.geographicBarrier || 0) * 4 + hash2(seed + 7100, b.id) * 0.2; + return bs - as || a.id - b.id; + })[0]; + if (edgeCandidate) { + chosen = edgeCandidate; + offscreenCapitalPrefectures.push(prefId); + } + } + if (!chosen) chosen = membersSortedByCapital.find((node) => !used.has(node.id)) || membersSortedByCapital[0]; + if (chosen) { + used.add(chosen.id); + seeds.push(chosen); + } + } + // If merges removed too many first-stage regions, preserve count by adding high-score unused capital-like nodes. + for (const node of [...nodes.values()].sort((a, b) => ((b.cityPopulation || 0) + (b.settlementPopulation || 0) * 0.25 + b.area * 5) - ((a.cityPopulation || 0) + (a.settlementPopulation || 0) * 0.25 + a.area * 5) || a.id - b.id)) { + if (seeds.length >= Math.max(1, originalSeeds.length || seeds.length)) break; + if (used.has(node.id)) continue; + used.add(node.id); + seeds.push(node); + } + seeds.minMunicipalitiesPerPrefecture = originalSeeds.minMunicipalitiesPerPrefecture || 14; + seeds.offscreenCapitalPrefectures = offscreenCapitalPrefectures; + return seeds; +} + +export function repairPrefectureMunicipalityConnectivity(nodes, owner) { + let changed = 0; + for (let pass = 0; pass < 8; pass++) { + let passChanged = 0; + const prefIds = [...new Set(owner.values())].sort((a, b) => a - b); + for (const prefId of prefIds) { + const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId); + const memberSet = new Set(members); + const seen = new Set(); + const components = []; + for (const start of members) { + if (seen.has(start)) continue; + const queue = [start]; + const comp = []; + seen.add(start); + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + comp.push(cur); + for (const next of nodes.get(cur)?.adjacent.keys() || []) { + if (!memberSet.has(next) || seen.has(next)) continue; + seen.add(next); + queue.push(next); + } + } + components.push(comp); + } + if (components.length <= 1) continue; + components.sort((a, b) => b.length - a.length); + for (const comp of components.slice(1)) { + const neighborCounts = new Map(); + for (const id of comp) { + for (const next of nodes.get(id)?.adjacent.keys() || []) { + const nOwner = owner.get(next); + if (nOwner !== prefId) neighborCounts.set(nOwner, (neighborCounts.get(nOwner) || 0) + 1); + } + } + let best = -1, bestCount = -1; + for (const [id, count] of neighborCounts) if (count > bestCount || (count === bestCount && id < best)) { best = id; bestCount = count; } + if (best < 0) continue; + for (const id of comp) owner.set(id, best); + passChanged += comp.length; + } + } + changed += passChanged; + if (!passChanged) break; + } + return changed; +} + +export function repairPrefectureMunicipalityEnclaves(nodes, owner, maxPasses = 6) { + let changed = 0; + for (let pass = 0; pass < maxPasses; pass++) { + let passChanged = 0; + const prefIds = [...new Set(owner.values())].filter((id) => id >= 0).sort((a, b) => a - b); + for (const prefId of prefIds) { + const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId); + const memberSet = new Set(members); + const seen = new Set(); + for (const start of members) { + if (seen.has(start)) continue; + const queue = [start]; + const comp = []; + seen.add(start); + let touchesOutside = false; + const boundaryPrefs = new Map(); + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + comp.push(cur); + const node = nodes.get(cur); + if (node?.touchesOutside) touchesOutside = true; + for (const next of node?.adjacent.keys() || []) { + const nextOwner = owner.get(next); + if (nextOwner === prefId) { + if (!seen.has(next)) { seen.add(next); queue.push(next); } + } else if (nextOwner >= 0) { + boundaryPrefs.set(nextOwner, (boundaryPrefs.get(nextOwner) || 0) + 1); + } + } + } + if (touchesOutside || boundaryPrefs.size !== 1) continue; + const [targetPref] = boundaryPrefs.keys(); + if (targetPref < 0 || targetPref === prefId) continue; + for (const id of comp) owner.set(id, targetPref); + passChanged += comp.length; + } + } + changed += passChanged; + if (!passChanged) break; + } + return changed; +} + +export function lockCompactUrbanMunicipalitiesToSinglePrefecture(owner, adminId, context, maxCells = 2600) { + const { prefectureMask, sea, landuse, populationDensity } = context; + if (!owner || !adminId || !landuse) return 0; + const seen = new Uint8Array(SIZE); + const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; + const isUrban = (i) => { + const lu = landuse[i]; + return lu === 2 || lu === 3 || lu === 4 || lu === 7 || lu === 8 || (populationDensity?.[i] || 0) > 0.18; + }; + let changedMunicipalities = 0; + for (let start = 0; start < SIZE; start++) { + if (seen[start] || !prefectureMask[start] || sea[start] || adminId[start] < 0 || !isUrban(start)) continue; + const queue = [start]; + const comp = []; + seen[start] = 1; + const municipalityWeights = new Map(); + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + comp.push(cur); + const id = adminId[cur]; + const weight = 1 + Math.max(0, (populationDensity?.[cur] || 0) - 0.12) * 2.4 + (landuse[cur] === 3 ? 2.0 : landuse[cur] === 2 ? 1.2 : 0); + municipalityWeights.set(id, (municipalityWeights.get(id) || 0) + weight); + const [x, y] = xyOf(cur); + for (const [dx, dy] of dirs) { + const nx = x + dx, ny = y + dy; + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (seen[ni] || !prefectureMask[ni] || sea[ni] || adminId[ni] < 0 || !isUrban(ni)) continue; + seen[ni] = 1; + queue.push(ni); + } + } + if (comp.length < 14 || comp.length > maxCells || municipalityWeights.size <= 1) continue; + const prefectureWeights = new Map(); + for (const [munId, weight] of municipalityWeights) { + const prefId = owner.get(munId); + if (prefId < 0) continue; + prefectureWeights.set(prefId, (prefectureWeights.get(prefId) || 0) + weight); + } + if (prefectureWeights.size <= 1) continue; + let bestPref = -1, bestWeight = -INF, totalWeight = 0; + for (const [prefId, weight] of prefectureWeights) { + totalWeight += weight; + if (weight > bestWeight || (weight === bestWeight && prefId < bestPref)) { bestPref = prefId; bestWeight = weight; } + } + if (bestPref < 0 || bestWeight / Math.max(1, totalWeight) < 0.34) continue; + for (const munId of municipalityWeights.keys()) { + if (owner.get(munId) === bestPref) continue; + owner.set(munId, bestPref); + changedMunicipalities++; + } + } + return changedMunicipalities; +} + +export function lockCityMetroMunicipalitiesToSinglePrefecture(owner, adminId, context) { + const { prefectureMask, sea, landuse, populationDensity, modernCities = [] } = context; + if (!owner || !adminId || !modernCities?.length) return 0; + let changed = 0; + const isUrban = (i) => { + const lu = landuse?.[i]; + return lu === 2 || lu === 3 || lu === 4 || lu === 7 || lu === 8 || (populationDensity?.[i] || 0) > 0.14; + }; + for (const city of modernCities) { + if (!city || (city.population || 0) < 24000 || !inside(city.x, city.y)) continue; + const centerAdmin = adminId[indexOf(city.x, city.y)]; + if (centerAdmin < 0) continue; + const centerPref = owner.get(centerAdmin); + if (centerPref < 0) continue; + const radius = clamp(Math.round((city.urbanRadius || 8) * ((city.population || 0) >= 160000 ? 1.55 : 1.25)), 7, 26); + const municipalityWeights = new Map(); + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const x = city.x + dx; + const y = city.y + dy; + if (!inside(x, y) || Math.hypot(dx, dy) > radius) continue; + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i] || adminId[i] < 0 || !isUrban(i)) continue; + const dist = Math.hypot(dx, dy) / Math.max(1, radius); + const weight = (1 - dist * 0.55) * (1 + (populationDensity?.[i] || 0) * 2.6 + (landuse?.[i] === 3 ? 1.6 : 0)); + municipalityWeights.set(adminId[i], (municipalityWeights.get(adminId[i]) || 0) + weight); + } + } + if (municipalityWeights.size <= 1) continue; + let total = 0, centerOwnedWeight = 0; + for (const [munId, weight] of municipalityWeights) { + total += weight; + if (owner.get(munId) === centerPref) centerOwnedWeight += weight; + } + // Only force compact city regions. If the center prefecture has almost no + // share, this is probably a genuine cross-prefecture conurbation or a city + // center on the edge; leave it to the graph repair. + if (centerOwnedWeight / Math.max(1, total) < 0.24) continue; + for (const munId of municipalityWeights.keys()) { + if (owner.get(munId) === centerPref) continue; + owner.set(munId, centerPref); + changed++; + } + } + return changed; +} + + +export function lockLivingSphereMunicipalitiesToSinglePrefecture(owner, nodes, adminId, context) { + const { prefectureMask, sea, modernCities = [], markets = [], ports = [] } = context || {}; + if (!owner || !nodes || !adminId) return 0; + const hubs = [ + ...(modernCities || []).filter((p) => p && ((p.population || 0) >= 70000 || p.isPrefecturalCapital || p.isRegionalCapital)), + ...(markets || []).filter((p) => p && (p.population || 0) >= 26000), + ...(ports || []).filter((p) => p && (p.portClass === "major" || p.portClass === "regional")), + ]; + let changed = 0; + for (const hub of hubs) { + if (!hub || !inside(hub.x, hub.y)) continue; + const startCell = indexOf(hub.x, hub.y); + if (!prefectureMask[startCell] || sea[startCell]) continue; + const startAdmin = adminId[startCell]; + if (startAdmin < 0 || !nodes.has(startAdmin)) continue; + const targetPref = owner.get(startAdmin); + if (targetPref < 0) continue; + const population = hub.population || (hub.portClass === "major" ? 140000 : 42000); + const maxCost = (population >= 260000 || hub.isPrefecturalCapital) ? 34 : population >= 120000 ? 25 : 17; + const maxDistance = clamp(10 + Math.sqrt(population) / 42, 14, 38); + const heap = new MinHeap(); + const best = new Map([[startAdmin, 0]]); + heap.push({ i: startAdmin, f: 0 }); + const candidates = new Set([startAdmin]); + while (heap.length) { + const cur = heap.pop(); + if (!cur || cur.f > (best.get(cur.i) ?? INF) + 1e-5 || cur.f > maxCost) continue; + const node = nodes.get(cur.i); + if (!node) continue; + if (Math.hypot(node.x - hub.x, node.y - hub.y) <= maxDistance) candidates.add(cur.i); + for (const [nextId, edge] of node.adjacent || []) { + const next = nodes.get(nextId); + if (!next) continue; + if (Math.hypot(next.x - hub.x, next.y - hub.y) > maxDistance * 1.25) continue; + if ((edge.barrier || 0) > 0.68 && (edge.adminBoundaryPreference || 0) > 0.44) continue; + const lifeContinuity = (edge.boundaryAvoidance || 0) * 1.7 + (edge.accessibility || 0) * 0.9 + (edge.centrality || 0) * 0.9; + const stepCost = Math.max(0.35, (edge.crossingCost ?? 1.5) - lifeContinuity); + const nd = cur.f + stepCost; + if (nd < (best.get(nextId) ?? INF)) { + best.set(nextId, nd); + heap.push({ i: nextId, f: nd }); + } + } + } + if (candidates.size <= 1) continue; + let totalWeight = 0; + for (const id of candidates) { + const node = nodes.get(id); + totalWeight += Math.max(1, (node?.centrality || 0) * 8 + (node?.accessibility || 0) * 5 + Math.sqrt(node?.area || 1) * 0.18); + } + if (totalWeight < 5.5) continue; + for (const id of candidates) { + if (id === startAdmin || owner.get(id) === targetPref) continue; + const node = nodes.get(id); + if (!node) continue; + if ((node.cityPopulation || 0) >= 160000 && Math.hypot(node.x - hub.x, node.y - hub.y) > 8) continue; + owner.set(id, targetPref); + changed++; + } + } + if (changed) repairPrefectureMunicipalityConnectivity(nodes, owner); + return changed; +} + +export function mergeTinyMunicipalityPrefectures(nodes, owner) { + let changed = 0; + for (let pass = 0; pass < 6; pass++) { + const areaByPref = new Map(); + for (const node of nodes.values()) { + const pref = owner.get(node.id); + areaByPref.set(pref, (areaByPref.get(pref) || 0) + node.area); + } + const totalArea = [...areaByPref.values()].reduce((sum, value) => sum + value, 0); + const minArea = Math.max(520, totalArea / Math.max(1, areaByPref.size) * 0.34); + const tiny = [...areaByPref.entries()] + .filter(([, area]) => area > 0 && area < minArea && areaByPref.size > 4) + .sort((a, b) => a[1] - b[1] || a[0] - b[0])[0]; + if (!tiny) break; + const [tinyPref] = tiny; + const neighborScores = new Map(); + for (const node of nodes.values()) { + if (owner.get(node.id) !== tinyPref) continue; + for (const [nextId, edge] of node.adjacent) { + const nextPref = owner.get(nextId); + if (nextPref === tinyPref || nextPref < 0) continue; + const score = (neighborScores.get(nextPref) || 0) + edge.count * 0.8 - (edge.crossingCost ?? (1.0 + edge.barrier * 8.5)) * 0.65; + neighborScores.set(nextPref, score); + } + } + let best = -1, bestScore = -INF; + for (const [pref, score] of neighborScores) { + if (score > bestScore || (score === bestScore && pref < best)) { best = pref; bestScore = score; } + } + if (best < 0) break; + for (const node of nodes.values()) if (owner.get(node.id) === tinyPref) { owner.set(node.id, best); changed++; } + } + return changed; +} + + +export function extractPrefectureBordersFromMunicipalities(adminId, municipalityToPrefectureId, prefectureMask, sea) { + const segments = []; + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; + const aPref = municipalityToPrefectureId[adminId[i]] ?? -1; + if (x + 1 < MAP_W) { + const ni = indexOf(x + 1, y); + const bPref = !sea[ni] && adminId[ni] >= 0 ? municipalityToPrefectureId[adminId[ni]] ?? -1 : -1; + if (prefectureMask[ni] && bPref >= 0 && aPref >= 0 && bPref !== aPref && adminId[ni] !== adminId[i]) segments.push([[x + 1, y], [x + 1, y + 1]]); + } + if (y + 1 < MAP_H) { + const ni = indexOf(x, y + 1); + const bPref = !sea[ni] && adminId[ni] >= 0 ? municipalityToPrefectureId[adminId[ni]] ?? -1 : -1; + if (prefectureMask[ni] && bPref >= 0 && aPref >= 0 && bPref !== aPref && adminId[ni] !== adminId[i]) segments.push([[x, y + 1], [x + 1, y + 1]]); + } + } + } + return segments; +} + + +export function prefectureMunicipalityCounts(owner) { + const counts = new Map(); + for (const pref of owner.values()) counts.set(pref, (counts.get(pref) || 0) + 1); + return counts; +} + +export function ownerMembersByPref(owner) { + const by = new Map(); + for (const [id, pref] of owner) { + if (!by.has(pref)) by.set(pref, []); + by.get(pref).push(id); + } + return by; +} + +export function wouldRemainConnectedAfterRemoval(nodes, owner, adminId, prefId) { + const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId && id !== adminId); + if (members.length <= 1) return true; + const memberSet = new Set(members); + const seen = new Set([members[0]]); + const queue = [members[0]]; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + for (const next of nodes.get(cur)?.adjacent.keys() || []) { + if (!memberSet.has(next) || seen.has(next)) continue; + seen.add(next); + queue.push(next); + } + } + return seen.size === members.length; +} + +export function rebalanceSmallPrefecturesByMunicipalityCount(nodes, owner, minCount = 14, maxPasses = 96) { + let changed = 0; + for (let pass = 0; pass < maxPasses; pass++) { + const counts = prefectureMunicipalityCounts(owner); + const small = [...counts.entries()].filter(([, count]) => count > 0 && count < minCount).sort((a, b) => a[1] - b[1] || a[0] - b[0])[0]; + if (!small) break; + const [smallPref, smallCount] = small; + let best = null, bestScore = -INF; + for (const [id, pref] of owner) { + if (pref === smallPref) continue; + const donorCount = counts.get(pref) || 0; + if (donorCount <= minCount + 1) continue; + const node = nodes.get(id); + if (!node) continue; + let edgeToSmall = null; + for (const [nextId, edge] of node.adjacent || []) { + if (owner.get(nextId) === smallPref) { + edgeToSmall = edge; + break; + } + } + if (!edgeToSmall) continue; + if (!wouldRemainConnectedAfterRemoval(nodes, owner, id, pref)) continue; + const capitalPenalty = (node.cityPopulation || 0) >= 150000 ? 18 : 0; + const donorSurplus = donorCount - minCount; + const score = (edgeToSmall.count || 1) * 2.5 - (edgeToSmall.crossingCost ?? (1.0 + (edgeToSmall.barrier || 0) * 8.5)) * 1.15 + donorSurplus * 1.6 - Math.sqrt(node.area || 1) * 0.03 - capitalPenalty; + if (score > bestScore || (score === bestScore && id < best?.id)) best = { id, pref, score }; + } + if (!best) break; + owner.set(best.id, smallPref); + changed++; + repairPrefectureMunicipalityConnectivity(nodes, owner); + } + return changed; +} + +export function mergePersistentlyTinyPrefecturesByCount(nodes, owner, minCount = 12, minRemainingPrefectures = 2) { + let changed = 0; + for (let pass = 0; pass < 16; pass++) { + const counts = prefectureMunicipalityCounts(owner); + if (counts.size <= minRemainingPrefectures) break; + const tiny = [...counts.entries()].filter(([, count]) => count > 0 && count < minCount).sort((a, b) => a[1] - b[1] || a[0] - b[0])[0]; + if (!tiny) break; + const [tinyPref] = tiny; + const neighborScores = new Map(); + for (const [id, pref] of owner) { + if (pref !== tinyPref) continue; + const node = nodes.get(id); + for (const [nextId, edge] of node?.adjacent || []) { + const other = owner.get(nextId); + if (other === undefined || other === tinyPref) continue; + const score = (edge.count || 1) * 2.4 - (edge.crossingCost ?? (1.0 + (edge.barrier || 0) * 8.5)) * 1.0 + Math.min(18, counts.get(other) || 0) * 0.20; + neighborScores.set(other, (neighborScores.get(other) || 0) + score); + } + } + let best = -1, bestScore = -INF; + for (const [candidate, score] of neighborScores) { + if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; } + } + if (best < 0) { + const tinyNodes = [...owner.keys()].filter((id) => owner.get(id) === tinyPref).map((id) => nodes.get(id)).filter(Boolean); + const tx = tinyNodes.reduce((sum, node) => sum + node.x * node.area, 0) / Math.max(1, tinyNodes.reduce((sum, node) => sum + node.area, 0)); + const ty = tinyNodes.reduce((sum, node) => sum + node.y * node.area, 0) / Math.max(1, tinyNodes.reduce((sum, node) => sum + node.area, 0)); + let bestDist = INF; + for (const [candidate, count] of counts) { + if (candidate === tinyPref || count <= 0) continue; + const candidateNodes = [...owner.keys()].filter((id) => owner.get(id) === candidate).map((id) => nodes.get(id)).filter(Boolean); + for (const node of candidateNodes) { + const d = Math.hypot(node.x - tx, node.y - ty); + if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; } + } + } + } + if (best < 0) break; + for (const [id, pref] of owner) if (pref === tinyPref) { owner.set(id, best); changed++; } + repairPrefectureMunicipalityConnectivity(nodes, owner); + } + // Renumber compactly so labels/debug do not expose deleted prefecture IDs. + const active = [...new Set(owner.values())].sort((a, b) => a - b); + const remap = new Map(active.map((id, n) => [id, n])); + for (const [id, pref] of owner) owner.set(id, remap.get(pref)); + return changed; +} + + +export function splitOversizedPrefecturesByMunicipalityCount(nodes, owner, maxCount = 88, maxPrefectures = 8) { + let changed = 0; + for (let pass = 0; pass < 10; pass++) { + const counts = prefectureMunicipalityCounts(owner); + const oversized = [...counts.entries()].filter(([, count]) => count > maxCount).sort((a, b) => b[1] - a[1] || a[0] - b[0])[0]; + if (!oversized || counts.size >= maxPrefectures) break; + const [prefId, count] = oversized; + const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId); + if (members.length <= maxCount) break; + let sx = 0, sy = 0, area = 0; + for (const id of members) { + const node = nodes.get(id); + if (!node) continue; + sx += node.x * Math.max(1, node.area || 1); + sy += node.y * Math.max(1, node.area || 1); + area += Math.max(1, node.area || 1); + } + const cx = sx / Math.max(1, area); + const cy = sy / Math.max(1, area); + const seedNode = members + .map((id) => nodes.get(id)) + .filter(Boolean) + .sort((a, b) => Math.hypot(b.x - cx, b.y - cy) - Math.hypot(a.x - cx, a.y - cy) || a.id - b.id)[0]; + if (!seedNode) break; + const newPref = Math.max(-1, ...counts.keys()) + 1; + const target = Math.max(count - maxCount, Math.floor(count * 0.42)); + const queue = [seedNode.id]; + const picked = new Set([seedNode.id]); + for (let q = 0; q < queue.length && picked.size < target; q++) { + const cur = queue[q]; + const nexts = [...(nodes.get(cur)?.adjacent.keys() || [])] + .filter((id) => owner.get(id) === prefId && !picked.has(id)) + .map((id) => nodes.get(id)) + .filter(Boolean) + .sort((a, b) => Math.hypot(b.x - cx, b.y - cy) - Math.hypot(a.x - cx, a.y - cy) || a.id - b.id); + for (const next of nexts) { + picked.add(next.id); + queue.push(next.id); + if (picked.size >= target) break; + } + } + if (picked.size < Math.max(8, target * 0.55)) break; + for (const id of picked) owner.set(id, newPref); + changed += picked.size; + repairPrefectureMunicipalityConnectivity(nodes, owner); + } + return changed; +} + +function averageRegionalBorderField(adminId, municipalityToPrefectureId, prefectureMask, sea, field) { + if (!field) return 0; + let sum = 0; + let count = 0; + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; + const a = municipalityToPrefectureId[adminId[i]] ?? -1; + for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni] || adminId[ni] < 0) continue; + const b = municipalityToPrefectureId[adminId[ni]] ?? -1; + if (a < 0 || b < 0 || a === b) continue; + sum += ((field[i] || 0) + (field[ni] || 0)) * 0.5; + count++; + } + } + } + return count ? sum / count : 0; +} + +export function generatePrefecturesFromMunicipalities(context, adminResult) { + const { adminId } = adminResult; + const { prefectureMask, sea, naturalBarrierScore, populationDensity, landuse, modernCities, markets, ports, seed, geography = null, habitability = null, accessibility = null, centrality = null, adminBoundaryPreference = null, boundaryAvoidance = null, geographicBarrier = null } = context; + const geographyFields = geography || { habitability, accessibility, centrality, adminBoundaryPreference, boundaryAvoidance, geographicBarrier }; + const graph = buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity, [...(modernCities || []), ...(markets || []), ...(ports || [])], geographyFields); + const firstStageSeeds = choosePrefectureMunicipalitySeeds(graph.nodes, seed + 91001); + const firstStageOwner = assignMunicipalitiesToPrefectures(graph.nodes, firstStageSeeds); + repairPrefectureMunicipalityConnectivity(graph.nodes, firstStageOwner); + repairPrefectureMunicipalityEnclaves(graph.nodes, firstStageOwner, 4); + const secondStageSeeds = chooseSecondStagePrefectureSeeds(graph.nodes, firstStageOwner, firstStageSeeds, seed + 91077); + const seeds = secondStageSeeds.length ? secondStageSeeds : firstStageSeeds; + const owner = assignMunicipalitiesToPrefectures(graph.nodes, seeds); + const changedForTinyMerge = mergeTinyMunicipalityPrefectures(graph.nodes, owner); + let changedForMetroUnification = lockCompactUrbanMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity }, 2600); + changedForMetroUnification += lockCityMetroMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity, modernCities }); + const changedForLivingSphereUnification = lockLivingSphereMunicipalitiesToSinglePrefecture(owner, graph.nodes, adminId, { prefectureMask, sea, modernCities, markets, ports }); + let changedForConnectivity = repairPrefectureMunicipalityConnectivity(graph.nodes, owner); + let changedForEnclaveRepair = repairPrefectureMunicipalityEnclaves(graph.nodes, owner); + changedForMetroUnification += lockCompactUrbanMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity }, 2600); + changedForMetroUnification += lockCityMetroMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity, modernCities }); + const changedForPostRepairLivingSphereUnification = lockLivingSphereMunicipalitiesToSinglePrefecture(owner, graph.nodes, adminId, { prefectureMask, sea, modernCities, markets, ports }); + const changedForMunicipalityCountRebalance = rebalanceSmallPrefecturesByMunicipalityCount(graph.nodes, owner, seeds.minMunicipalitiesPerPrefecture || 14); + const changedForTinyPrefectureCountMerge = mergePersistentlyTinyPrefecturesByCount(graph.nodes, owner, Math.max(13, (seeds.minMunicipalitiesPerPrefecture || 14) - 1)); + const changedForPostMergeMunicipalityCountRebalance = rebalanceSmallPrefecturesByMunicipalityCount(graph.nodes, owner, Math.max(12, (seeds.minMunicipalitiesPerPrefecture || 14) - 1), 64); + const changedForOversizedPrefectureSplit = splitOversizedPrefecturesByMunicipalityCount(graph.nodes, owner, 88, 8); + changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner); + // Rebalancing and oversized splitting can create small municipality-level + // exclaves. Run enclave/connectivity repair as the final owner operation so + // rendered prefectures are contiguous unions of municipalities. + changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 10); + changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner); + changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 10); + changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner); + const maxAdminId = Math.max(-1, ...[...graph.nodes.keys()]); + const municipalityToPrefectureId = new Int16Array(maxAdminId + 1); + municipalityToPrefectureId.fill(-1); + for (const [admin, pref] of owner) municipalityToPrefectureId[admin] = pref; + const prefectureRegionId = new Int16Array(SIZE); + prefectureRegionId.fill(-1); + for (let i = 0; i < SIZE; i++) { + if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; + prefectureRegionId[i] = municipalityToPrefectureId[adminId[i]] ?? -1; + } + const regionalPrefectureBorders = extractPrefectureBordersFromMunicipalities(adminId, municipalityToPrefectureId, prefectureMask, sea); + const areaByPref = new Map(); + const popByPref = new Map(); + for (const node of graph.nodes.values()) { + const pref = owner.get(node.id); + areaByPref.set(pref, (areaByPref.get(pref) || 0) + node.area); + popByPref.set(pref, (popByPref.get(pref) || 0) + node.population); + } + const totalArea = [...areaByPref.values()].reduce((sum, value) => sum + value, 0); + return { + prefectureRegionId, + municipalityToPrefectureId, + regionalPrefectureBorders, + regionalDebug: { + prefecturesGeneratedAfterMunicipalities: true, + prefectureSource: "municipality-boundary-union", + municipalityGraphNodeCount: graph.nodes.size, + municipalityGraphEdgeCount: graph.edges.size, + prefectureMunicipalitySeedCount: seeds.length, + prefectureTwoStageReassignment: true, + prefectureFirstStageSeedCount: firstStageSeeds.length, + prefectureSecondStageSeedCount: secondStageSeeds.length, + prefectureSecondStageOffscreenCapitalSeedCount: secondStageSeeds.offscreenCapitalPrefectures?.length || 0, + prefectureSecondStageOffscreenCapitalPrefectures: secondStageSeeds.offscreenCapitalPrefectures || [], + prefectureTinyMergeChangedMunicipalities: changedForTinyMerge, + prefectureConnectivityRepairChangedMunicipalities: changedForConnectivity, + prefectureEnclaveRepairChangedMunicipalities: changedForEnclaveRepair, + prefectureUrbanMetroUnificationChangedMunicipalities: changedForMetroUnification, + prefectureLivingSphereUnificationChangedMunicipalities: (changedForLivingSphereUnification || 0) + (changedForPostRepairLivingSphereUnification || 0), + prefectureUnifiedGeographyBasis: true, + prefectureMunicipalityCountRebalanceChangedMunicipalities: (changedForMunicipalityCountRebalance || 0) + (changedForPostMergeMunicipalityCountRebalance || 0), + prefectureTinyMunicipalityCountMergeChangedMunicipalities: changedForTinyPrefectureCountMerge || 0, + prefectureOversizedCountSplitChangedMunicipalities: changedForOversizedPrefectureSplit || 0, + finalRegionalMaxMunicipalityCount: Math.max(...prefectureMunicipalityCounts(owner).values()), + finalRegionalMunicipalityCountCap: 88, + finalRegionalMinMunicipalityCount: Math.min(...prefectureMunicipalityCounts(owner).values()), + finalRegionalMaxAreaShare: totalArea ? Math.max(0, ...areaByPref.values()) / totalArea : 0, + finalRegionalTinyPrefectureCount: [...areaByPref.values()].filter((area) => area < 520).length, + finalRegionalPrefectureBorderCount: regionalPrefectureBorders.length, + regionalPrefectureBordersRebuiltFromFinalId: true, + finalRegionalBorderUnifiedBoundaryPreferenceAverage: averageRegionalBorderField(adminId, municipalityToPrefectureId, prefectureMask, sea, geographyFields.adminBoundaryPreference), + finalRegionalBorderUnifiedBoundaryAvoidanceAverage: averageRegionalBorderField(adminId, municipalityToPrefectureId, prefectureMask, sea, geographyFields.boundaryAvoidance), + finalRegionalBorderUnifiedCentralityAverage: averageRegionalBorderField(adminId, municipalityToPrefectureId, prefectureMask, sea, geographyFields.centrality), + }, + }; +} + + diff --git a/mapTerrain.js b/mapTerrain.js index 77b1fff..e0e4db5 100644 --- a/mapTerrain.js +++ b/mapTerrain.js @@ -213,7 +213,7 @@ const TERRAIN_TYPES = [ coastStyle: "inland_sea", mountainMode: "mixed", massifnessRange: [0.34, 0.62], - seaRatioRange: [0.20, 0.33], + seaRatioRange: [0.28, 0.43], twoSidedChance: 0.92, mountainOffsetRange: [0.22, 0.34], baseHeightRange: [0.46, 0.78], @@ -226,7 +226,7 @@ const TERRAIN_TYPES = [ lengthScale: 1.00, widthScale: 1.18, heightScale: 0.82, - coastStrength: 1.10, + coastStrength: 1.34, plainBiasRange: [0.26, 0.50], riverRichnessRange: [0.58, 0.96], bigRiverChanceRange: [0.18, 0.42], @@ -580,10 +580,12 @@ function computeCoastLower(px, py, template, seed) { let pressure = 0; if (template.coastStyle === "inland_sea") { - // 瀬戸内型だけは中央を横切る浅い内海を許す。出現率は地形タイプ側で管理する。 - const sideA = smoothstep((-axis + 0.25 + wave + bay) / 0.26); - const sideB = smoothstep((axis + 0.23 - wave + bay * 0.7) / 0.27); - const channel = smoothstep((0.060 - Math.abs(cross + wave * 0.65 + islandNoise)) / 0.090) * 0.82; + // 瀬戸内型は旧来の大きな内海+両岸海岸線に戻す。 + // 海面比率はテンプレート側で高めに保ち、微細な島ノイズではなく + // 連続した水道形状で海を増やす。 + const sideA = smoothstep((-axis + 0.28 + wave + bay) / 0.28); + const sideB = smoothstep((axis + 0.26 - wave + bay * 0.7) / 0.29); + const channel = smoothstep((0.082 - Math.abs(cross + wave * 0.68 + islandNoise * 0.55)) / 0.112) * 0.98; pressure = Math.max(sideA, sideB, channel); } else if (template.coastStyle === "parallel_spine") { // 東北型: 左右端または上下端に海を置く。海岸線は脊梁山脈とおおよそ平行。 @@ -738,6 +740,81 @@ function computeFlowAccumulation(sea, flowTo, filled, flowAccum) { return area; } +function buildWatershedId(sea, flowTo, flowAccum) { + const outletKey = new Int32Array(SIZE); + outletKey.fill(-1); + const ids = new Int32Array(SIZE); + ids.fill(-1); + const outletToId = new Map(); + const quant = 12; + + function keyForOutlet(i) { + if (i < 0) return -1; + const x = i % MAP_W; + const y = Math.floor(i / MAP_W); + const qx = Math.floor(x / quant); + const qy = Math.floor(y / quant); + return qy * 1000 + qx; + } + + function resolve(start) { + if (start < 0 || sea[start]) return -1; + if (outletKey[start] >= 0) return outletKey[start]; + const chain = []; + const seen = new Set(); + let i = start; + let key = -1; + for (let guard = 0; guard < SIZE && i >= 0; guard++) { + if (sea[i]) { key = keyForOutlet(i); break; } + if (outletKey[i] >= 0) { key = outletKey[i]; break; } + if (seen.has(i)) { key = keyForOutlet(i); break; } + seen.add(i); + chain.push(i); + const to = flowTo[i]; + if (to < 0 || to === i) { key = keyForOutlet(i); break; } + // Major channels should be a watershed's spine, not a sequence of tiny + // drainage labels. Continue to the coast/outlet even after hitting them. + i = to; + } + if (key < 0 && chain.length) key = keyForOutlet(chain[chain.length - 1]); + for (const ci of chain) outletKey[ci] = key; + return key; + } + + for (let i = 0; i < SIZE; i++) { + if (sea[i]) continue; + const key = resolve(i); + if (key < 0) continue; + if (!outletToId.has(key)) outletToId.set(key, outletToId.size); + ids[i] = outletToId.get(key); + } + + // Very small coastal outlet labels create noisy slivers. Merge them into the + // strongest neighbouring watershed so natural compartments remain basin-scale. + const counts = new Int32Array(outletToId.size || 1); + for (let i = 0; i < SIZE; i++) if (ids[i] >= 0) counts[ids[i]]++; + const minArea = 18; + for (let pass = 0; pass < 2; pass++) { + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + const id = ids[i]; + if (id < 0 || counts[id] >= minArea) continue; + const choices = new Map(); + for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) { + if (!inside(nx, ny)) continue; + const nid = ids[indexOf(nx, ny)]; + if (nid >= 0 && nid !== id) choices.set(nid, (choices.get(nid) || 0) + 1 + (flowAccum[indexOf(nx, ny)] || 0)); + } + let best = -1, bestScore = -1; + for (const [nid, score] of choices) if (score > bestScore) { bestScore = score; best = nid; } + if (best >= 0) { counts[id]--; counts[best]++; ids[i] = best; } + } + } + } + return ids; +} + function traceFlowPath(start, sea, flowTo, maxSteps = 900) { const path = []; const seen = new Set(); @@ -1129,6 +1206,9 @@ export function generateTerrainAndRivers(seed) { const lowHillNoise = clamp((fbm(x * 0.95 + 17, y * 0.95 - 23, seed + 571) - 0.38) * 2.9); const lowHillMask = clamp(0.34 + mountainMaskMax * 0.72 + lowHillNoise * 0.50 - coastPressure * 0.12); e += lowHillMask * 0.145; + // Do not add the previous fine speckle uplift here: it created too many + // tiny islets. Sea amount is controlled by seaRatio/coast pressure. + e -= clamp((coastPressure - 0.42) * 1.35) * 0.026; const high = Math.max(0, e - 0.62); e -= high * 0.42; } @@ -1150,6 +1230,7 @@ export function generateTerrainAndRivers(seed) { const filled = new Float32Array(SIZE); priorityFloodFlow(elevation, sea, flowTo, filled); computeFlowAccumulation(sea, flowTo, filled, flowAccum); + const watershedId = buildWatershedId(sea, flowTo, flowAccum); const { riverPaths, mainRivers, tributaryRivers, smallStreams } = buildRiverNetwork(seed, terrainTemplate, sea, lake, elevation, slope, flowTo, flowAccum, river, erosionField); enforceLandGradient(elevation, sea, seaLevel); deriveFields(seed, terrainTemplate, fields, seaLevel); @@ -1162,7 +1243,7 @@ export function generateTerrainAndRivers(seed) { const natural = buildNaturalCompartments( landMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, zeroDensity, zeroLanduse, - { seed: seed + 17003, targetCompartmentCount: clamp(Math.round((SIZE - sea.reduce((sum, value) => sum + value, 0)) / 45), 70, 360) } + { seed: seed + 17003, watershedId, targetCompartmentCount: clamp(Math.round((SIZE - sea.reduce((sum, value) => sum + value, 0)) / 45), 70, 360) } ); const sharedNaturalBarrierScore = natural.naturalBarrierScore || naturalBarrierScore; const prefectureBorder = extractMaskBorder(prefectureMask, sea); @@ -1221,6 +1302,7 @@ export function generateTerrainAndRivers(seed) { basinField, coastalLowland, flowAccum, + watershedId, erosionField, depositionField, arcSpineField, diff --git a/mapTransport.js b/mapTransport.js index b218cb4..068224d 100644 --- a/mapTransport.js +++ b/mapTransport.js @@ -1,99 +1,2557 @@ -import { SIZE, clamp, indexOf, inside } from "./mapUtils.js"; +import { INF, MAP_H, MAP_W, SIZE, clamp, hash2, indexOf, inside, pickEntities, xyOf } from "./mapUtils.js"; +import { distanceToNearest, influenceFromPaths, samplePath } from "./mapGeneratorHelpers.js"; +import { + assessExpresswayRoute, + assessMountainRoute, + countReason, + fieldBackbonePolicy, + makeSpatialIndex, + makeUnionFind, + packDebugField, + pathAverageField, + pathLengthCells, + squaredDistance, + TRANSPORT_ROUTE_POLICIES, +} from "./mapTransportUtils.js"; +export { + createPathInfluenceCache, + packDebugField, + pathAverageField, + pathLengthCells, + pathSetSignature, + routeQualityAcceptable, + routeQualityStats, + TRANSPORT_ROUTE_POLICIES, +} from "./mapTransportUtils.js"; -export function pathSetSignature(paths) { - let cells = 0; - let endpoints = 0; - for (const path of paths || []) { - cells += path?.length || 0; - const a = path?.[0]; - const b = path?.[path.length - 1]; - if (a) endpoints = (endpoints + a[0] * 17 + a[1] * 31) | 0; - if (b) endpoints = (endpoints + b[0] * 47 + b[1] * 73) | 0; - } - return `${paths?.length || 0}:${cells}:${endpoints >>> 0}`; -} +export function buildDensityFlowRoadTransportSystem(ctx) { + const { + seed, + sea, elevation, slope, ridgeField, valleyField, coastalLowland, naturalBarrierScore, + agriculture, basinField, plain, passSuitability, crossingSuitability, + settlementDemand, preliminaryVillageInfluence, preliminaryTownInfluence, + logisticsPreSuitability, urbanEdge, + transportFields, cachedInfluenceFromPaths, + nationalRoads, minorRoads, railways, externalRoads, externalRailways, + expressways, externalExpressways, icAccessRoads, interchanges, externalGateways, + modernCities, markets, villages, ports, commercialPorts, passes, regionStats, + regionIdAt, inFocusedPrefecture, importantNodesForRegion, dedupePointCandidates, + routeBetweenTrafficCandidates, traceCorridorByCost, addCorridorInfluencePenalty, + relaxRouteToTerrain, transportRouteAcceptable, repairTransportConnectivity, + repairDanglingTransportEndpoints, pruneDanglingTerminalSegments, pruneParallelSameMode, + } = ctx; -export function createPathInfluenceCache(influenceFromPaths) { - const cache = new Map(); - return (paths, radius, label = "paths") => { - const key = `${label}:${radius}:${pathSetSignature(paths)}`; - let grid = cache.get(key); - if (!grid) { - grid = influenceFromPaths(paths, radius); - cache.set(key, grid); + // --- OD-corridor road generation --------------------------------------- + // Roads are generated as corridors first; hierarchy-specific labels are + // assigned by corridor purpose. This replaces the older field-corridor + // national/expressway lines and avoids repeated endpoint correction passes. + + const majorCitiesForExpressway = modernCities.filter((c) => (c.population || 0) >= 45000); + const villageCentersForExpressway = villages.filter((v) => (v.population || 0) >= 400); + const cityCoreProtectionIndex = makeSpatialIndex( + majorCitiesForExpressway.map((c) => ({ ...c, protectedRadius: Math.max(5.5, (c.coreRadius || 4) + 3.2) })), + 18 + ); + const urbanCoreProtectionIndex = makeSpatialIndex( + modernCities.map((c) => ({ ...c, protectedRadius: Math.max(6.2, (c.coreRadius || 4) + 4.2) })), + 18 + ); + const villageCoreIndex = makeSpatialIndex(villageCentersForExpressway, 8); + const lineCostCache = new Map(); + + function approximateLineCost(a, b, costField) { + const fieldLabel = + costField === expresswayCorridorCost ? "expressway" : + costField === transportFields.national ? "national" : + costField === transportFields.local ? "local" : + null; + const orderedEndpoints = a.x < b.x || (a.x === b.x && a.y <= b.y) + ? `${a.x},${a.y}:${b.x},${b.y}` + : `${b.x},${b.y}:${a.x},${a.y}`; + const cacheKey = fieldLabel ? `${fieldLabel}:${orderedEndpoints}` : null; + if (cacheKey && lineCostCache.has(cacheKey)) return lineCostCache.get(cacheKey); + const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y))); + let sum = 0; + let n = 0; + for (let s = 0; s <= steps; s++) { + const t = s / steps; + const x = Math.round(a.x + (b.x - a.x) * t); + const y = Math.round(a.y + (b.y - a.y) * t); + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i] || costField[i] >= INF) { + if (cacheKey) lineCostCache.set(cacheKey, INF); + return INF; + } + sum += costField[i]; + n++; } - return grid; + const result = n ? sum / n : INF; + if (cacheKey) lineCostCache.set(cacheKey, result); + return result; + } + + function pathTerrainRisk(path) { + if (!path?.length) return 1; + let mountain = 0; + let boundary = 0; + let dense = 0; + let villageCore = 0; + let cityCore = 0; + let highAltitude = 0; + let n = 0; + for (const [x, y] of path) { + if (!inside(x, y)) continue; + const i = indexOf(x, y); + mountain += clamp((elevation[i] - 0.56) * 2.7 + slope[i] * 0.95 + ridgeField[i] * 0.85); + if (elevation[i] >= 0.70) highAltitude++; + const nb = naturalBarrierScore?.[i] || 0; + boundary += nb * nb; + dense += settlementDemand[i] > 0.70 ? 1 : 0; + villageCore += preliminaryVillageInfluence[i] > 0.46 ? 1 : 0; + cityCore += preliminaryTownInfluence[i] > 0.52 || settlementDemand[i] > 0.68 ? 1 : 0; + n++; + } + return n ? { + mountain: mountain / n, + boundary: boundary / n, + denseShare: dense / n, + villageCoreShare: villageCore / n, + cityCoreShare: cityCore / n, + highAltitudeShare: highAltitude / n, + } : { mountain: 1, boundary: 1, denseShare: 1, villageCoreShare: 1, cityCoreShare: 1, highAltitudeShare: 1 }; + } + + function highAltitudeRoadClosed(i) { + return elevation[i] >= 0.70; + } + + const expresswayCorridorCost = new Float32Array(SIZE); + for (let i = 0; i < SIZE; i++) { + if (sea[i] || highAltitudeRoadClosed(i) || transportFields.expressway[i] >= INF) { + expresswayCorridorCost[i] = INF; + } else { + const urbanCore = clamp(settlementDemand[i] * 0.80 + preliminaryTownInfluence[i] * 0.92 + preliminaryVillageInfluence[i] * 0.58); + const ruralSettlementCore = clamp(preliminaryVillageInfluence[i] * 1.15 + agriculture[i] * 0.20 - plain[i] * 0.16); + const boundary = naturalBarrierScore?.[i] || 0; + // Highways are through-corridors here, not urban expressways. Penalize + // CBD proxies and village cores strongly, while still allowing suburban + // edge cells selected by majorCitySuburbanAnchor(). + expresswayCorridorCost[i] = transportFields.expressway[i] + + urbanCore * 8.2 + + ruralSettlementCore * 6.3 + + boundary * boundary * 2.6 + + Math.max(0, elevation[i] - 0.58) * 5.4 + + ridgeField[i] * 2.1; + } + } + + const routePolicies = TRANSPORT_ROUTE_POLICIES; + + function mountainRouteAssessment(path, mode = "road") { + return assessMountainRoute(path, mode, pathTerrainRisk, routePolicies); + } + + function routeTooStraightAcrossMountains(path, mode = "road") { + return !mountainRouteAssessment(path, mode).ok; + } + + + function routeTooStraightMountainOnly(path) { + return !mountainRouteAssessment(path, "expresswayMountainOnly").ok; + } + + function expresswayProximityRisk(path) { + const result = { cityCoreHits: 0, villageHits: 0, minMajorCityDistance: Infinity, minVillageDistance: Infinity }; + if (!path?.length) return { ...result, cityCoreHits: 999, villageHits: 999, minMajorCityDistance: 0, minVillageDistance: 0 }; + for (const [x, y] of path) { + for (const c of cityCoreProtectionIndex.near(x, y, 42)) { + const d2 = squaredDistance(x, y, c.x, c.y); + const d = Math.sqrt(d2); + result.minMajorCityDistance = Math.min(result.minMajorCityDistance, d); + if (d2 < c.protectedRadius * c.protectedRadius) result.cityCoreHits++; + } + for (const v of villageCoreIndex.near(x, y, 4)) { + const d2 = squaredDistance(x, y, v.x, v.y); + const d = Math.sqrt(d2); + result.minVillageDistance = Math.min(result.minVillageDistance, d); + if (d2 < 1.65 * 1.65) result.villageHits++; + } + } + return result; + } + + function expresswayRouteAssessment(path, options = {}) { + return assessExpresswayRoute(path, options, pathTerrainRisk, expresswayProximityRisk, routePolicies); + } + + function expresswayRouteAcceptable(path, options = {}) { + return expresswayRouteAssessment(path, options).ok; + } + + function routeAcceptableForMode(path, mode, potentialField, penalty, limits = {}, expresswayOptions = {}) { + if (mode === "expressway") { + return expresswayRouteAssessment(path, expresswayOptions); + } + const ok = transportRouteAcceptable(path, mode, potentialField, penalty, limits); + return { ok, reason: ok ? "ok" : "transportQuality" }; + } + + function majorCitySuburbanAnchor(city) { + if (!city || !inside(city.x, city.y)) return null; + let best = null; + const inner = Math.max(10, Math.round((city.coreRadius || 4) + 7)); + const outer = Math.round(Math.max(inner + 7, Math.min(32, (city.urbanRadius || 12) * 1.85))); + for (let dy = -outer; dy <= outer; dy++) { + for (let dx = -outer; dx <= outer; dx++) { + const x = city.x + dx; + const y = city.y + dy; + if (!inside(x, y)) continue; + const d = Math.hypot(dx, dy); + if (d < inner || d > outer) continue; + const i = indexOf(x, y); + if (sea[i] || transportFields.expressway[i] >= INF) continue; + let protectedUrbanCore = false; + for (const other of urbanCoreProtectionIndex.near(x, y, 18)) { + if (squaredDistance(x, y, other.x, other.y) < other.protectedRadius * other.protectedRadius) { + protectedUrbanCore = true; + break; + } + } + if (protectedUrbanCore) continue; + let onVillageCore = false; + for (const v of villageCoreIndex.near(x, y, 4)) { + if (squaredDistance(x, y, v.x, v.y) < 2.3 * 2.3) { + onVillageCore = true; + break; + } + } + if (onVillageCore) continue; + const nb = naturalBarrierScore?.[i] || 0; + const suburbanBand = clamp(1 - Math.abs(d - (inner + outer) * 0.50) / Math.max(3, (outer - inner) * 0.52)); + const score = + transportFields.expresswayPotential[i] * 1.18 + + logisticsPreSuitability[i] * 0.68 + + urbanEdge[i] * 0.52 + + plain[i] * 0.22 + + basinField[i] * 0.14 + + suburbanBand * 0.38 - + settlementDemand[i] * 1.10 - + preliminaryTownInfluence[i] * 0.72 - + preliminaryVillageInfluence[i] * 0.96 - + slope[i] * 0.86 - + ridgeField[i] * 0.72 - + nb * nb * 1.05 + + hash2(x, y, seed + 18103 + city.x * 7 + city.y * 13) * 0.06; + if (!best || score > best.score) { + best = { x, y, score, city, regionId: regionIdAt(x, y), role: "major-city-suburb", population: city.population || 0 }; + } + } + } + return best; + } + + function dedupeAnchors(points, minDistance = 5) { + return dedupePointCandidates(points.filter((p) => p && inside(p.x, p.y) && !sea[indexOf(p.x, p.y)]), minDistance); + } + + function buildExpresswayODCorridors(debug) { + const majorSuburbs = dedupeAnchors( + modernCities + .filter((c) => (c.population || 0) >= 100000) + .map(majorCitySuburbanAnchor), + 10 + ); + debug.majorCitySuburbanAnchors = majorSuburbs.map((p) => ({ x: p.x, y: p.y, city: p.city?.name, population: p.population })); + + const majorCityRefs = majorSuburbs.filter(Boolean); + const externalRefs = externalGateways.map((g) => ({ ...g, score: 0.92, role: "external-gateway", population: 90000 })); + const portRefs = commercialPorts + .filter((p) => p.portClass === "major" || (p.population || 0) >= 18000) + .map((p) => ({ ...p, score: 0.78 + (p.portClass === "major" ? 0.30 : 0), role: "port-logistics", population: p.population || 45000 })); + const remoteRefs = modernCities + .filter((c) => (c.population || 0) >= 65000 && !majorSuburbs.some((m) => m.city === c)) + .map((c) => { + const nearestMajor = majorSuburbs.reduce((best, m) => { + const d = Math.hypot(m.x - c.x, m.y - c.y); + return !best || d < best.d ? { m, d } : best; + }, null); + const anchor = majorCitySuburbanAnchor(c) || { x: c.x, y: c.y, score: 0.3, city: c, population: c.population }; + return { ...anchor, role: "remote-city", score: (anchor.score || 0.3) + Math.min(1.0, (nearestMajor?.d || 0) / 95) * 0.55, remoteDistance: nearestMajor?.d || 0, population: c.population || 0 }; + }) + .filter((p) => p.remoteDistance >= 52) + .sort((a, b) => b.score - a.score) + .slice(0, 8); + + const nodes = dedupeAnchors([...majorCityRefs, ...portRefs, ...externalRefs, ...remoteRefs], 9); + const pairs = []; + for (let a = 0; a < nodes.length; a++) { + for (let b = a + 1; b < nodes.length; b++) { + const A = nodes[a]; + const B = nodes[b]; + const d = Math.hypot(A.x - B.x, A.y - B.y); + if (d < 48 || d > 176) continue; + const lineCost = approximateLineCost(A, B, expresswayCorridorCost); + if (!Number.isFinite(lineCost) || lineCost >= INF) continue; + const demand = Math.sqrt(Math.max(25000, A.population || 50000) * Math.max(25000, B.population || 50000)) / 100000; + const longDistanceNeed = clamp((d - 48) / 70); + const externalNeed = A.role === "external-gateway" || B.role === "external-gateway" ? 0.55 : 0; + const logisticsNeed = A.role === "port-logistics" || B.role === "port-logistics" ? 0.38 : 0; + const remoteNeed = A.role === "remote-city" || B.role === "remote-city" ? 0.42 : 0; + const score = (demand * 0.70 + longDistanceNeed * 0.90 + externalNeed + logisticsNeed + remoteNeed) / Math.max(0.9, lineCost) + hash2(A.x + B.x, A.y + B.y, seed + 18131) * 0.025; + pairs.push({ a: A, b: B, d, score }); + } + } + pairs.sort((x, y) => y.score - x.score); + + const penalty = new Float32Array(SIZE); + const degree = new Map(); + const maxCorridors = Math.min(6, Math.max(3, Math.ceil(majorSuburbs.length / 2.4))); + for (const pair of pairs) { + if (expressways.length >= maxCorridors) break; + const aid = `${pair.a.x},${pair.a.y}`; + const bid = `${pair.b.x},${pair.b.y}`; + if ((degree.get(aid) || 0) >= 2 || (degree.get(bid) || 0) >= 2) continue; + const path = routeBetweenTrafficCandidates(pair.a, pair.b, "expressway", expresswayCorridorCost, penalty, { + curvePenalty: 0.11, + penaltyStrength: 2.2, + terrainFlowBias: 0.10, + surfaceGrain: 0.006, + relaxRadius: 1, + relaxLineWeight: 0.24, + maxPathLength: pair.d * 2.25 + 42, + }); + const len = pathLengthCells(path); + if (len < 34 || len > pair.d * 2.25 + 48) continue; + if (routeTooStraightMountainOnly(path)) continue; + if (!expresswayRouteAcceptable(path)) continue; + expressways.push(path); + addCorridorInfluencePenalty(penalty, path, 24, 1.80); + degree.set(aid, (degree.get(aid) || 0) + 1); + degree.set(bid, (degree.get(bid) || 0) + 1); + debug.expresswayCorridors.push({ from: pair.a.role, to: pair.b.role, distance: Math.round(pair.d), length: Math.round(len), score: Math.round(pair.score * 100) / 100, path }); + } + + // Guarantee at least one expressway approach for every very large city. The + // path still uses suburban anchors and expresswayCorridorCost, so it should + // bypass the CBD and village cores instead of cutting through them. + const expressInfluence = cachedInfluenceFromPaths(expressways, 18, "expressway:major-city-coverage"); + const allCandidateTargets = dedupeAnchors([...majorCityRefs, ...portRefs, ...externalRefs, ...remoteRefs], 8); + for (const anchor of majorCityRefs.sort((a, b) => (b.population || 0) - (a.population || 0))) { + if ((anchor.population || 0) < 100000) continue; + const ai = indexOf(anchor.x, anchor.y); + if ((expressInfluence[ai] || 0) > 0.20) continue; + const options = allCandidateTargets + .filter((q) => q !== anchor) + .map((q) => ({ q, d: Math.hypot(q.x - anchor.x, q.y - anchor.y), c: approximateLineCost(anchor, q, expresswayCorridorCost) })) + .filter((e) => e.d >= 38 && e.d <= 170 && Number.isFinite(e.c) && e.c < INF) + .sort((a, b) => (a.d * a.c) - (b.d * b.c)); + for (const opt of options.slice(0, 8)) { + const path = routeBetweenTrafficCandidates(anchor, opt.q, "expressway", expresswayCorridorCost, penalty, { + curvePenalty: 0.11, + penaltyStrength: 2.6, + terrainFlowBias: 0.10, + surfaceGrain: 0.006, + relaxRadius: 1, + relaxLineWeight: 0.22, + maxPathLength: opt.d * 2.75 + 78, + }); + const len = pathLengthCells(path); + if (len >= 24 && len <= opt.d * 2.95 + 96 && !routeTooStraightMountainOnly(path) && expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) { + expressways.push(path); + addCorridorInfluencePenalty(penalty, path, 58, 8.80); + debug.expresswayCorridors.push({ from: "major-city-guarantee", city: anchor.city?.name, to: opt.q.role, distance: Math.round(opt.d), length: Math.round(len), score: 0, path }); + break; + } + } + const refreshed = cachedInfluenceFromPaths(expressways, 9, `expressway:coverage:${anchor.x},${anchor.y}`); + if ((refreshed[ai] || 0) <= 0.20) { + // Last resort: create a short suburban approach to the nearest low-cost + // through corridor cell, still outside the urban core. This avoids the + // pathological case where a large isolated city receives no motorway at all. + const fallbackTargets = []; + const searchR = 56; + for (let dy = -searchR; dy <= searchR; dy += 3) { + for (let dx = -searchR; dx <= searchR; dx += 3) { + const x = anchor.x + dx; + const y = anchor.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + const d = Math.hypot(dx, dy); + if (d < 20 || d > searchR || sea[i] || expresswayCorridorCost[i] >= INF) continue; + if (settlementDemand[i] > 0.44 || preliminaryTownInfluence[i] > 0.34 || preliminaryVillageInfluence[i] > 0.30) continue; + fallbackTargets.push({ x, y, d, role: "suburban-fallback", population: anchor.population, score: expresswayCorridorCost[i] + d * 0.018 }); + } + } + fallbackTargets.sort((a, b) => a.score - b.score); + const target = fallbackTargets[0]; + if (target) { + const path = routeBetweenTrafficCandidates(anchor, target, "expressway", expresswayCorridorCost, penalty, { + curvePenalty: 0.11, + penaltyStrength: 2.2, + terrainFlowBias: 0.10, + surfaceGrain: 0.006, + relaxRadius: 1, + relaxLineWeight: 0.16, + maxPathLength: target.d * 2.8 + 34, + }); + if (pathLengthCells(path) >= 12 && expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) { + expressways.push(path); + addCorridorInfluencePenalty(penalty, path, 10, 0.74); + debug.expresswayCorridors.push({ from: "major-city-fallback-approach", city: anchor.city?.name, to: target.role, distance: Math.round(target.d), length: Math.round(pathLengthCells(path)), score: 0, path }); + } + } + } + } + + // Final city-level coverage pass. The anchor-level influence check can miss + // paired urban centers whose suburban anchors were deduplicated into the + // neighboring city. Check distance from each major city center to the + // motorway layer, then connect its own suburban anchor to the nearest + // existing motorway cell or create a short outward suburban approach. + function expresswayCells() { + const cells = []; + for (const path of expressways) { + for (const [x, y] of path) if (inside(x, y) && !sea[indexOf(x, y)]) cells.push({ x, y, role: "existing-expressway", population: 0 }); + } + return cells; + } + function minDistanceToExpressways(x, y) { + let best = Infinity; + for (const c of expresswayCells()) best = Math.min(best, Math.hypot(c.x - x, c.y - y)); + return best; + } + for (const city of modernCities.filter((c) => (c.population || 0) >= 100000).sort((a, b) => (b.population || 0) - (a.population || 0))) { + const coverLimit = Math.max(25, (city.urbanRadius || 13) * 1.75); + if (minDistanceToExpressways(city.x, city.y) <= coverLimit) continue; + const anchor = majorCitySuburbanAnchor(city); + if (!anchor) continue; + let addedForCity = false; + const cells = expresswayCells() + .map((q) => ({ q, d: Math.hypot(q.x - anchor.x, q.y - anchor.y), c: approximateLineCost(anchor, q, expresswayCorridorCost) })) + .filter((e) => e.d >= 8 && e.d <= 105 && Number.isFinite(e.c) && e.c < INF) + .sort((a, b) => (a.d * a.c) - (b.d * b.c)); + for (const opt of cells.slice(0, 8)) { + const path = routeBetweenTrafficCandidates(anchor, opt.q, "expressway", expresswayCorridorCost, penalty, { + curvePenalty: 0.11, + penaltyStrength: 2.6, + terrainFlowBias: 0.10, + surfaceGrain: 0.006, + relaxRadius: 1, + relaxLineWeight: 0.18, + maxPathLength: opt.d * 2.85 + 42, + }); + const len = pathLengthCells(path); + if (len >= 8 && len <= opt.d * 3.0 + 58 && !routeTooStraightMountainOnly(path) && expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) { + expressways.push(path); + addCorridorInfluencePenalty(penalty, path, 10, 0.72); + debug.expresswayCorridors.push({ from: "major-city-center-coverage", city: city.name, to: opt.q.role, distance: Math.round(opt.d), length: Math.round(len), score: 0, path }); + addedForCity = true; + break; + } + } + if (!addedForCity) { + const searchR = 48; + const fallbackTargets = []; + for (let dy = -searchR; dy <= searchR; dy += 3) { + for (let dx = -searchR; dx <= searchR; dx += 3) { + const x = anchor.x + dx; + const y = anchor.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + const d = Math.hypot(dx, dy); + if (d < 16 || d > searchR || sea[i] || expresswayCorridorCost[i] >= INF) continue; + if (settlementDemand[i] > 0.46 || preliminaryTownInfluence[i] > 0.36 || preliminaryVillageInfluence[i] > 0.31) continue; + fallbackTargets.push({ x, y, d, role: "city-coverage-fallback", population: city.population, score: expresswayCorridorCost[i] + d * 0.016 }); + } + } + fallbackTargets.sort((a, b) => a.score - b.score); + for (const target of fallbackTargets.slice(0, 4)) { + const path = routeBetweenTrafficCandidates(anchor, target, "expressway", expresswayCorridorCost, penalty, { + curvePenalty: 0.11, + penaltyStrength: 2.2, + terrainFlowBias: 0.10, + surfaceGrain: 0.006, + relaxRadius: 1, + relaxLineWeight: 0.16, + maxPathLength: target.d * 2.9 + 36, + }); + if (pathLengthCells(path) >= 10 && expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) { + expressways.push(path); + addCorridorInfluencePenalty(penalty, path, 10, 0.64); + debug.expresswayCorridors.push({ from: "major-city-center-fallback", city: city.name, to: target.role, distance: Math.round(target.d), length: Math.round(pathLengthCells(path)), score: 0, path }); + break; + } + } + } + } + + // Inter-city backbone pass. The previous city-coverage fallback produced + // short suburban motorway approaches, but did not necessarily connect those + // approaches into a through network. Treat high-capacity roads as OD + // corridors: connect major-city suburb anchors, ports and external gates by + // a small Kruskal-style backbone over low-cost terrain. + const backboneAnchors = dedupeAnchors([...majorSuburbs, ...portRefs, ...externalRefs], 10) + .filter((p) => p && inside(p.x, p.y) && !sea[indexOf(p.x, p.y)]); + const keyOf = (p) => `${p.x},${p.y}`; + const { find, unite } = makeUnionFind(backboneAnchors, keyOf); + const backbonePairs = []; + for (let a = 0; a < backboneAnchors.length; a++) { + for (let b = a + 1; b < backboneAnchors.length; b++) { + const A = backboneAnchors[a]; + const B = backboneAnchors[b]; + const d = Math.hypot(A.x - B.x, A.y - B.y); + if (d < 44 || d > 190) continue; + const c = approximateLineCost(A, B, expresswayCorridorCost); + if (!Number.isFinite(c) || c >= INF) continue; + const demand = Math.sqrt(Math.max(50000, A.population || 70000) * Math.max(50000, B.population || 70000)) / 120000; + const gatewayBonus = A.role === "external-gateway" || B.role === "external-gateway" ? 0.28 : 0; + const portBonus = A.role === "port-logistics" || B.role === "port-logistics" ? 0.18 : 0; + backbonePairs.push({ A, B, d, score: d * c / Math.max(0.55, demand + gatewayBonus + portBonus) }); + } + } + backbonePairs.sort((a, b) => a.score - b.score); + let backboneAdded = 0; + for (const pair of backbonePairs) { + const ak = keyOf(pair.A); + const bk = keyOf(pair.B); + if (find(ak) === find(bk)) continue; + if (backboneAdded >= Math.min(9, Math.max(3, backboneAnchors.length - 1))) break; + const path = routeBetweenTrafficCandidates(pair.A, pair.B, "expressway", expresswayCorridorCost, penalty, { + curvePenalty: 0.105, + penaltyStrength: 2.10, + terrainFlowBias: 0.12, + surfaceGrain: 0.007, + relaxRadius: 1, + relaxLineWeight: 0.20, + maxPathLength: pair.d * 3.05 + 96, + snapRadius: 5, + }); + const len = pathLengthCells(path); + if (len < 34 || len > pair.d * 3.15 + 116) continue; + if (routeTooStraightMountainOnly(path)) continue; + if (!expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) continue; + expressways.push(path); + addCorridorInfluencePenalty(penalty, path, 24, 1.70); + unite(ak, bk); + backboneAdded++; + debug.expresswayCorridors.push({ from: "expressway-backbone", to: `${pair.A.role}-${pair.B.role}`, distance: Math.round(pair.d), length: Math.round(len), score: Math.round(pair.score * 100) / 100, path }); + } + } + + + function buildNationalCorridorNetwork(debug) { + const baseNodes = [ + ...modernCities.map((c) => ({ ...c, score: 1.2 + Math.sqrt(c.population || 50000) / 430 + ((c.population || 0) >= 100000 ? 0.55 : 0) + ((c.population || 0) >= 500000 ? 1.10 : 0), role: "city", population: c.population || 0 })), + ...markets.filter((m) => (m.population || 0) >= 3000).map((m) => ({ ...m, score: 0.72 + (m.population || 6000) / 42000, role: "market", population: m.population || 0 })), + ...ports.map((p) => ({ ...p, score: 0.76 + (p.portClass === "major" ? 0.45 : 0), role: "port", population: p.population || 12000 })), + ...externalGateways.map((g) => ({ ...g, score: 0.92, role: "external-gateway", population: 42000 })), + ...villages.filter((v) => (v.population || 0) >= 2200).map((v) => ({ ...v, score: 0.38 + (v.population || 0) / 18000, role: "large-village", population: v.population || 0 })), + ]; + const nodes = dedupeAnchors(baseNodes.sort((a, b) => b.score - a.score), 6).slice(0, 48); + if (nodes.length < 2) return; + const penalty = cachedInfluenceFromPaths([...expressways, ...externalRoads], 8, "national-corridor:base"); + const connected = [nodes[0]]; + const remaining = nodes.slice(1); + const maxMain = Math.min(24, Math.max(14, Math.ceil(nodes.length * 0.42))); + + while (remaining.length && nationalRoads.length < maxMain) { + let best = null; + for (const node of remaining) { + const candidates = connected + .map((q) => { + const d = Math.hypot(node.x - q.x, node.y - q.y); + if (d < 10 || d > 112) return null; + const lineCost = approximateLineCost(node, q, transportFields.national); + if (!Number.isFinite(lineCost) || lineCost >= INF) return null; + const demand = Math.sqrt(Math.max(3000, node.population || 6000) * Math.max(3000, q.population || 6000)) / 65000; + const score = d * lineCost / Math.max(0.35, demand + node.score * 0.25 + q.score * 0.25); + return { q, d, score }; + }) + .filter(Boolean) + .sort((a, b) => a.score - b.score); + if (!candidates.length) continue; + const cand = candidates[0]; + if (!best || cand.score < best.score) best = { node, target: cand.q, d: cand.d, score: cand.score }; + } + if (!best) break; + const path = routeBetweenTrafficCandidates(best.node, best.target, "national", transportFields.national, penalty, { + curvePenalty: 0.050, + penaltyStrength: 0.92, + terrainFlowBias: 0.26, + surfaceGrain: 0.034, + relaxRadius: 2, + relaxLineWeight: 0.32, + maxPathLength: best.d * 2.55 + 38, + }); + const len = pathLengthCells(path); + if (len >= 6 && len <= best.d * 2.65 + 42 && !routeTooStraightAcrossMountains(path, "national") && transportRouteAcceptable(path, "national", transportFields.nationalPotential, penalty, { minLength: 6, maxLength: best.d * 2.65 + 42, maxHighElevationShare: 0.34, maxSteepShare: 0.46 })) { + nationalRoads.push(path); + debug.nationalCorridors.push({ from: best.node.role, to: best.target.role, length: Math.round(len), path }); + addCorridorInfluencePenalty(penalty, path, 6, 0.30); + } + connected.push(best.node); + remaining.splice(remaining.indexOf(best.node), 1); + } + + const extraPairs = []; + for (let a = 0; a < Math.min(nodes.length, 32); a++) { + for (let b = a + 1; b < Math.min(nodes.length, 32); b++) { + const A = nodes[a]; + const B = nodes[b]; + const d = Math.hypot(A.x - B.x, A.y - B.y); + if (d < 22 || d > 86) continue; + const regional = A.regionId !== B.regionId ? 0.22 : 0; + const need = (A.score + B.score) * 0.5 + regional; + extraPairs.push({ A, B, d, score: d / Math.max(0.5, need) + hash2(A.x + B.x, A.y + B.y, seed + 18161) * 0.06 }); + } + } + extraPairs.sort((a, b) => a.score - b.score); + let addedExtra = 0; + for (const pair of extraPairs) { + if (addedExtra >= 5 || nationalRoads.length >= 29) break; + const path = routeBetweenTrafficCandidates(pair.A, pair.B, "national", transportFields.national, penalty, { + curvePenalty: 0.050, + penaltyStrength: 1.05, + terrainFlowBias: 0.25, + surfaceGrain: 0.034, + relaxRadius: 2, + relaxLineWeight: 0.32, + maxPathLength: pair.d * 2.35 + 32, + }); + const len = pathLengthCells(path); + if (len < 8 || len > pair.d * 2.35 + 32 || routeTooStraightAcrossMountains(path, "national")) continue; + if (pathAverageField(path, penalty) > 0.42 && pathAverageField(path, transportFields.nationalPotential) < 0.37) continue; + nationalRoads.push(path); + addedExtra++; + debug.nationalCorridors.push({ from: `${pair.A.role}-extra`, to: `${pair.B.role}-extra`, length: Math.round(len), path }); + addCorridorInfluencePenalty(penalty, path, 6, 0.32); + } + + const nationalInfluence = cachedInfluenceFromPaths([...nationalRoads, ...externalRoads], 7, "national:major-city-coverage"); + const importantCities = modernCities + .filter((c) => (c.population || 0) >= 100000) + .sort((a, b) => (b.population || 0) - (a.population || 0)); + const nationalTargets = dedupeAnchors([...nodes, ...externalGateways.map((g) => ({ ...g, role: "external-gateway", population: 42000, score: 0.92 }))], 5); + for (const city of importantCities) { + const ci = indexOf(city.x, city.y); + if ((nationalInfluence[ci] || 0) > 0.20) continue; + const target = nationalTargets + .filter((q) => Math.hypot(q.x - city.x, q.y - city.y) > 4) + .map((q) => ({ q, d: Math.hypot(q.x - city.x, q.y - city.y), c: approximateLineCost(city, q, transportFields.national) })) + .filter((e) => e.d <= 86 && Number.isFinite(e.c) && e.c < INF) + .sort((a, b) => (a.d * a.c) - (b.d * b.c))[0]; + if (!target) continue; + const path = routeBetweenTrafficCandidates(city, target.q, "national", transportFields.national, penalty, { + curvePenalty: 0.052, + penaltyStrength: 0.86, + terrainFlowBias: 0.27, + surfaceGrain: 0.034, + relaxRadius: 2, + relaxLineWeight: 0.28, + maxPathLength: target.d * 2.5 + 34, + }); + const len = pathLengthCells(path); + if (len >= 4 && len <= target.d * 2.55 + 38 && !routeTooStraightAcrossMountains(path, "national")) { + nationalRoads.push(path); + debug.nationalCorridors.push({ from: "major-city-guarantee", city: city.name, to: target.q.role, length: Math.round(len), path }); + addCorridorInfluencePenalty(penalty, path, 6, 0.30); + } + } + + // National roads should form regional corridors, not a set of short roads + // terminating around each town. Add a sparse backbone over cities, ports + // and external gates after the initial MST/extra pass, using the same + // terrain cost field but a larger distance envelope. + const backboneNodes = dedupeAnchors([ + ...modernCities.filter((c) => (c.population || 0) >= 65000).map((c) => ({ ...c, role: "city-backbone", score: 1.0 + Math.sqrt(c.population || 70000) / 420, population: c.population || 0 })), + ...ports.filter((p) => p.portClass === "major" || (p.population || 0) >= 9000).map((p) => ({ ...p, role: "port-backbone", score: 1.05, population: p.population || 20000 })), + ...externalGateways.map((g) => ({ ...g, role: "external-backbone", score: 1.0, population: 42000 })), + ].sort((a, b) => b.score - a.score), 7).slice(0, 34); + const keyOf = (p) => `${p.x},${p.y}`; + const { find, unite } = makeUnionFind(backboneNodes, keyOf); + const pairs = []; + for (let a = 0; a < backboneNodes.length; a++) { + for (let b = a + 1; b < backboneNodes.length; b++) { + const A = backboneNodes[a]; + const B = backboneNodes[b]; + const d = Math.hypot(A.x - B.x, A.y - B.y); + if (d < 16 || d > 132) continue; + const c = approximateLineCost(A, B, transportFields.national); + if (!Number.isFinite(c) || c >= INF) continue; + const demand = Math.sqrt(Math.max(9000, A.population || 12000) * Math.max(9000, B.population || 12000)) / 82000; + const regional = A.regionId !== B.regionId ? 0.28 : 0; + pairs.push({ A, B, d, score: d * c / Math.max(0.42, demand + regional + (A.score + B.score) * 0.18) }); + } + } + pairs.sort((a, b) => a.score - b.score); + let backboneAdded = 0; + for (const pair of pairs) { + if (backboneAdded >= Math.min(22, Math.max(8, backboneNodes.length - 1))) break; + const ak = keyOf(pair.A); + const bk = keyOf(pair.B); + if (find(ak) === find(bk)) continue; + const path = routeBetweenTrafficCandidates(pair.A, pair.B, "national", transportFields.national, penalty, { + curvePenalty: 0.052, + penaltyStrength: 0.78, + terrainFlowBias: 0.28, + surfaceGrain: 0.036, + relaxRadius: 2, + relaxLineWeight: 0.28, + maxPathLength: pair.d * 3.05 + 62, + snapRadius: 4, + }); + const len = pathLengthCells(path); + if (len < 6 || len > pair.d * 3.15 + 78) continue; + if (routeTooStraightAcrossMountains(path, "national")) continue; + nationalRoads.push(path); + addCorridorInfluencePenalty(penalty, path, 6, 0.27); + unite(ak, bk); + backboneAdded++; + debug.nationalCorridors.push({ from: "national-backbone", to: `${pair.A.role}-${pair.B.role}`, length: Math.round(len), path }); + } + } + + // ------------------------------------------------------------------------- + // Density-flow transport system + // ------------------------------------------------------------------------- + // The road hierarchy below deliberately avoids treating a city as one node. + // Cities contribute several edge portals and the field samplers add population + // density anchors, so trunks are drawn to preferred density bands instead of + // collapsing into CBD points. + + function densityBand(i, target = 0.46, width = 0.26) { + const d = (settlementDemand[i] || 0) - target; + return Math.exp(-(d * d) / Math.max(0.0001, 2 * width * width)); + } + + function denseCorePenaltyAt(i) { + return clamp( + (settlementDemand[i] - 0.62) / 0.30 + + preliminaryTownInfluence[i] * 0.44 + + preliminaryVillageInfluence[i] * 0.30 + ); + } + + function terrainCorridorBonusAt(i, mode = "national") { + return clamp( + valleyField[i] * (mode === "expressway" ? 0.26 : 0.46) + + coastalLowland[i] * (mode === "expressway" ? 0.24 : 0.34) + + plain[i] * 0.24 + + basinField[i] * 0.18 + + agriculture[i] * (mode === "expressway" ? 0.08 : 0.18) + + (passSuitability?.[i] || 0) * (mode === "expressway" ? 0.18 : 0.32) + + (crossingSuitability?.[i] || 0) * (mode === "expressway" ? 0.10 : 0.24) - + ridgeField[i] * (mode === "expressway" ? 0.42 : 0.24) - + slope[i] * (mode === "expressway" ? 0.36 : 0.22) + ); + } + + function markPathInfluence(field, path, radius = 5, strength = 1) { + for (const [px, py] of path || []) { + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + if (dx * dx + dy * dy > radius * radius) continue; + const x = px + dx; + const y = py + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i]) continue; + const d = Math.hypot(dx, dy); + const v = strength * Math.pow(1 - d / Math.max(1, radius), 1.35); + if (v > field[i]) field[i] = v; + } + } + } + } + + function fieldAdjustedCost(baseCost, mode, flowField = null) { + const out = new Float32Array(SIZE); + for (let i = 0; i < SIZE; i++) { + if (sea[i] || baseCost[i] >= INF) { + out[i] = INF; + continue; + } + const core = denseCorePenaltyAt(i); + const flow = flowField?.[i] || 0; + if (mode === "expressway") { + // Motorways prefer the urban fringe / logistics band and through-flow, + // but strongly avoid CBD and village cores. + const preferred = densityBand(i, 0.38, 0.22) * 0.42 + urbanEdge[i] * 0.42 + logisticsPreSuitability[i] * 0.34 + terrainCorridorBonusAt(i, mode) * 0.24; + out[i] = Math.max(0.12, + baseCost[i] + - preferred + - flow * 0.58 + + core * 2.55 + + preliminaryVillageInfluence[i] * 1.38 + + Math.max(0, elevation[i] - 0.60) * 2.9 + + ridgeField[i] * 0.92 + + slope[i] * 0.92 + ); + } else if (mode === "national") { + // National roads should follow town chains and valleys without diving + // into every exact population maximum. + const preferred = densityBand(i, 0.52, 0.32) * 0.38 + preliminaryTownInfluence[i] * 0.24 + preliminaryVillageInfluence[i] * 0.20 + terrainCorridorBonusAt(i, mode) * 0.34; + out[i] = Math.max(0.10, + baseCost[i] + - preferred + - flow * 0.74 + + Math.max(0, core - 0.46) * 0.52 + + Math.max(0, elevation[i] - 0.66) * 1.4 + + ridgeField[i] * 0.28 + ); + } else { + out[i] = baseCost[i]; + } + } + return out; + } + + function pointPopulationProxy(p, mode = "national") { + if (!p) return 1000; + if (p.population) return p.population; + if (p.portClass === "major") return 85000; + if (p.portClass === "regional") return 42000; + const i = inside(p.x, p.y) ? indexOf(p.x, p.y) : -1; + const fieldPop = i >= 0 ? Math.round((settlementDemand[i] * 110000 + preliminaryTownInfluence[i] * 65000 + preliminaryVillageInfluence[i] * 18000)) : 0; + return Math.max(mode === "expressway" ? 42000 : 7000, fieldPop); + } + + function portalSearchAroundPoint(point, mode = "national", role = "portal", options = {}) { + if (!point || !inside(point.x, point.y)) return null; + const inner = options.inner ?? (mode === "expressway" ? Math.max(9, Math.round((point.coreRadius || 3) + 6)) : Math.max(3, Math.round((point.coreRadius || 2) + 2))); + const outer = options.outer ?? (mode === "expressway" ? Math.max(inner + 7, Math.round((point.urbanRadius || 12) * 1.85)) : Math.max(inner + 5, Math.round((point.urbanRadius || 9) * 1.05))); + const potentialField = mode === "expressway" ? transportFields.expresswayPotential : transportFields.nationalPotential; + const targetDensity = mode === "expressway" ? 0.38 : 0.52; + const width = mode === "expressway" ? 0.22 : 0.32; + let best = null; + for (let dy = -outer; dy <= outer; dy++) { + for (let dx = -outer; dx <= outer; dx++) { + const x = point.x + dx; + const y = point.y + dy; + if (!inside(x, y)) continue; + const d = Math.hypot(dx, dy); + if (d < inner || d > outer) continue; + const i = indexOf(x, y); + if (sea[i]) continue; + if ((mode === "expressway" ? expresswayCorridorCost[i] : transportFields.national[i]) >= INF) continue; + const radialBand = clamp(1 - Math.abs(d - (inner + outer) * 0.50) / Math.max(2, (outer - inner) * 0.55)); + const core = denseCorePenaltyAt(i); + const score = + potentialField[i] * 1.15 + + densityBand(i, targetDensity, width) * 0.58 + + (mode === "expressway" ? urbanEdge[i] * 0.60 + logisticsPreSuitability[i] * 0.46 - core * 1.12 : preliminaryTownInfluence[i] * 0.34 + preliminaryVillageInfluence[i] * 0.18 - Math.max(0, core - 0.70) * 0.28) + + terrainCorridorBonusAt(i, mode) * 0.38 + + radialBand * 0.26 - + slope[i] * (mode === "expressway" ? 0.90 : 0.42) - + ridgeField[i] * (mode === "expressway" ? 0.68 : 0.26) + + hash2(x, y, seed + 18610 + point.x * 7 + point.y * 13 + (mode === "expressway" ? 37 : 0)) * 0.055; + if (!best || score > best.score) best = { x, y, score, role, regionId: regionIdAt(x, y), population: pointPopulationProxy(point, mode), source: point }; + } + } + return best; + } + + function cityPortalAnchors(city, mode = "national") { + if (!city || !inside(city.x, city.y)) return []; + const sectors = mode === "expressway" ? 8 : 10; + const inner = mode === "expressway" ? Math.max(10, Math.round((city.coreRadius || 4) + 7)) : Math.max(4, Math.round((city.coreRadius || 3) + 2)); + const outer = mode === "expressway" ? Math.max(inner + 7, Math.round((city.urbanRadius || 12) * 1.9)) : Math.max(inner + 5, Math.round((city.urbanRadius || 11) * 1.15)); + const bySector = Array.from({ length: sectors }, () => null); + const potentialField = mode === "expressway" ? transportFields.expresswayPotential : transportFields.nationalPotential; + for (let dy = -outer; dy <= outer; dy += 1) { + for (let dx = -outer; dx <= outer; dx += 1) { + const x = city.x + dx; + const y = city.y + dy; + if (!inside(x, y)) continue; + const d = Math.hypot(dx, dy); + if (d < inner || d > outer) continue; + const i = indexOf(x, y); + if (sea[i]) continue; + if ((mode === "expressway" ? expresswayCorridorCost[i] : transportFields.national[i]) >= INF) continue; + const sector = Math.floor((((Math.atan2(dy, dx) + Math.PI) / (Math.PI * 2)) * sectors)) % sectors; + const tangentNoise = hash2(x, y, seed + 18630 + city.x * 5 + city.y * 11) * 0.055; + const radialBand = clamp(1 - Math.abs(d - (inner + outer) * 0.50) / Math.max(2, (outer - inner) * 0.54)); + const core = denseCorePenaltyAt(i); + const score = + potentialField[i] * 1.10 + + (mode === "expressway" ? densityBand(i, 0.38, 0.22) * 0.58 + urbanEdge[i] * 0.60 + logisticsPreSuitability[i] * 0.48 - core * 1.22 : densityBand(i, 0.52, 0.32) * 0.48 + preliminaryTownInfluence[i] * 0.26 + preliminaryVillageInfluence[i] * 0.16 - Math.max(0, core - 0.70) * 0.24) + + terrainCorridorBonusAt(i, mode) * 0.36 + + radialBand * 0.30 - + slope[i] * (mode === "expressway" ? 0.92 : 0.42) - + ridgeField[i] * (mode === "expressway" ? 0.74 : 0.30) + + tangentNoise; + const current = bySector[sector]; + if (!current || score > current.score) bySector[sector] = { x, y, score, role: mode === "expressway" ? "urban-fringe-ic" : "urban-portal", regionId: regionIdAt(x, y), population: city.population || 0, city }; + } + } + const count = mode === "expressway" + ? ((city.population || 0) >= 420000 ? 2 : 1) + : ((city.population || 0) >= 420000 ? 4 : (city.population || 0) >= 140000 ? 3 : 2); + return dedupeAnchors(bySector.filter(Boolean).sort((a, b) => b.score - a.score), mode === "expressway" ? 9 : 5).slice(0, count); + } + + function densityFieldAnchors(mode = "national", max = 48) { + const candidates = []; + const step = mode === "expressway" ? 5 : 4; + const potentialField = mode === "expressway" ? transportFields.expresswayPotential : transportFields.nationalPotential; + for (let y = 3; y < MAP_H - 3; y += step) { + for (let x = 3; x < MAP_W - 3; x += step) { + const i = indexOf(x, y); + if (sea[i] || regionIdAt(x, y) < 0) continue; + if ((mode === "expressway" ? expresswayCorridorCost[i] : transportFields.national[i]) >= INF) continue; + const core = denseCorePenaltyAt(i); + const score = mode === "expressway" + ? potentialField[i] * 1.18 + densityBand(i, 0.38, 0.22) * 0.60 + urbanEdge[i] * 0.48 + logisticsPreSuitability[i] * 0.52 + terrainCorridorBonusAt(i, mode) * 0.32 - core * 1.18 - preliminaryVillageInfluence[i] * 0.82 - slope[i] * 0.78 - ridgeField[i] * 0.62 + hash2(x, y, seed + 18670) * 0.06 + : potentialField[i] * 1.26 + densityBand(i, 0.52, 0.32) * 0.46 + preliminaryTownInfluence[i] * 0.32 + preliminaryVillageInfluence[i] * 0.24 + terrainCorridorBonusAt(i, mode) * 0.38 - Math.max(0, core - 0.82) * 0.36 - slope[i] * 0.36 - ridgeField[i] * 0.22 + hash2(x, y, seed + 18671) * 0.06; + const threshold = mode === "expressway" ? 0.78 : 0.66; + if (score >= threshold) candidates.push({ x, y, score, role: mode === "expressway" ? "density-fringe" : "density-town-chain", regionId: regionIdAt(x, y), population: pointPopulationProxy({ x, y }, mode) }); + } + } + return pickEntities(candidates, { max, minDistance: mode === "expressway" ? 18 : 9, threshold: 0, seed: seed + 18680 + (mode === "expressway" ? 41 : 0), jitter: 0.035 }); + } + + function roadAnchorsForMode(mode = "national") { + if (mode === "expressway") { + const urbanPortals = modernCities + // Expressways are intercity corridors. Do not give every medium city an + // urban-expressway-like fringe anchor; medium cities are handled by the + // national-road layer unless they are a capital. + .filter((c) => (c.population || 0) >= 150000 || c.isRegionalCapital || c.isPrefecturalCapital) + .flatMap((c) => cityPortalAnchors(c, "expressway")); + const portPortals = commercialPorts + .filter((p) => p.portClass === "major" || p.portClass === "regional") + .map((p) => portalSearchAroundPoint(p, "expressway", "port-fringe", { inner: 4, outer: 14 }) || { ...p, role: "port-fringe", population: p.population || 55000, score: 0.9 }); + const gatewayPortals = externalGateways.map((g) => ({ ...g, role: "external-gateway", population: 90000, score: 0.92 })); + const fieldPortals = densityFieldAnchors("expressway", 16); + return dedupeAnchors([...urbanPortals, ...portPortals, ...gatewayPortals, ...fieldPortals].sort((a, b) => b.score - a.score), 11).slice(0, 38); + } + + const cityPortals = modernCities.flatMap((c) => cityPortalAnchors(c, "national")); + const marketPortals = markets + .filter((m) => (m.population || 0) >= 6000) + .map((m) => portalSearchAroundPoint(m, "national", "market-portal", { inner: 2, outer: 8 }) || { ...m, role: "market-portal", population: m.population || 9000, score: 0.65 }); + const villagePortals = villages + .filter((v) => (v.population || 0) >= 3500) + .map((v) => ({ ...v, role: "large-village", score: 0.34 + (v.population || 0) / 17000, population: v.population || 0 })); + const portPortals = ports.map((p) => ({ ...p, role: "port", score: 0.72 + (p.portClass === "major" ? 0.48 : p.portClass === "regional" ? 0.28 : 0), population: p.population || 18000 })); + const passPortals = passes.map((p) => ({ ...p, role: "pass", score: 0.46 + (passSuitability?.[indexOf(p.x, p.y)] || 0), population: 8000 })); + const gatewayPortals = externalGateways.map((g) => ({ ...g, role: "external-gateway", population: 42000, score: 0.92 })); + const fieldPortals = densityFieldAnchors("national", 54); + return dedupeAnchors([...cityPortals, ...marketPortals, ...villagePortals, ...portPortals, ...passPortals, ...gatewayPortals, ...fieldPortals].sort((a, b) => b.score - a.score), 5.5).slice(0, 88); + } + + function buildTrafficFlowField(mode, anchors, baseCost, maxRoutes = 34) { + const flow = new Float32Array(SIZE); + const nodes = anchors.slice(0, mode === "expressway" ? 24 : 56); + const pairs = []; + for (let a = 0; a < nodes.length; a++) { + for (let b = a + 1; b < nodes.length; b++) { + const A = nodes[a]; + const B = nodes[b]; + const d = Math.hypot(A.x - B.x, A.y - B.y); + if (d < (mode === "expressway" ? 36 : 14) || d > (mode === "expressway" ? 178 : 118)) continue; + const c = approximateLineCost(A, B, baseCost); + if (!Number.isFinite(c) || c >= INF) continue; + const demand = Math.sqrt(pointPopulationProxy(A, mode) * pointPopulationProxy(B, mode)) / (mode === "expressway" ? 120000 : 52000); + const crossRegion = A.regionId !== B.regionId ? (mode === "expressway" ? 0.30 : 0.18) : 0; + const gateway = A.role === "external-gateway" || B.role === "external-gateway" ? (mode === "expressway" ? 0.38 : 0.18) : 0; + const score = d * c / Math.max(0.28, demand + crossRegion + gateway + (A.score + B.score) * 0.16); + pairs.push({ A, B, d, score, demand }); + } + } + pairs.sort((a, b) => a.score - b.score); + let added = 0; + const virtualPenalty = new Float32Array(SIZE); + for (const pair of pairs) { + if (added >= maxRoutes) break; + const path = routeBetweenTrafficCandidates(pair.A, pair.B, mode, baseCost, virtualPenalty, { + curvePenalty: mode === "expressway" ? 0.12 : 0.065, + penaltyStrength: mode === "expressway" ? 1.05 : 0.72, + terrainFlowBias: mode === "expressway" ? 0.10 : 0.25, + surfaceGrain: mode === "expressway" ? 0.006 : 0.026, + relaxRadius: mode === "expressway" ? 1 : 2, + relaxLineWeight: mode === "expressway" ? 0.20 : 0.28, + maxPathLength: pair.d * (mode === "expressway" ? 2.65 : 2.45) + (mode === "expressway" ? 70 : 34), + snapRadius: mode === "expressway" ? 4 : 3, + heuristicWeight: mode === "expressway" ? 0.70 : 0.48, + }); + if (path.length < 4) continue; + const len = pathLengthCells(path); + if (len > pair.d * (mode === "expressway" ? 2.75 : 2.55) + (mode === "expressway" ? 78 : 38)) continue; + markPathInfluence(flow, path, mode === "expressway" ? 8 : 5, Math.min(1.2, 0.42 + pair.demand * 0.36)); + addCorridorInfluencePenalty(virtualPenalty, path, mode === "expressway" ? 42 : 6, mode === "expressway" ? 4.20 : 0.22); + added++; + } + return flow; + } + + function maxSegmentLength(path) { + let max = 0; + for (let k = 1; k < (path?.length || 0); k++) max = Math.max(max, Math.hypot(path[k][0] - path[k - 1][0], path[k][1] - path[k - 1][1])); + return max; + } + + function existingParallelShare(path, influenceField, threshold = 0.16) { + if (!path?.length || !influenceField) return 0; + let hit = 0; + let n = 0; + for (const [x, y] of path) { + if (!inside(x, y)) continue; + n++; + if ((influenceField[indexOf(x, y)] || 0) > threshold) hit++; + } + return n ? hit / n : 0; + } + + function localRouteTerrainStats(path) { + const len = pathLengthCells(path); + const first = path?.[0]; + const last = path?.[path.length - 1]; + const direct = first && last ? Math.hypot(first[0] - last[0], first[1] - last[1]) : 0; + let mountain = 0, ridge = 0, steep = 0, high = 0, valleyPass = 0, coast = 0, seaNear = 0, n = 0; + for (const [x, y] of path || []) { + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i]) return { len, direct, straightness: 1, mountain: 1, ridge: 1, steepShare: 1, highShare: 1, valleyPass: 0, coast: 0, seaNear: 1 }; + mountain += clamp((elevation[i] - 0.56) * 2.6 + slope[i] * 0.95 + ridgeField[i] * 0.85 - valleyField[i] * 0.26 - (passSuitability?.[i] || 0) * 0.32); + ridge += ridgeField[i]; + if (slope[i] > 0.44) steep++; + if (elevation[i] >= 0.70) high++; + valleyPass += clamp(valleyField[i] * 0.62 + (passSuitability?.[i] || 0) * 0.58 + basinField[i] * 0.18 + plain[i] * 0.12); + coast += coastalLowland[i]; + let near = 0; + for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) { + if (!dx && !dy) continue; + const nx = x + dx, ny = y + dy; + if (inside(nx, ny) && sea[indexOf(nx, ny)]) near = 1; + } + seaNear += near; + n++; + } + return { + len, + direct, + straightness: direct / Math.max(1, len), + mountain: mountain / Math.max(1, n), + ridge: ridge / Math.max(1, n), + steepShare: steep / Math.max(1, n), + highShare: high / Math.max(1, n), + valleyPass: valleyPass / Math.max(1, n), + coast: coast / Math.max(1, n), + seaNear: seaNear / Math.max(1, n), + }; + } + + function localRouteAcceptableStrict(path, options = {}) { + if (!path?.length || path.length < 2) return false; + const st = localRouteTerrainStats(path); + const maxLength = options.maxLength ?? 74; + if (st.len > maxLength) return false; + if (st.highShare > 0 && !(options.allowPassCrossing && st.valleyPass > 0.62 && st.len <= 18)) return false; + // Coastal land roads are allowed; actual sea cells are already rejected by localRouteTerrainStats(). + if (st.len > 26 && st.highShare > 0.34 && st.valleyPass < 0.34) return false; + if (st.len > 30 && st.steepShare > 0.42 && st.valleyPass < 0.36) return false; + if (st.len > 34 && st.mountain > 0.46 && st.valleyPass < 0.36) return false; + if (st.len > 26 && st.straightness > 0.82 && st.mountain > 0.38 && st.valleyPass < 0.40) return false; + if (st.len > 44 && st.ridge > 0.42 && st.valleyPass < 0.42) return false; + return true; + } + + function sanitizeLocalRoads() { + const before = minorRoads.length; + const kept = []; + let pruned = 0; + for (const path of minorRoads) { + if (!path || path.length < 2) { pruned++; continue; } + const len = pathLengthCells(path); + const maxLength = len > 58 ? 68 : 78; + if (localRouteAcceptableStrict(path, { maxLength })) kept.push(path); + else pruned++; + } + minorRoads.length = 0; + minorRoads.push(...kept); + return { before, after: minorRoads.length, pruned }; + } + + + function removePathSelfLoops(path) { + if (!path || path.length < 2) return path || []; + const out = []; + let pos = new Map(); + function rebuildIndex() { + pos = new Map(); + for (let k = 0; k < out.length; k++) pos.set(`${out[k][0]},${out[k][1]}`, k); + } + for (const p of path) { + if (!p || !inside(p[0], p[1]) || sea[indexOf(p[0], p[1])]) continue; + const key = `${p[0]},${p[1]}`; + if (out.length && out[out.length - 1][0] === p[0] && out[out.length - 1][1] === p[1]) continue; + if (pos.has(key)) { + const keep = pos.get(key) + 1; + if (out.length - keep > 2) { + out.length = keep; + rebuildIndex(); + } + continue; + } + pos.set(key, out.length); + out.push([p[0], p[1]]); + } + return out; + } + + function expresswayUrbanTangleScore(path) { + if (!path?.length) return { share: 0, maxCity: null, coreHits: 0 }; + let bestShare = 0; + let bestCity = null; + let bestCoreHits = 0; + for (const city of modernCities) { + if ((city.population || 0) < 28000) continue; + const envelope = Math.max(18, (city.urbanRadius || 10) * ((city.population || 0) >= 220000 ? 2.15 : 2.55)); + const core = Math.max(5.0, (city.coreRadius || 4) + 4.2); + let inEnvelope = 0; + let coreHits = 0; + for (const [x, y] of path) { + const d = Math.hypot(x - city.x, y - city.y); + if (d <= envelope) inEnvelope++; + if (d <= core) coreHits++; + } + const share = inEnvelope / Math.max(1, path.length); + if (share > bestShare) { bestShare = share; bestCity = city; bestCoreHits = coreHits; } + } + return { share: bestShare, maxCity: bestCity, coreHits: bestCoreHits }; + } + + function sanitizeExpresswayNetwork(debug = null) { + const before = expressways.length; + const cleaned = []; + let loopTrimmed = 0; + let urbanPruned = 0; + let shortPruned = 0; + const seen = new Set(); + for (const path of expressways) { + const cleanedPath = removePathSelfLoops(path); + if (cleanedPath.length < (path?.length || 0)) loopTrimmed++; + const len = pathLengthCells(cleanedPath); + if (len < 12 || cleanedPath.length < 2) { shortPruned++; continue; } + const sig = cleanedPath.map((p, k) => k % 3 === 0 ? `${p[0]},${p[1]}` : '').filter(Boolean).join('|'); + if (seen.has(sig)) continue; + seen.add(sig); + const tangle = expresswayUrbanTangleScore(cleanedPath); + // A through expressway may graze a city's fringe, but a path mostly inside + // one middle-sized urban envelope is rendered like an accidental city + // expressway. Drop those short/looping urban motorway fragments. + if (expressways.length > 1 && tangle.maxCity && (tangle.maxCity.population || 0) < 260000 && tangle.share > 0.46 && len < 105) { + urbanPruned++; + continue; + } + if (tangle.coreHits > (tangle.maxCity && (tangle.maxCity.population || 0) >= 260000 ? 8 : 3)) { + urbanPruned++; + continue; + } + cleaned.push(cleanedPath); + } + expressways.length = 0; + expressways.push(...cleaned); + pruneParallelSameMode(expressways, "expressway", transportFields.expresswayPotential, { threshold: 0.08, radius: 92, minKeep: 1, shortLength: 220 }); + if (debug) debug.expresswaySanitization = { before, after: expressways.length, loopTrimmed, urbanPruned, shortPruned }; + return { before, after: expressways.length, loopTrimmed, urbanPruned, shortPruned }; + } + + function buildFieldBackbone(debug, mode, outPaths, anchors, costField, potentialField, options = {}) { + const nodes = anchors.slice(0, options.maxNodes ?? (mode === "expressway" ? 38 : 76)); + if (nodes.length < 2) return; + const policy = fieldBackbonePolicy(mode); + const label = mode === "expressway" ? "expresswayCorridors" : "nationalCorridors"; + const penalty = new Float32Array(SIZE); + const accepted = new Float32Array(SIZE); + for (const path of outPaths) markPathInfluence(accepted, path, options.parallelRadius ?? (mode === "expressway" ? 44 : 6), 1); + if (mode === "national") { + for (const path of expressways) markPathInfluence(penalty, path, 8, 0.16); + for (const path of externalRoads) markPathInfluence(accepted, path, 5, 0.55); + } + + const degree = new Map(); + const keyOf = (p) => `${p.x},${p.y}`; + const { find, unite } = makeUnionFind(nodes, keyOf); + + const pairs = []; + for (let a = 0; a < nodes.length; a++) { + for (let b = a + 1; b < nodes.length; b++) { + const A = nodes[a]; + const B = nodes[b]; + const d = Math.hypot(A.x - B.x, A.y - B.y); + if (d < (options.minDistance ?? (mode === "expressway" ? 42 : 13)) || d > (options.maxDistance ?? (mode === "expressway" ? 186 : 126))) continue; + const c = approximateLineCost(A, B, costField); + if (!Number.isFinite(c) || c >= INF) continue; + const demand = Math.sqrt(pointPopulationProxy(A, mode) * pointPopulationProxy(B, mode)) / (mode === "expressway" ? 115000 : 48000); + const roleNeed = + ((A.role || "").includes("gateway") || (B.role || "").includes("gateway") ? (mode === "expressway" ? 0.42 : 0.22) : 0) + + ((A.role || "").includes("port") || (B.role || "").includes("port") ? (mode === "expressway" ? 0.24 : 0.18) : 0) + + (A.regionId !== B.regionId ? (mode === "expressway" ? 0.26 : 0.16) : 0); + const localScore = (A.score + B.score) * 0.16; + const jitter = hash2(A.x + B.x * 3, A.y + B.y * 5, seed + (mode === "expressway" ? 18720 : 18721)) * 0.05; + pairs.push({ A, B, d, score: d * c / Math.max(0.35, demand + roleNeed + localScore) + jitter }); + } + } + pairs.sort((a, b) => a.score - b.score); + const skip = { pairs: pairs.length, degree: 0, noPath: 0, length: 0, mountain: 0, mountainReasons: {}, acceptable: 0, acceptableReasons: {}, parallel: 0, added: 0 }; + + let connectedAdds = 0; + let extraAdds = 0; + const maxAdded = options.maxAdded ?? (mode === "expressway" ? Math.min(10, Math.max(4, Math.ceil(nodes.length / 4))) : Math.min(34, Math.max(16, Math.ceil(nodes.length * 0.46)))); + const maxExtra = options.maxExtra ?? (mode === "expressway" ? 2 : 7); + const maxDegree = options.maxDegree ?? (mode === "expressway" ? 2 : 4); + for (const pair of pairs) { + if (outPaths.length >= maxAdded) break; + const ak = keyOf(pair.A); + const bk = keyOf(pair.B); + const connects = find(ak) !== find(bk); + if (!connects && extraAdds >= maxExtra) { skip.degree++; continue; } + if ((degree.get(ak) || 0) >= maxDegree || (degree.get(bk) || 0) >= maxDegree) { skip.degree++; continue; } + const path = routeBetweenTrafficCandidates(pair.A, pair.B, mode, costField, penalty, { + curvePenalty: mode === "expressway" ? 0.125 : 0.065, + penaltyStrength: mode === "expressway" ? 7.20 : 1.05, + terrainFlowBias: mode === "expressway" ? 0.08 : 0.24, + surfaceGrain: mode === "expressway" ? 0.006 : 0.030, + relaxRadius: mode === "expressway" ? 1 : 2, + relaxLineWeight: mode === "expressway" ? 0.19 : 0.28, + maxPathLength: pair.d * (mode === "expressway" ? 2.75 : 2.55) + (mode === "expressway" ? 84 : 42), + snapRadius: mode === "expressway" ? 3.5 : 2.5, + heuristicWeight: mode === "expressway" ? 0.72 : 0.50, + }); + const len = pathLengthCells(path); + if (!path.length) { skip.noPath++; continue; } + if (len < policy.minLength || len > pair.d * policy.maxLengthMultiplier + policy.maxLengthAdd) { skip.length++; continue; } + if (mode === "expressway") { + const mountainCheck = mountainRouteAssessment(path, "expresswayMountainOnly"); + if (!mountainCheck.ok) { + skip.mountain++; + countReason(skip, "mountainReasons", mountainCheck.reason); + continue; + } + const acceptCheck = routeAcceptableForMode(path, mode, potentialField, penalty, {}, { allowFallback: true, allowApproach: true }); + if (!acceptCheck.ok) { + skip.acceptable++; + countReason(skip, "acceptableReasons", acceptCheck.reason); + continue; + } + } else { + const mountainCheck = mountainRouteAssessment(path, "national"); + if (!mountainCheck.ok) { + skip.mountain++; + countReason(skip, "mountainReasons", mountainCheck.reason); + continue; + } + const acceptCheck = routeAcceptableForMode(path, "national", potentialField, penalty, { + minLength: policy.minLength, + maxLength: pair.d * policy.maxLengthMultiplier + policy.maxLengthAdd, + maxHighElevationShare: policy.maxHighElevationShare, + maxSteepShare: policy.maxSteepShare, + }); + if (!acceptCheck.ok) { + skip.acceptable++; + countReason(skip, "acceptableReasons", acceptCheck.reason); + continue; + } + } + const parallel = existingParallelShare(path, accepted, mode === "expressway" ? 0.010 : 0.18); + if (parallel > (connects ? policy.parallelConnected : policy.parallelExtra)) { skip.parallel++; continue; } + outPaths.push(path); + markPathInfluence(accepted, path, options.parallelRadius ?? (mode === "expressway" ? 44 : 6), 1); + addCorridorInfluencePenalty(penalty, path, options.parallelRadius ?? (mode === "expressway" ? 44 : 6), options.penaltyStrengthMark ?? (mode === "expressway" ? 2.10 : 0.30)); + degree.set(ak, (degree.get(ak) || 0) + 1); + degree.set(bk, (degree.get(bk) || 0) + 1); + if (connects) { + unite(ak, bk); + connectedAdds++; + } else { + extraAdds++; + } + skip.added++; + debug[label].push({ from: pair.A.role, to: pair.B.role, length: Math.round(len), distance: Math.round(pair.d), score: Math.round(pair.score * 100) / 100, path }); + } + debug[`${mode}AnchorCount`] = nodes.length; + debug[`${mode}ConnectedAdds`] = connectedAdds; + debug[`${mode}ExtraAdds`] = extraAdds; + debug[`${mode}SkipStats`] = skip; + } + + function pathComesOutOfCity(path, city, anchor, mode = "national") { + if (!path?.length || !city || !anchor) return false; + const nearAnchorRadius = mode === "expressway" ? 5.5 : 4.5; + const exitRadius = mode === "expressway" + ? Math.max(28, (city.urbanRadius || 12) * 1.75) + : Math.max(16, (city.urbanRadius || 9) * 1.18); + let touchesAnchor = false; + let leavesUrbanEnvelope = false; + for (const [x, y] of path) { + if (Math.hypot(x - anchor.x, y - anchor.y) <= nearAnchorRadius) touchesAnchor = true; + if (Math.hypot(x - city.x, y - city.y) >= exitRadius) leavesUrbanEnvelope = true; + if (touchesAnchor && leavesUrbanEnvelope) return true; + } + return false; + } + + function pathServesCityCenter(path, city, mode = "national") { + if (!path?.length || !city) return false; + const nearRadius = mode === "expressway" + ? Math.max(20, (city.urbanRadius || 12) * 1.55) + : Math.max(10, (city.urbanRadius || 9) * 0.95); + const exitRadius = mode === "expressway" + ? Math.max(28, (city.urbanRadius || 12) * 1.75) + : Math.max(16, (city.urbanRadius || 9) * 1.18); + let near = false; + let far = false; + for (const [x, y] of path) { + const d = Math.hypot(x - city.x, y - city.y); + if (d <= nearRadius) near = true; + if (d >= exitRadius) far = true; + if (near && far) return true; + } + return false; + } + + function ensureExpresswayCityIntercity(debug, expressAnchors, expressCost) { + const forceCost = new Float32Array(SIZE); + for (let i = 0; i < SIZE; i++) { + if (sea[i]) { forceCost[i] = INF; continue; } + if (Number.isFinite(expressCost[i]) && expressCost[i] < INF) { + forceCost[i] = expressCost[i]; + } else { + const nationalFallback = Number.isFinite(transportFields.national[i]) && transportFields.national[i] < INF ? transportFields.national[i] + 2.2 : 5.8; + forceCost[i] = nationalFallback + denseCorePenaltyAt(i) * 3.4 + preliminaryVillageInfluence[i] * 1.3 + Math.max(0, elevation[i] - 0.58) * 3.1 + slope[i] * 1.6 + ridgeField[i] * 1.4; + } + } + function forcedExpresswayAnchorForCity(city) { + const primary = cityPortalAnchors(city, "expressway")[0] || majorCitySuburbanAnchor(city) || portalSearchAroundPoint(city, "expressway", "urban-fringe-ic"); + if (primary) return primary; + const inner = Math.max(8, Math.round((city.coreRadius || 4) + 5)); + const outer = Math.max(inner + 10, Math.round((city.urbanRadius || 13) * 2.3)); + let best = null; + for (let dy = -outer; dy <= outer; dy++) { + for (let dx = -outer; dx <= outer; dx++) { + const x = city.x + dx, y = city.y + dy; + if (!inside(x, y)) continue; + const d = Math.hypot(dx, dy); + if (d < inner || d > outer) continue; + const i = indexOf(x, y); + if (sea[i] || forceCost[i] >= INF) continue; + const target = Math.max(inner + 3, Math.min(outer - 2, (city.urbanRadius || 13) * 1.65)); + const score = -forceCost[i] - Math.abs(d - target) * 0.045 - denseCorePenaltyAt(i) * 1.5 - preliminaryVillageInfluence[i] * 0.8 - ridgeField[i] * 0.4 + urbanEdge[i] * 0.28 + hash2(x, y, seed + 18891 + city.x * 11 + city.y * 17) * 0.04; + if (!best || score > best.score) best = { x, y, score, role: "urban-fringe-ic", regionId: regionIdAt(x, y), population: city.population || 0, city }; + } + } + return best; + } + const eligibleCities = modernCities + .filter((c) => (c.population || 0) >= 150000 || c.isRegionalCapital || c.isPrefecturalCapital) + .sort((a, b) => (b.population || 0) - (a.population || 0)); + const cityAnchors = eligibleCities + .map((city) => { + const anchor = forcedExpresswayAnchorForCity(city); + return anchor ? { ...anchor, city, role: "urban-fringe-ic", population: city.population || anchor.population || 0, score: (anchor.score || 0) + Math.sqrt(city.population || 80000) / 900 } : null; + }) + .filter(Boolean); + if (cityAnchors.length < 1) return; + const logisticsTargets = expressAnchors + .filter((p) => p && (p.role === "port-fringe" || p.role === "external-gateway")) + .sort((a, b) => (b.score || 0) - (a.score || 0)); + const otherTargets = [...cityAnchors, ...logisticsTargets] + .sort((a, b) => (b.score || 0) - (a.score || 0)); + const penalty = new Float32Array(SIZE); + const accepted = new Float32Array(SIZE); + for (const path of expressways) { + markPathInfluence(penalty, path, 64, 9.20); + markPathInfluence(accepted, path, 64, 1.0); + } + let added = 0; + const stats = { cities: cityAnchors.length, covered: 0, noCandidates: 0, tried: 0, noPath: 0, length: 0, notOutbound: 0, mountain: 0, mountainReasons: {}, unacceptable: 0, unacceptableReasons: {}, parallel: 0 }; + for (const anchor of cityAnchors) { + const city = anchor.city; + if (expressways.some((path) => pathServesCityCenter(path, city, "expressway"))) { stats.covered++; continue; } + const candidates = otherTargets + .filter((q) => q !== anchor && q.city !== city) + .map((q) => { + const d = Math.hypot(q.x - anchor.x, q.y - anchor.y); + const c0 = approximateLineCost(anchor, q, forceCost); + const c = Number.isFinite(c0) && c0 < INF ? c0 : 2.8; + return { q, d, c }; + }) + .filter((e) => e.d >= 32 && e.d <= 260) + .sort((a, b) => { + const aCity = a.q.role === "urban-fringe-ic" ? -22 : 0; + const bCity = b.q.role === "urban-fringe-ic" ? -22 : 0; + return (a.d * a.c + aCity) - (b.d * b.c + bCity); + }); + if (!candidates.length) stats.noCandidates++; + for (const opt of candidates.slice(0, 10)) { + stats.tried++; + const path = routeBetweenTrafficCandidates(anchor, opt.q, "expressway", forceCost, penalty, { + curvePenalty: 0.13, + penaltyStrength: 13.80, + terrainFlowBias: 0.08, + surfaceGrain: 0.006, + relaxRadius: 1, + relaxLineWeight: 0.18, + maxPathLength: opt.d * 3.35 + 150, + snapRadius: 2.2, + heuristicWeight: 0.80, + searchPad: Math.ceil(Math.max(64, Math.min(132, opt.d * 0.72))), + }); + const len = pathLengthCells(path); + if (!path.length) { stats.noPath++; continue; } + if (len < 20 || len > opt.d * 3.55 + 168) { stats.length++; continue; } + if (!pathComesOutOfCity(path, city, anchor, "expressway")) { stats.notOutbound++; continue; } + const mountainCheck = mountainRouteAssessment(path, "expresswayMountainOnly"); + if (!mountainCheck.ok) { + stats.mountain++; + countReason(stats, "mountainReasons", mountainCheck.reason); + continue; + } + const acceptCheck = expresswayRouteAssessment(path, { allowFallback: true, allowApproach: true }); + if (!acceptCheck.ok) { + const risk = acceptCheck.risk; + if (risk.cityCoreShare > 0.36 || risk.villageCoreShare > 0.38 || risk.denseShare > 0.42) { + stats.unacceptable++; + countReason(stats, "unacceptableReasons", acceptCheck.reason); + continue; + } + } + const parallel = existingParallelShare(path, accepted, 0.010); + if (parallel > 0.105) { stats.parallel++; continue; } + expressways.push(path); + markPathInfluence(accepted, path, 86, 1.0); + addCorridorInfluencePenalty(penalty, path, 82, 12.40); + debug.expresswayCorridors.push({ from: "forced-city-intercity", city: city.name, to: opt.q.city?.name || opt.q.role, distance: Math.round(opt.d), length: Math.round(len), path }); + added++; + break; + } + } + debug.forcedExpresswayCityConnections = (debug.forcedExpresswayCityConnections || 0) + added; + debug.forcedExpresswayCityStats = stats; + } + + function ensureNationalCityOutbound(debug, nationalAnchors, nationalCost) { + const targetAnchors = nationalAnchors + .filter((p) => p && ["urban-portal", "market-portal", "port", "external-gateway", "large-village"].includes(p.role)) + .sort((a, b) => (b.score || 0) - (a.score || 0)); + const penalty = new Float32Array(SIZE); + const accepted = new Float32Array(SIZE); + for (const path of nationalRoads) { + markPathInfluence(penalty, path, 6, 0.42); + markPathInfluence(accepted, path, 6, 1.0); + } + for (const path of externalRoads) markPathInfluence(accepted, path, 5, 0.55); + let added = 0; + const cities = modernCities + .filter((c) => (c.population || 0) >= 52000 || c.isRegionalCapital || c.isPrefecturalCapital) + .sort((a, b) => (b.population || 0) - (a.population || 0)); + for (const city of cities) { + const start = cityPortalAnchors(city, "national")[0] || portalSearchAroundPoint(city, "national", "urban-portal"); + if (!start) continue; + if (nationalRoads.some((path) => pathServesCityCenter(path, city, "national"))) continue; + const candidates = targetAnchors + .filter((q) => q !== start && q.city !== city && q.source !== city) + .map((q) => ({ q, d: Math.hypot(q.x - start.x, q.y - start.y), c: approximateLineCost(start, q, nationalCost) })) + .filter((e) => e.d >= 14 && e.d <= 126 && Number.isFinite(e.c) && e.c < INF) + .sort((a, b) => { + const aCity = a.q.role === "urban-portal" ? -10 : 0; + const bCity = b.q.role === "urban-portal" ? -10 : 0; + return (a.d * a.c + aCity) - (b.d * b.c + bCity); + }); + for (const opt of candidates.slice(0, 8)) { + const path = routeBetweenTrafficCandidates(start, opt.q, "national", nationalCost, penalty, { + curvePenalty: 0.060, + penaltyStrength: 1.04, + terrainFlowBias: 0.26, + surfaceGrain: 0.030, + relaxRadius: 2, + relaxLineWeight: 0.28, + maxPathLength: opt.d * 3.05 + 74, + snapRadius: 2.0, + heuristicWeight: 0.60, + searchPad: Math.ceil(Math.max(42, Math.min(110, opt.d * 0.62))), + }); + const len = pathLengthCells(path); + if (len < 5 || len > opt.d * 3.15 + 82) continue; + if (!pathComesOutOfCity(path, city, start, "national")) continue; + if (routeTooStraightAcrossMountains(path, "national")) continue; + if (!transportRouteAcceptable(path, "national", transportFields.nationalPotential, penalty, { minLength: 5, maxLength: opt.d * 2.85 + 58, maxHighElevationShare: 0.36, maxSteepShare: 0.50 })) continue; + const parallel = existingParallelShare(path, accepted, 0.18); + if (parallel > 0.58) continue; + nationalRoads.push(path); + markPathInfluence(accepted, path, 6, 1.0); + addCorridorInfluencePenalty(penalty, path, 6, 0.38); + debug.nationalCorridors.push({ from: "forced-city-outbound", city: city.name, to: opt.q.city?.name || opt.q.source?.name || opt.q.role, length: Math.round(len), path }); + added++; + break; + } + } + debug.forcedNationalCityConnections = (debug.forcedNationalCityConnections || 0) + added; + } + + function buildDensityFlowRoadSystem(debug) { + const expressAnchors = roadAnchorsForMode("expressway"); + const nationalAnchors = roadAnchorsForMode("national"); + debug.majorCitySuburbanAnchors = expressAnchors + .filter((p) => p.role === "urban-fringe-ic") + .map((p) => ({ x: p.x, y: p.y, city: p.city?.name, population: p.population })); + + const expressFlow = buildTrafficFlowField("expressway", expressAnchors, expresswayCorridorCost, 14); + const expressCost = fieldAdjustedCost(expresswayCorridorCost, "expressway", expressFlow); + buildFieldBackbone(debug, "expressway", expressways, expressAnchors, expressCost, transportFields.expresswayPotential, { + maxNodes: 48, + maxAdded: Math.min(4, Math.max(2, Math.ceil(expressAnchors.length / 12))), + maxExtra: 0, + minDistance: 38, + maxDistance: 190, + maxDegree: 2, + parallelRadius: 84, + penaltyStrengthMark: 16.50, + }); + ensureExpresswayCityIntercity(debug, expressAnchors, expressCost); + + // Recompute national flow with expressways already present. National roads + // are allowed to cross/approach motorways but are discouraged from becoming + // a duplicate motorway frontage road for long distances. + const nationalFlow = buildTrafficFlowField("national", nationalAnchors, transportFields.national, 50); + const nationalCost = fieldAdjustedCost(transportFields.national, "national", nationalFlow); + buildFieldBackbone(debug, "national", nationalRoads, nationalAnchors, nationalCost, transportFields.nationalPotential, { + maxNodes: 78, + maxAdded: Math.min(38, Math.max(18, Math.ceil(nationalAnchors.length * 0.45))), + maxExtra: 8, + minDistance: 12, + maxDistance: 130, + maxDegree: 4, + parallelRadius: 6, + penaltyStrengthMark: 0.32, + }); + + // Guarantee light national access to large urban areas whose portals were + // deduped away. The target remains a portal/field anchor, not the city point. + const nationalInfluence = influenceFromPaths([...nationalRoads, ...externalRoads], 7); + const nationalPenalty = influenceFromPaths(nationalRoads, 6); + const nationalTargets = dedupeAnchors([...nationalAnchors, ...externalGateways.map((g) => ({ ...g, role: "external-gateway", population: 42000, score: 0.92 }))], 5); + for (const city of modernCities.filter((c) => (c.population || 0) >= 90000).sort((a, b) => (b.population || 0) - (a.population || 0))) { + if ((nationalInfluence[indexOf(city.x, city.y)] || 0) > 0.18) continue; + const portals = cityPortalAnchors(city, "national"); + const start = portals[0] || portalSearchAroundPoint(city, "national", "urban-portal"); + if (!start) continue; + const target = nationalTargets + .filter((q) => Math.hypot(q.x - start.x, q.y - start.y) > 8) + .map((q) => ({ q, d: Math.hypot(q.x - start.x, q.y - start.y), c: approximateLineCost(start, q, nationalCost) })) + .filter((e) => e.d <= 82 && Number.isFinite(e.c) && e.c < INF) + .sort((a, b) => (a.d * a.c) - (b.d * b.c))[0]; + if (!target) continue; + const path = routeBetweenTrafficCandidates(start, target.q, "national", nationalCost, nationalPenalty, { + curvePenalty: 0.060, + penaltyStrength: 0.82, + terrainFlowBias: 0.24, + surfaceGrain: 0.030, + relaxRadius: 2, + relaxLineWeight: 0.28, + maxPathLength: target.d * 2.6 + 38, + snapRadius: 2.5, + }); + const len = pathLengthCells(path); + if (len >= 5 && len <= target.d * 2.7 + 44 && !routeTooStraightAcrossMountains(path, "national")) { + nationalRoads.push(path); + debug.nationalCorridors.push({ from: "city-portal-guarantee", city: city.name, to: target.q.role, length: Math.round(len), path }); + addCorridorInfluencePenalty(nationalPenalty, path, 6, 0.28); + } + } + + ensureNationalCityOutbound(debug, nationalAnchors, nationalCost); + + pruneParallelSameMode(expressways, "expressway", transportFields.expresswayPotential, { threshold: 0.08, radius: 92, minKeep: 1, shortLength: 220 }); + pruneParallelSameMode(nationalRoads, "national", transportFields.nationalPotential, { threshold: 0.62, radius: 2, minKeep: 10, shortLength: 18 }); + + // Pruning may remove short stubs that were serving as the only outward + // connection for a city. Run the hard city-outbound guarantees after pruning + // as the final road graph invariant. + ensureExpresswayCityIntercity(debug, expressAnchors, expressCost); + ensureNationalCityOutbound(debug, nationalAnchors, nationalCost); + sanitizeExpresswayNetwork(debug); + } + + function rebuildRoadTransportByCorridors() { + const debug = { + strategy: "density-field-portals-plus-flow-backbone", + routePolicySummary: { + expresswayAcceptance: routePolicies.expresswayAcceptance, + fieldBackbone: routePolicies.fieldBackbone, + }, + clearedGeneratedNational: nationalRoads.length, + clearedGeneratedExpressways: expressways.length, + majorCitySuburbanAnchors: [], + expresswayCorridors: [], + nationalCorridors: [], + }; + nationalRoads.length = 0; + expressways.length = 0; + minorRoads.length = 0; + buildDensityFlowRoadSystem(debug); + return debug; + } + + function addShortConnector(outPaths, a, b, mode = "local") { + const d = Math.hypot(a.x - b.x, a.y - b.y); + const costField = mode === "expressway" ? expresswayCorridorCost : mode === "national" ? transportFields.national : transportFields.local; + let path = []; + if (d > 2.2) { + path = routeBetweenTrafficCandidates(a, b, mode === "expressway" ? "expressway" : mode === "national" ? "national" : "local", costField, null, { + curvePenalty: mode === "expressway" ? 0.095 : mode === "national" ? 0.045 : 0.035, + penaltyStrength: 0.0, + terrainFlowBias: mode === "expressway" ? 0.12 : mode === "national" ? 0.22 : 0.28, + surfaceGrain: 0.020, + relaxRadius: 1, + relaxLineWeight: 0.22, + maxPathLength: d * 2.6 + 8, + snapRadius: 0.5, + searchPad: Math.max(6, Math.ceil(d + 4)), + }); + } else { + const steps = Math.max(1, Math.ceil(d)); + for (let s = 0; s <= steps; s++) { + const t = s / steps; + const x = Math.round(a.x + (b.x - a.x) * t); + const y = Math.round(a.y + (b.y - a.y) * t); + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i] || costField[i] >= INF) return false; + if (!path.length || path[path.length - 1][0] !== x || path[path.length - 1][1] !== y) path.push([x, y]); + } + } + if (path.length >= 2 && maxSegmentLength(path) <= 1.6 && pathLengthCells(path) <= d * 2.25 + 10) { + if (mode === "local" && !localRouteAcceptableStrict(path, { maxLength: Math.max(10, d * 2.45 + 12) })) return false; + outPaths.push(path); + return true; + } + return false; + } + + function stitchRasterNearContacts() { + const debug = { expressway: 0, national: 0, localToNational: 0, local: 0, broadNearMisses: 0 }; + function sampledCells(paths, step = 1) { + const cells = []; + for (let pathId = 0; pathId < (paths || []).length; pathId++) { + const path = paths[pathId]; + for (let k = 0; k < path.length; k += step) { + const [x, y] = path[k]; + if (inside(x, y) && !sea[indexOf(x, y)]) cells.push({ x, y, pathId }); + } + } + return cells; + } + function endpointListWithIds(paths) { + const out = []; + for (let pathId = 0; pathId < (paths || []).length; pathId++) { + const p = paths[pathId]; + if (!p || p.length < 2) continue; + out.push({ x: p[0][0], y: p[0][1], pathId }); + const q = p[p.length - 1]; + out.push({ x: q[0], y: q[1], pathId }); + } + return out; + } + function nearestWithin(source, targets, radius, excludeSamePath = true) { + let best = null; + let bestD = radius + 1; + for (const t of targets) { + if (excludeSamePath && source.pathId != null && t.pathId === source.pathId) continue; + const d = Math.hypot(source.x - t.x, source.y - t.y); + if (d > 0.01 && d < bestD) { + bestD = d; + best = t; + } + } + return best ? { target: best, d: bestD } : null; + } + function stitchEndpoints(paths, mode, targets, maxAdds, radius) { + let added = 0; + const endpoints = endpointListWithIds(paths); + for (const ep of endpoints) { + if (added >= maxAdds) break; + const near = nearestWithin(ep, targets, radius, true); + if (near && addShortConnector(paths, ep, near.target, mode)) added++; + } + return added; + } + const expresswayCells = sampledCells(expressways, 1); + const nationalCells = sampledCells([...nationalRoads, ...externalRoads], 1); + const localCells = sampledCells(minorRoads, 1); + debug.expressway += stitchEndpoints(expressways, "expressway", expresswayCells, 8, 34.0); + debug.national += stitchEndpoints(nationalRoads, "national", nationalCells, 48, 14.0); + debug.localToNational += stitchEndpoints(minorRoads, "local", nationalCells, 240, 20.0); + debug.local += stitchEndpoints(minorRoads, "local", localCells, 260, 16.0); + + // Also stitch near-miss interiors: visually crossing/adjacent roads that do + // not share a raster cell. This addresses line simplification and diagonal + // near-contact cases not caught by endpoint-only repair. + const allLocalTargets = [...nationalCells, ...localCells]; + const candidates = sampledCells([...nationalRoads, ...minorRoads], 3) + .sort((a, b) => hash2(a.x, a.y, seed + 18377) - hash2(b.x, b.y, seed + 18377)); + for (const c of candidates) { + if (debug.broadNearMisses >= 260) break; + const near = nearestWithin(c, allLocalTargets, 4.75, true); + if (!near) continue; + const out = c.pathId < nationalRoads.length ? nationalRoads : minorRoads; + const mode = out === nationalRoads ? "national" : "local"; + if (addShortConnector(out, c, near.target, mode)) debug.broadNearMisses++; + } + // Expressway stitching can add new motorway cells; remove loopbacks before + // regenerating the IC/access layer so final spacing is applied to the + // simplified motorway graph. + sanitizeExpresswayNetwork(); + generateInterchangesForExpressways(); + return debug; + } + + function stitchLongLocalBranches() { + const debug = { localToTrunk: 0, localToLocal: 0 }; + function cells(paths, step = 2) { + const out = []; + for (let pathId = 0; pathId < (paths || []).length; pathId++) { + const path = paths[pathId]; + for (let k = 0; k < path.length; k += step) { + const [x, y] = path[k]; + if (inside(x, y) && !sea[indexOf(x, y)]) out.push({ x, y, pathId }); + } + } + return out; + } + function endpoints(paths) { + const out = []; + for (let pathId = 0; pathId < (paths || []).length; pathId++) { + const p = paths[pathId]; + if (!p || p.length < 2) continue; + out.push({ x: p[0][0], y: p[0][1], pathId }); + const q = p[p.length - 1]; + out.push({ x: q[0], y: q[1], pathId }); + } + return out; + } + function nearest(source, targets, radius, excludePath = null) { + let best = null; + let bestD = radius + 1; + for (const t of targets) { + if (excludePath != null && t.pathId === excludePath) continue; + const d = Math.hypot(source.x - t.x, source.y - t.y); + if (d > 0.01 && d < bestD) { + bestD = d; + best = t; + } + } + return best ? { ...best, d: bestD } : null; + } + const trunkCells = cells([...nationalRoads, ...externalRoads], 2); + const localCells = cells(minorRoads, 2); + const eps = endpoints(minorRoads) + .sort((a, b) => hash2(a.x, a.y, seed + 18491) - hash2(b.x, b.y, seed + 18491)); + for (const ep of eps) { + if (debug.localToTrunk >= 160) break; + const hit = nearest(ep, trunkCells, 20, null); + if (!hit) continue; + if (addShortConnector(minorRoads, ep, hit, "local")) debug.localToTrunk++; + } + const eps2 = endpoints(minorRoads) + .sort((a, b) => hash2(a.x, a.y, seed + 18493) - hash2(b.x, b.y, seed + 18493)); + for (const ep of eps2) { + if (debug.localToLocal >= 150) break; + const hit = nearest(ep, localCells, 17, ep.pathId); + if (!hit) continue; + if (addShortConnector(minorRoads, ep, hit, "local")) debug.localToLocal++; + } + return debug; + } + + const corridorRoadNetworkDebug = rebuildRoadTransportByCorridors(); + + const transportDebugLayers = { + packedHeatmaps: true, + expresswayPotential: packDebugField(transportFields.expresswayPotential), + railPotential: packDebugField(transportFields.railPotential), + nationalRoadPotential: packDebugField(transportFields.nationalPotential), + slopeSeaPenalty: (() => { + const src = new Float32Array(SIZE); + for (let i = 0; i < SIZE; i++) src[i] = sea[i] ? 1 : clamp(slope[i] * 1.55 + Math.max(0, elevation[i] - 0.58) * 2.2 + ridgeField[i] * 0.42); + return packDebugField(src); + })(), + components: [], + repairedSegments: [], + unservedSettlements: [], + corridorRoadNetwork: { + strategy: corridorRoadNetworkDebug.strategy, + routePolicySummary: corridorRoadNetworkDebug.routePolicySummary, + clearedGeneratedNational: corridorRoadNetworkDebug.clearedGeneratedNational, + clearedGeneratedExpressways: corridorRoadNetworkDebug.clearedGeneratedExpressways, + majorCitySuburbanAnchors: corridorRoadNetworkDebug.majorCitySuburbanAnchors, + expresswayCorridorCount: corridorRoadNetworkDebug.expresswayCorridors.length, + nationalCorridorCount: corridorRoadNetworkDebug.nationalCorridors.length, + expresswayAnchorCount: corridorRoadNetworkDebug.expresswayAnchorCount, + nationalAnchorCount: corridorRoadNetworkDebug.nationalAnchorCount, + expresswaySkipStats: corridorRoadNetworkDebug.expresswaySkipStats, + nationalSkipStats: corridorRoadNetworkDebug.nationalSkipStats, + forcedExpresswayCityConnections: corridorRoadNetworkDebug.forcedExpresswayCityConnections || 0, + forcedNationalCityConnections: corridorRoadNetworkDebug.forcedNationalCityConnections || 0, + forcedExpresswayCityStats: corridorRoadNetworkDebug.forcedExpresswayCityStats, + }, }; -} - -export function packDebugField(field) { - const out = new Uint8Array(SIZE); - for (let i = 0; i < SIZE; i++) out[i] = Math.round(clamp(field?.[i] || 0) * 255); - return out; -} - -export function pathLengthCells(path) { - let total = 0; - for (let i = 1; i < (path?.length || 0); i++) { - total += Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]); + // Corridor selection gives the road hierarchy, but a small topology pass is + // still needed to remove isolated fragments and near-miss components. Keep + // the repair budget bounded so it connects existing backbones rather than + // recreating the removed field-generated spaghetti layer. + for (const repair of [ + repairTransportConnectivity(expressways, "expressway", expresswayCorridorCost, transportFields.expresswayPotential, { + minImportance: 5.5, + minComponentCells: 10, + maxComponents: 8, + maxRepairs: 3, + maxRepairDistance: 145, + minRepairDistance: 18, + searchPad: 48, + penaltyRadius: 18, + penaltyStrength: 4.2, + curvePenalty: 0.13, + terrainFlowBias: 0.09, + surfaceGrain: 0.006, + relaxRadius: 1, + relaxLineWeight: 0.20, + highPotentialThreshold: 0.24, + }), + repairTransportConnectivity(nationalRoads, "national", transportFields.national, transportFields.nationalPotential, { + minImportance: 4.0, + minComponentCells: 7, + maxComponents: 14, + maxRepairs: 12, + maxRepairDistance: 96, + minRepairDistance: 8, + searchPad: 36, + penaltyRadius: 6, + penaltyStrength: 1.15, + curvePenalty: 0.060, + terrainFlowBias: 0.24, + surfaceGrain: 0.030, + relaxRadius: 2, + relaxLineWeight: 0.28, + highPotentialThreshold: 0.28, + }), + repairTransportConnectivity(railways, "rail", transportFields.rail, transportFields.railPotential, { + minImportance: 10.5, + minComponentCells: 18, + maxComponents: 6, + maxRepairs: 3, + maxRepairDistance: 105, + penaltyRadius: 7, + penaltyStrength: 2.0, + curvePenalty: 0.16, + terrainFlowBias: 0.13, + surfaceGrain: 0.008, + relaxRadius: 1, + relaxLineWeight: 0.50, + highPotentialThreshold: 0.36, + }), + ]) { + transportDebugLayers.components.push(...repair.components); + transportDebugLayers.repairedSegments.push(...repair.repairs); } - return total; -} -export function pathAverageField(path, field) { - if (!path?.length || !field) return 0; - let sum = 0; - let n = 0; - for (const [x, y] of path) { - if (!inside(x, y)) continue; - sum += field[indexOf(x, y)] || 0; - n++; + function pruneShortTransportSegments(paths, minLength, minKeep = 1) { + const kept = []; + let pruned = 0; + const sorted = (paths || []).map((path, index) => ({ path, index, len: pathLengthCells(path) })); + for (const row of sorted) { + if ((row.len >= minLength || kept.length < minKeep) && (row.path?.length || 0) >= 2) kept.push(row.path); + else pruned++; + } + paths.length = 0; + paths.push(...kept); + return { pruned, kept: kept.length }; } - return n ? sum / n : 0; -} -export function routeQualityStats(path, fields = {}) { - if (!path?.length) return { length: 0, compactness: Infinity, highElevationShare: 1, steepShare: 1, waterShare: 1, avgPotential: 0, avgPenalty: 0 }; - const length = pathLengthCells(path); - const first = path[0]; - const last = path[path.length - 1]; - const direct = first && last ? Math.hypot(first[0] - last[0], first[1] - last[1]) : 0; - let high = 0; - let steep = 0; - let water = 0; - let potential = 0; - let penalty = 0; - let n = 0; - for (const [x, y] of path) { - if (!inside(x, y)) continue; - const i = indexOf(x, y); - if (fields.sea?.[i]) water++; - if ((fields.elevation?.[i] || 0) > (fields.highElevationThreshold ?? 0.72)) high++; - if ((fields.slope?.[i] || 0) > (fields.steepThreshold ?? 0.44)) steep++; - potential += fields.potential?.[i] || 0; - penalty += fields.penalty?.[i] || 0; - n++; + const nationalEndpointRepair = repairDanglingTransportEndpoints(nationalRoads, "national", transportFields.national, [...externalRoads, ...expressways, ...railways], transportFields.nationalPotential, { + maxAdded: 18, + maxTargetDistance: 58, + maxPathLength: 86, + curvePenalty: 0.060, + terrainFlowBias: 0.24, + surfaceGrain: 0.030, + relaxRadius: 2, + relaxLineWeight: 0.28, + targetRadius: 6, + penaltyRadius: 6, + addedPenalty: 0.24, + }); + transportDebugLayers.endpointRepairs = [nationalEndpointRepair]; + transportDebugLayers.repairedSegments.push(...nationalEndpointRepair.added); + + const expressEndpointRepair = repairDanglingTransportEndpoints(expressways, "expressway", expresswayCorridorCost, [...externalExpressways, ...nationalRoads], transportFields.expresswayPotential, { + maxAdded: 3, + maxTargetDistance: 78, + maxPathLength: 128, + curvePenalty: 0.13, + terrainFlowBias: 0.09, + surfaceGrain: 0.006, + relaxRadius: 1, + relaxLineWeight: 0.20, + targetRadius: 9, + penaltyRadius: 20, + penaltyStrength: 4.4, + addedPenalty: 1.10, + }); + transportDebugLayers.endpointRepairs.push(expressEndpointRepair); + transportDebugLayers.repairedSegments.push(...expressEndpointRepair.added); + transportDebugLayers.expresswaySanitizationAfterRepair = sanitizeExpresswayNetwork(); + + function downgradeShortNationalRoads(minLength = 18, protectedKeep = 8) { + const kept = []; + const downgraded = []; + for (const path of nationalRoads) { + if (!path || path.length < 2) continue; + const len = pathLengthCells(path); + if (len < minLength && nationalRoads.length - downgraded.length > protectedKeep) downgraded.push(path); + else kept.push(path); + } + nationalRoads.length = 0; + nationalRoads.push(...kept); + minorRoads.push(...downgraded); + return { threshold: minLength, downgraded: downgraded.length, kept: kept.length }; } - return { - length, - compactness: direct > 0.001 ? length / direct : Infinity, - highElevationShare: high / Math.max(1, n), - steepShare: steep / Math.max(1, n), - waterShare: water / Math.max(1, n), - avgPotential: potential / Math.max(1, n), - avgPenalty: penalty / Math.max(1, n), + + transportDebugLayers.shortNationalDowngrade = downgradeShortNationalRoads(18, 10); + transportDebugLayers.shortSegmentPruning = { + expressway: pruneShortTransportSegments(expressways, 12, 1), + national: pruneShortTransportSegments(nationalRoads, 9, 8), }; -} -export function routeQualityAcceptable(path, fields = {}, limits = {}) { - const q = routeQualityStats(path, fields); - if (q.length < (limits.minLength ?? 2)) return false; - if (q.length > (limits.maxLength ?? Infinity)) return false; - if (q.compactness > (limits.maxCompactness ?? 3.2)) return false; - if (q.highElevationShare > (limits.maxHighElevationShare ?? 0.0)) return false; - if (q.steepShare > (limits.maxSteepShare ?? 0.38)) return false; - if (q.waterShare > (limits.maxWaterShare ?? 0)) return false; - if (q.avgPenalty > (limits.maxAvgPenalty ?? Infinity) && q.avgPotential < (limits.minPotentialIfPenalty ?? 0.35)) return false; - if (q.avgPotential < (limits.minAvgPotential ?? 0)) return false; - return true; + const expressTerminalPrune = pruneDanglingTerminalSegments(expressways, "expressway", [...externalExpressways, ...nationalRoads], { + oneInvalidMax: 26, + bothInvalidMax: 42, + minKeep: 1, + targetRadius: 9, + }); + const nationalTerminalPrune = pruneDanglingTerminalSegments(nationalRoads, "national", [...externalRoads, ...expressways, ...railways], { + oneInvalidMax: 14, + bothInvalidMax: 26, + minKeep: 12, + targetRadius: 6, + }); + + transportDebugLayers.graphCandidateNetworks = []; + transportDebugLayers.parallelPruning = []; + transportDebugLayers.prunedDanglingSegments = [expressTerminalPrune, nationalTerminalPrune]; + transportDebugLayers.expresswaySanitizationFinal = sanitizeExpresswayNetwork(); + + function generateLocalRoadsForUnservedSettlements() { + const trunkInfluence = cachedInfluenceFromPaths([...nationalRoads, ...railways, ...externalRoads], 8, "local:trunk"); + const candidates = [...villages, ...markets, ...ports] + .filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)]) + .map((p) => { + const i = indexOf(p.x, p.y); + const settlementWeight = p.portClass ? 1.2 : p.population ? clamp(p.population / 18000, 0.35, 1.4) : 0.45; + return { ...p, score: settlementWeight + transportFields.localPotential[i] * 0.65 - trunkInfluence[i] * 1.15 }; + }) + .filter((p) => p.score > 0.06 && trunkInfluence[indexOf(p.x, p.y)] < 0.34) + .sort((a, b) => b.score - a.score) + .slice(0, 150); + const localPenalty = new Float32Array(SIZE); + const paths = []; + const served = []; + for (const start of candidates) { + if (paths.length >= 115) break; + if (distanceToNearest(served, start.x, start.y) < 4.5) continue; + let path = traceCorridorByCost( + start, + (x, y, i) => trunkInfluence[i] > 0.18 || (paths.length > 6 && localPenalty[i] > 0.045), + transportFields.local, + localPenalty, + { curvePenalty: 0.08, penaltyStrength: 1.00, minGoalDistance: 5, regionId: start.regionId, maxExpanded: SIZE } + ); + if (path.length < 4 || path.length > 86) continue; + if (!localRouteAcceptableStrict(path, { maxLength: 72 })) continue; + if (!transportRouteAcceptable(path, "local", transportFields.localPotential, localPenalty, { minLength: 4, maxLength: 86, maxHighElevationShare: 0.34, maxSteepShare: 0.50 })) continue; + paths.push(path); + served.push(start); + addCorridorInfluencePenalty(localPenalty, path, 4, 0.22); + } + return paths; + } + + function runLocalAccessPass({ + candidates, + accessInfluence, + localPenalty, + maxAdded = 80, + minSpacing = 3.5, + maxLength = 82, + debugMode = "local-access", + from = "unserved", + to = "network", + targetPredicate = null, + }) { + const served = []; + let added = 0; + for (const start of candidates) { + if (added >= maxAdded) break; + if (distanceToNearest(served, start.x, start.y) < minSpacing) continue; + let path = traceCorridorByCost( + start, + targetPredicate || ((x, y, i) => accessInfluence[i] > 0.16 || localPenalty[i] > 0.075), + transportFields.local, + localPenalty, + { curvePenalty: 0.020, penaltyStrength: 0.90, minGoalDistance: 3, regionId: start.regionId, maxExpanded: Math.floor(SIZE * 0.72), terrainFlowBias: 0.26, surfaceGrain: 0.042 } + ); + path = relaxRouteToTerrain(path, transportFields.local, { radius: 2, lineWeight: 0.25, grain: 0.048, iterations: 1 }); + const strictMaxLength = Math.min(maxLength, 74); + const ok = path.length >= 4 && path.length <= maxLength && localRouteAcceptableStrict(path, { maxLength: strictMaxLength }) && transportRouteAcceptable(path, "local", transportFields.localPotential, localPenalty, { minLength: 4, maxLength: strictMaxLength, maxHighElevationShare: 0.34, maxSteepShare: 0.50 }); + transportDebugLayers.unservedSettlements.push({ x: start.x, y: start.y, kind: start.kind || "Unserved Settlement", mode: debugMode, repaired: ok }); + if (!ok) continue; + minorRoads.push(path); + served.push(start); + added++; + transportDebugLayers.repairedSegments.push({ mode: "local", path, from, to }); + addCorridorInfluencePenalty(localPenalty, path, 4, 0.18); + if (accessInfluence) addCorridorInfluencePenalty(accessInfluence, path, 5, 0.22); + } + return added; + } + + minorRoads.push(...generateLocalRoadsForUnservedSettlements()); + const localEndpointRepair = repairDanglingTransportEndpoints(minorRoads, "local", transportFields.local, [...nationalRoads, ...externalRoads, ...railways], transportFields.localPotential, { + maxAdded: 36, + maxTargetDistance: 34, + curvePenalty: 0.055, + terrainFlowBias: 0.26, + surfaceGrain: 0.048, + relaxRadius: 2, + relaxLineWeight: 0.30, + targetRadius: 5, + }); + transportDebugLayers.endpointRepairs.push(localEndpointRepair); + transportDebugLayers.repairedSegments.push(...localEndpointRepair.added); + const localDanglingPrune = pruneDanglingTerminalSegments(minorRoads, "local", [...nationalRoads, ...externalRoads, ...railways], { + oneInvalidMax: 11, + bothInvalidMax: 18, + minKeep: 24, + }); + transportDebugLayers.prunedDanglingSegments.push(localDanglingPrune); + const localParallelPruning = pruneParallelSameMode(minorRoads, "local", transportFields.localPotential, { + threshold: 0.70, + radius: 1, + minKeep: 28, + shortLength: 13, + }); + transportDebugLayers.parallelPruning.push(localParallelPruning); + transportDebugLayers.localSanitizationInitial = sanitizeLocalRoads(); + function sampledNetworkCells(paths, step = 2) { + const cells = []; + for (let pathId = 0; pathId < (paths || []).length; pathId++) { + const path = paths[pathId]; + for (let k = 0; k < (path?.length || 0); k += step) { + const [x, y] = path[k]; + if (inside(x, y) && !sea[indexOf(x, y)]) cells.push({ x, y, pathId }); + } + } + return cells; + } + const nationalForIc = sampledNetworkCells([...nationalRoads, ...externalRoads], 2).map((q) => ({ ...q, roadClass: "national" })); + const generalRoadForIc = sampledNetworkCells([...nationalRoads, ...externalRoads, ...minorRoads], 2).map((q, idx) => ({ ...q, roadClass: idx < nationalForIc.length ? "national" : "local" })); + const nationalRoadIcIndex = makeSpatialIndex(nationalForIc, 16); + const generalRoadIcIndex = makeSpatialIndex(generalRoadForIc, 16); + function nearestRoadForIc(p, maxDistance = 24.0, preferNational = true) { + let best = null; + let bestD = maxDistance + 1; + const pool = preferNational ? nationalRoadIcIndex.near(p.x, p.y, maxDistance) : generalRoadIcIndex.near(p.x, p.y, maxDistance); + for (const q of pool) { + const d2 = squaredDistance(q.x, q.y, p.x, p.y); + if (d2 <= 1.2 * 1.2 || d2 >= bestD * bestD) continue; + const d = Math.sqrt(d2); + if (d < bestD) { + bestD = d; + best = q; + } + } + if (!best && preferNational) return nearestRoadForIc(p, maxDistance + 8, false); + return best ? { ...best, d: bestD } : null; + } + + function pathCumulativeLengths(path) { + const cum = [0]; + for (let k = 1; k < (path?.length || 0); k++) cum.push(cum[cum.length - 1] + Math.hypot(path[k][0] - path[k - 1][0], path[k][1] - path[k - 1][1])); + return cum; + } + + function pointAtPathDistance(path, cum, dist) { + if (!path?.length) return null; + if (dist <= 0) return { x: path[0][0], y: path[0][1], s: 0 }; + const total = cum[cum.length - 1] || 0; + if (dist >= total) { + const p = path[path.length - 1]; + return { x: p[0], y: p[1], s: total }; + } + let k = 1; + while (k < cum.length && cum[k] < dist) k++; + const a = path[k - 1]; + const b = path[k]; + const seg = Math.max(0.0001, cum[k] - cum[k - 1]); + const t = clamp((dist - cum[k - 1]) / seg); + return { x: Math.round(a[0] + (b[0] - a[0]) * t), y: Math.round(a[1] + (b[1] - a[1]) * t), s: dist }; + } + + function meanFieldAround(field, x, y, radius = 8) { + let sum = 0, n = 0; + const r = Math.ceil(radius); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + if (dx * dx + dy * dy > radius * radius) continue; + const nx = x + dx, ny = y + dy; + if (!inside(nx, ny)) continue; + const i = indexOf(nx, ny); + if (sea[i]) continue; + const w = 1 - Math.hypot(dx, dy) / Math.max(1, radius); + sum += (field?.[i] || 0) * (0.35 + w); + n += 0.35 + w; + } + } + return n ? sum / n : 0; + } + + const icDemandCache = new Map(); + function icDemandAt(p) { + if (!p || !inside(p.x, p.y)) return 0; + const key = `${p.x},${p.y}`; + const cached = icDemandCache.get(key); + if (cached !== undefined) return cached; + const demand = clamp( + meanFieldAround(settlementDemand, p.x, p.y, 11) * 0.64 + + meanFieldAround(preliminaryTownInfluence, p.x, p.y, 13) * 0.42 + + meanFieldAround(preliminaryVillageInfluence, p.x, p.y, 9) * 0.18 + + meanFieldAround(urbanEdge, p.x, p.y, 10) * 0.28 + + meanFieldAround(logisticsPreSuitability, p.x, p.y, 10) * 0.24 + ); + icDemandCache.set(key, demand); + return demand; + } + + function icSpacingForPoint(p) { + const demand = icDemandAt(p); + // Sparse rural sections: 20-25 km. Dense urban fringe: 6-12 km. + return { + demand, + minGap: clamp(8.0 - demand * 3.0, 5.0, 8.0), + idealGap: clamp(22.0 - demand * 15.0, 7.0, 22.0), + maxGap: clamp(25.0 - demand * 10.5, 12.0, 25.0), + }; + } + + function icCoveragePenalty(p) { + const demand = icDemandAt(p); + if (demand <= 0.001 || !interchanges.length) return 0; + let nearest = Infinity; + for (const q of interchanges) nearest = Math.min(nearest, Math.hypot(q.x - p.x, q.y - p.y)); + const desired = clamp(22.0 - demand * 14.0, 7.0, 22.0); + return nearest < desired * 0.72 ? (desired * 0.72 - nearest) * 0.040 : 0; + } + + function interchangeScore(p, hit) { + if (!p || !inside(p.x, p.y)) return -INF; + const i = indexOf(p.x, p.y); + if (sea[i] || denseCorePenaltyAt(i) > 0.92) return -INF; + const demand = icDemandAt(p); + return (hit ? 0.72 - hit.d * 0.018 : -0.20) + + transportFields.expresswayPotential[i] * 0.46 + + urbanEdge[i] * 0.34 + + logisticsPreSuitability[i] * 0.28 + + demand * 0.42 + + settlementDemand[i] * 0.10 - + icCoveragePenalty(p) - + slope[i] * 0.34 - + ridgeField[i] * 0.28; + } + + + function directLandConnector(a, b) { + const d = Math.hypot(a.x - b.x, a.y - b.y); + const steps = Math.max(1, Math.ceil(d)); + const path = []; + for (let s = 0; s <= steps; s++) { + const t = s / steps; + const x = Math.round(a.x + (b.x - a.x) * t); + const y = Math.round(a.y + (b.y - a.y) * t); + if (!inside(x, y)) return []; + const i = indexOf(x, y); + if (sea[i] || highAltitudeRoadClosed(i) || transportFields.local[i] >= INF) return []; + if (!path.length || path[path.length - 1][0] !== x || path[path.length - 1][1] !== y) path.push([x, y]); + } + return path; + } + + function addInterchange(p, hit) { + if (!p || !inside(p.x, p.y)) return false; + if (interchanges.some((q) => Math.hypot(q.x - p.x, q.y - p.y) < 5.0)) return false; + const i = indexOf(p.x, p.y); + if (sea[i]) return false; + const roadHit = hit || nearestRoadForIc(p, 55.0, false); + if (!roadHit) return false; + const connector = routeBetweenTrafficCandidates(p, roadHit, "local", transportFields.local, null, { + curvePenalty: 0.030, + penaltyStrength: 0, + terrainFlowBias: 0.24, + surfaceGrain: 0.025, + relaxRadius: 1, + relaxLineWeight: 0.20, + maxPathLength: roadHit.d * 3.20 + 18, + snapRadius: 0.5, + searchPad: Math.max(10, Math.ceil(roadHit.d + 10)), + }); + const finalConnector = connector.length >= 2 ? connector : directLandConnector(p, roadHit); + if (finalConnector.length < 2) return false; + interchanges.push({ x: p.x, y: p.y, kind: "Interchange", score: transportFields.expresswayPotential[i] + (roadHit ? 0.24 : 0), regionId: regionIdAt(p.x, p.y) }); + icAccessRoads.push(finalConnector); + minorRoads.push(finalConnector); + return true; + } + + function generateInterchangesForExpressways() { + const absoluteMinGap = 5.0; + const absoluteMaxGap = 25.0; + if (icAccessRoads.length) { + const oldAccess = new Set(icAccessRoads); + for (let k = minorRoads.length - 1; k >= 0; k--) if (oldAccess.has(minorRoads[k])) minorRoads.splice(k, 1); + } + interchanges.length = 0; + icAccessRoads.length = 0; + for (const path of expressways) { + if (!path || path.length < 3) continue; + const cum = pathCumulativeLengths(path); + const total = cum[cum.length - 1] || 0; + if (total < absoluteMinGap * 1.6) continue; + let lastS = Math.min(8, Math.max(4, total * 0.10)); + while (lastS < total - absoluteMinGap) { + const current = pointAtPathDistance(path, cum, lastS) || { x: path[0][0], y: path[0][1] }; + const spacing = icSpacingForPoint(current); + const minGap = spacing.minGap; + const maxGap = spacing.maxGap; + const idealGap = spacing.idealGap; + const windowStart = Math.min(total - absoluteMinGap, lastS + minGap); + const windowEnd = Math.min(total - absoluteMinGap, lastS + Math.min(absoluteMaxGap, maxGap)); + if (windowEnd < windowStart) break; + let best = null; + for (let s = windowStart; s <= windowEnd; s += 1.5) { + const p = pointAtPathDistance(path, cum, s); + if (!p || !inside(p.x, p.y) || sea[indexOf(p.x, p.y)]) continue; + const hit = nearestRoadForIc(p, 30.0 + icDemandAt(p) * 14.0, true); + const localSpacing = icSpacingForPoint(p); + const score = interchangeScore(p, hit) - Math.abs((s - lastS) - localSpacing.idealGap) * 0.020; + if (!best || score > best.score) best = { ...p, hit, score }; + } + if (!best || best.score < -0.26) { + best = pointAtPathDistance(path, cum, Math.min(windowEnd, lastS + idealGap)); + if (best) best.hit = nearestRoadForIc(best, 36.0 + icDemandAt(best) * 12.0, true); + } + if (!best) break; + const added = addInterchange(best, best.hit); + // Keep advancing even if the candidate could not be connected; otherwise + // a no-road window can trap the loop. The next window may find a road. + lastS = best.s || Math.min(windowEnd, lastS + idealGap); + if (!added && lastS < windowEnd) lastS = windowEnd; + } + } + } + + generateInterchangesForExpressways(); + + function connectAllRoadNetworksFinal(maxAdds = 72) { + const debug = { beforeComponents: 0, afterComponents: 0, added: 0, failed: 0 }; + const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]; + const roadGroups = () => [minorRoads, nationalRoads, externalRoads, expressways, externalExpressways]; + function groupCostField(groupIndex) { + if (groupIndex === 3 || groupIndex === 4) return expresswayCorridorCost; + if (groupIndex === 1 || groupIndex === 2) return transportFields.national; + return transportFields.local; + } + function splitPathToValidCells(path, costField, minCells = 2) { + const chunks = []; + let cur = []; + function valid(x, y) { + if (!inside(x, y)) return false; + const i = indexOf(x, y); + return !sea[i] && !highAltitudeRoadClosed(i) && costField[i] < INF; + } + function pushPoint(x, y) { + if (!valid(x, y)) { + if (cur.length >= minCells) chunks.push(cur); + cur = []; + return; + } + if (!cur.length || cur[cur.length - 1][0] !== x || cur[cur.length - 1][1] !== y) cur.push([x, y]); + } + for (let k = 0; k < (path?.length || 0); k++) { + const a = path[k]; + const b = path[Math.min(k + 1, path.length - 1)]; + const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1]))); + for (let s = 0; s <= steps; s++) { + if (k > 0 && s === 0) continue; + const t = s / steps; + pushPoint(Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t)); + } + } + if (cur.length >= minCells) chunks.push(cur); + return chunks; + } + function normalizeRoadGroups() { + const groups = roadGroups(); + for (let gi = 0; gi < groups.length; gi++) { + const group = groups[gi]; + const costField = groupCostField(gi); + const out = []; + for (const path of group || []) out.push(...splitPathToValidCells(path, costField, 2)); + group.length = 0; + group.push(...out); + } + } + normalizeRoadGroups(); + const keepAliveSettlements = dedupeAnchors([ + ...modernCities, + ...markets, + ...villages.filter((p) => (p.population || 0) >= 600), + ...ports, + ], 4); + function pathNearKeptSettlement(path, radius = 7.5) { + if (!path?.length) return false; + for (const [x, y] of path) { + for (const p of keepAliveSettlements) { + if (Math.hypot(p.x - x, p.y - y) <= radius) return true; + } + } + return false; + } + + function roadComponents() { + const occ = new Uint8Array(SIZE); + function markSegmented(path, fn) { + for (let k = 0; k < (path?.length || 0); k++) { + const [x0, y0] = path[k]; + const [x1, y1] = path[Math.min(k + 1, path.length - 1)]; + const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0))); + for (let s = 0; s <= steps; s++) { + const t = s / steps; + const x = Math.round(x0 + (x1 - x0) * t); + const y = Math.round(y0 + (y1 - y0) * t); + fn(x, y); + } + } + } + for (const group of roadGroups()) { + for (const path of group || []) { + markSegmented(path, (x, y) => { + if (inside(x, y) && !sea[indexOf(x, y)]) occ[indexOf(x, y)] = 1; + }); + } + } + const seen = new Uint8Array(SIZE); + const comps = []; + for (let i = 0; i < SIZE; i++) { + if (!occ[i] || seen[i]) continue; + const queue = [i]; + const cells = []; + seen[i] = 1; + let sx = 0, sy = 0; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + cells.push(cur); + const [x, y] = xyOf(cur); + sx += x; sy += y; + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + if (!dx && !dy) continue; + if (dx * dx + dy * dy > 5) continue; + const nx = x + dx, ny = y + dy; + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (!occ[ni] || seen[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + } + comps.push({ id: comps.length, cells, size: cells.length, cx: sx / Math.max(1, cells.length), cy: sy / Math.max(1, cells.length) }); + } + comps.sort((a, b) => b.size - a.size); + return comps; + } + function sampleComponent(comp, limit = 96) { + const step = Math.max(1, Math.floor(comp.cells.length / limit)); + const out = []; + for (let k = 0; k < comp.cells.length; k += step) { + const [x, y] = xyOf(comp.cells[k]); + out.push({ x, y, regionId: regionIdAt(x, y) }); + if (out.length >= limit) break; + } + return out; + } + function bestAnchorPair(a, b) { + const as = sampleComponent(a, 72); + const bs = sampleComponent(b, 72); + let best = null; + for (const pa of as) for (const pb of bs) { + const d = Math.hypot(pa.x - pb.x, pa.y - pb.y); + if (d < 1.5) continue; + if (!best || d < best.d) best = { a: pa, b: pb, d }; + } + return best; + } + function pathIsLand(path) { + return path?.length >= 2 && path.every(([x, y]) => inside(x, y) && !sea[indexOf(x, y)] && !highAltitudeRoadClosed(indexOf(x, y)) && transportFields.local[indexOf(x, y)] < INF); + } + let comps = roadComponents(); + debug.beforeComponents = comps.length; + for (let pass = 0; pass < maxAdds && comps.length > 1; pass++) { + const main = comps[0]; + let best = null; + const candidates = comps.slice(1, Math.min(comps.length, 18)); + for (const comp of candidates) { + const pair = bestAnchorPair(main, comp); + if (!pair) continue; + const coastBridgeRisk = pair.d > 18 && approximateLineCost(pair.a, pair.b, transportFields.local) >= INF; + if (coastBridgeRisk) continue; + const score = pair.d / Math.sqrt(Math.max(4, comp.size)); + if (!best || score < best.score) best = { comp, pair, score }; + } + if (!best) break; + const { a, b, d } = best.pair; + const penalty = cachedInfluenceFromPaths(minorRoads, 3, `all-road-connect:${pass}`); + if (d > 72) { + debug.failed++; + comps.splice(comps.indexOf(best.comp), 1); + comps.push(best.comp); + continue; + } + const routed = routeBetweenTrafficCandidates(a, b, "local", transportFields.local, penalty, { + curvePenalty: 0.055, + penaltyStrength: 0.55, + terrainFlowBias: 0.34, + surfaceGrain: 0.040, + relaxRadius: 1, + relaxLineWeight: 0.18, + maxPathLength: d * 2.05 + 24, + snapRadius: 0.5, + heuristicWeight: 0.50, + searchPad: Math.ceil(Math.max(12, Math.min(72, d * 0.45 + 10))), + }); + const rawPath = routed?.length ? routed : (d <= 12 ? directLandConnector(a, b) : []); + const connectedPath = rawPath?.length ? [[a.x, a.y], ...rawPath, [b.x, b.y]] : []; + const dedupedPath = []; + for (const pt of connectedPath) { + if (!dedupedPath.length || dedupedPath[dedupedPath.length - 1][0] !== pt[0] || dedupedPath[dedupedPath.length - 1][1] !== pt[1]) dedupedPath.push(pt); + } + const routeLen = pathLengthCells(dedupedPath); + const terrainOk = localRouteAcceptableStrict(dedupedPath, { maxLength: Math.min(76, Math.max(26, d * 2.10 + 24)) }) + && routeLen <= d * 2.15 + 24 + && maxSegmentLength(dedupedPath) <= 1.6 + && !routeTooStraightMountainOnly(dedupedPath); + if (!pathIsLand(dedupedPath) || !terrainOk) { + debug.failed++; + // Drop this candidate by marking it tiny relative to the main component for this pass. + comps.splice(comps.indexOf(best.comp), 1); + comps.push(best.comp); + continue; + } + const before = comps.length; + minorRoads.push(dedupedPath); + normalizeRoadGroups(); + comps = roadComponents(); + if (comps.length < before) debug.added++; + else debug.failed++; + } + for (let prunePass = 0; prunePass < 4; prunePass++) { + comps = roadComponents(); + if (comps.length <= 1) break; + const mainMask = new Uint8Array(SIZE); + for (const ci of comps[0].cells) { + const [cx, cy] = xyOf(ci); + for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) { + if (dx * dx + dy * dy > 5) continue; + const nx = cx + dx, ny = cy + dy; + if (inside(nx, ny)) mainMask[indexOf(nx, ny)] = 1; + } + } + function touchesMain(path) { + let hit = 0, n = 0; + for (let k = 0; k < (path?.length || 0); k++) { + const [x0, y0] = path[k]; + const [x1, y1] = path[Math.min(k + 1, path.length - 1)]; + const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0))); + for (let s = 0; s <= steps; s++) { + const t = s / steps; + const x = Math.round(x0 + (x1 - x0) * t); + const y = Math.round(y0 + (y1 - y0) * t); + if (!inside(x, y) || sea[indexOf(x, y)]) continue; + n++; + if (mainMask[indexOf(x, y)]) hit++; + } + } + return n > 0 && hit / n >= (prunePass === 0 ? 0.12 : 0.01); + } + function pruneGroup(group, key) { + const kept = []; + let pruned = 0; + for (const path of group || []) { + if (touchesMain(path) || pathNearKeptSettlement(path)) kept.push(path); + else pruned++; + } + group.length = 0; + group.push(...kept); + debug.prunedIsolated[key] = (debug.prunedIsolated[key] || 0) + pruned; + } + if (!debug.prunedIsolated) debug.prunedIsolated = { minor: 0, national: 0, external: 0, expressway: 0, externalExpressway: 0 }; + pruneGroup(minorRoads, "minor"); + pruneGroup(nationalRoads, "national"); + pruneGroup(externalRoads, "external"); + pruneGroup(expressways, "expressway"); + pruneGroup(externalExpressways, "externalExpressway"); + } + normalizeRoadGroups(); + // Absolute invariant for the rendered modern road graph: retain the largest + // terrain-valid component. Unconnectable orphan fragments are more harmful + // than being absent, because they force implausible long repairs. + let finalComps = roadComponents(); + if (finalComps.length > 1) { + const main = new Uint8Array(SIZE); + for (const ci of finalComps[0].cells) main[ci] = 1; + function pruneToMain(group, key) { + const kept = []; + let pruned = 0; + for (const path of group || []) { + let hit = 0; + let n = 0; + for (const [x, y] of path || []) { + if (!inside(x, y) || sea[indexOf(x, y)]) continue; + n++; + if (main[indexOf(x, y)]) hit++; + } + if (hit > 0 || n === 0 || pathNearKeptSettlement(path)) kept.push(path); + else pruned++; + } + group.length = 0; + group.push(...kept); + debug.prunedIsolated[key] = (debug.prunedIsolated[key] || 0) + pruned; + } + if (!debug.prunedIsolated) debug.prunedIsolated = { minor: 0, national: 0, external: 0, expressway: 0, externalExpressway: 0 }; + pruneToMain(minorRoads, "minor"); + pruneToMain(nationalRoads, "national"); + pruneToMain(externalRoads, "external"); + pruneToMain(expressways, "expressway"); + pruneToMain(externalExpressways, "externalExpressway"); + normalizeRoadGroups(); + finalComps = roadComponents(); + } + debug.afterComponents = finalComps.length; + return debug; + } + + + return { transportDebugLayers, corridorRoadNetworkDebug, runLocalAccessPass, stitchRasterNearContacts, stitchLongLocalBranches, sanitizeLocalRoads, downgradeShortNationalRoads, connectAllRoadNetworksFinal }; } diff --git a/mapTransportOD.js b/mapTransportOD.js new file mode 100644 index 0000000..30d1002 --- /dev/null +++ b/mapTransportOD.js @@ -0,0 +1,415 @@ +import { INF, MAP_H, MAP_W, SIZE, clamp, hash2, indexOf, inside, pickEntities } from "./mapUtils.js"; +import { makeUnionFind, pathAverageField, pathLengthCells } from "./mapTransportUtils.js"; + +// Phase 3: unified OD rail model +// -------------------------------- +// Rail is no longer generated from isolated potential-field strokes. It is +// derived from a single transport-node model: major cities, prefectural seats, +// ports, large market towns, and external gateways create OD demand; MST-style +// connectivity gives the skeleton; high-demand pairs add loops; lower-tier +// settlements receive short branch connections only when the trunk is nearby. + +export function buildUnifiedRailODNetwork(ctx) { + const { + seed, + sea, + elevation, + slope, + ridgeField, + valleyField, + basinField, + coastalLowland, + plain, + agriculture, + naturalBarrierScore, + passSuitability, + transportFields, + settlementDemand, + preliminaryUrbanInfluence, + preliminaryTownInfluence, + preliminaryVillageInfluence, + modernCities, + markets, + ports, + commercialPorts, + externalGateways, + geographicUrbanAnchors = [], + regionIdAt, + routeBetweenTrafficCandidates, + addCorridorInfluencePenalty, + transportRouteAcceptable, + pruneParallelSameMode, + cachedInfluenceFromPaths, + } = ctx; + + const railways = []; + const branchRailways = []; + const debug = { + version: "phase3-unified-rail-od-v1", + strategy: "OD nodes -> MST trunk -> demand loops -> short branches", + nodeCounts: {}, + trunkPairsConsidered: 0, + trunkPairsRouted: 0, + loopPairsRouted: 0, + branchPairsRouted: 0, + rejected: {}, + nodes: [], + trunkCorridors: [], + loopCorridors: [], + branchCorridors: [], + parallelPruning: null, + }; + + const reject = (reason) => { debug.rejected[reason] = (debug.rejected[reason] || 0) + 1; }; + + function fieldValue(field, i, fallback = 0) { + const v = field?.[i]; + return Number.isFinite(v) ? v : fallback; + } + + function railCostAt(x, y) { + if (!inside(x, y)) return INF; + const i = indexOf(x, y); + return sea[i] ? INF : transportFields.rail[i]; + } + + function populationProxy(p, fallback = 12000) { + if (!p) return fallback; + if (Number.isFinite(p.population) && p.population > 0) return p.population; + if (p.portClass === "major") return 85000; + if (p.portClass === "regional") return 42000; + const i = inside(p.x, p.y) ? indexOf(p.x, p.y) : -1; + if (i < 0) return fallback; + return Math.max(fallback, Math.round( + fieldValue(preliminaryUrbanInfluence, i) * 160000 + + fieldValue(preliminaryTownInfluence, i) * 65000 + + fieldValue(preliminaryVillageInfluence, i) * 15000 + + fieldValue(settlementDemand, i) * 45000 + )); + } + + function nearbyRailAnchor(point, role = "rail-node", options = {}) { + if (!point || !inside(point.x, point.y)) return null; + const inner = options.inner ?? 0; + const outer = options.outer ?? (role.includes("city") || role.includes("capital") ? 7 : role.includes("port") ? 8 : 5); + let best = null; + for (let dy = -outer; dy <= outer; dy++) { + for (let dx = -outer; dx <= outer; dx++) { + const x = Math.round(point.x + dx); + const y = Math.round(point.y + dy); + if (!inside(x, y)) continue; + const d = Math.hypot(dx, dy); + if (d < inner || d > outer) continue; + const i = indexOf(x, y); + if (sea[i] || transportFields.rail[i] >= INF) continue; + const density = fieldValue(settlementDemand, i); + const terrain = + fieldValue(transportFields.railPotential, i) * 1.35 + + fieldValue(preliminaryUrbanInfluence, i) * 0.42 + + fieldValue(preliminaryTownInfluence, i) * 0.46 + + valleyField[i] * 0.28 + + basinField[i] * 0.20 + + coastalLowland[i] * 0.20 + + plain[i] * 0.16 - + slope[i] * 1.10 - + ridgeField[i] * 0.72 - + Math.max(0, elevation[i] - 0.58) * 1.45 - + Math.max(0, density - 0.78) * 0.32; + const centerPenalty = d * (role.includes("city") || role.includes("capital") ? 0.025 : 0.060); + const score = terrain - centerPenalty + hash2(x, y, seed + 23101 + point.x * 3 + point.y * 7) * 0.035; + if (!best || score > best.score) best = { + x, + y, + score, + role, + source: point, + regionId: regionIdAt(x, y), + population: populationProxy(point), + name: point.name, + kind: point.kind, + portClass: point.portClass, + }; + } + } + return best; + } + + function dedupeNodes(nodes, minDistance = 5.5) { + const sorted = nodes + .filter((p) => p && inside(p.x, p.y) && !sea[indexOf(p.x, p.y)] && railCostAt(p.x, p.y) < INF) + .sort((a, b) => (b.score || 0) - (a.score || 0)); + const out = []; + for (const p of sorted) { + if (out.every((q) => Math.hypot(q.x - p.x, q.y - p.y) >= minDistance)) out.push(p); + } + return out; + } + + function lineStats(a, b, costField = transportFields.rail) { + const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y))); + let cost = 0; + let barrier = 0; + let high = 0; + let seaHits = 0; + let n = 0; + for (let s = 0; s <= steps; s++) { + const t = s / steps; + const x = Math.round(a.x + (b.x - a.x) * t); + const y = Math.round(a.y + (b.y - a.y) * t); + if (!inside(x, y)) { seaHits++; n++; continue; } + const i = indexOf(x, y); + if (sea[i] || costField[i] >= INF) { + seaHits++; + cost += 8; + barrier += 1; + n++; + continue; + } + cost += costField[i]; + barrier += clamp( + fieldValue(naturalBarrierScore, i) * 0.80 + + ridgeField[i] * 0.36 + + slope[i] * 0.42 + + Math.max(0, elevation[i] - 0.58) * 0.65 - + fieldValue(passSuitability, i) * 0.42 - + valleyField[i] * 0.12 + ); + if (elevation[i] > 0.68 || slope[i] > 0.48) high++; + n++; + } + return { + avgCost: n ? cost / n : INF, + barrier: n ? barrier / n : 1, + highShare: n ? high / n : 1, + seaShare: n ? seaHits / n : 1, + }; + } + + function odDemand(a, b, mode = "trunk") { + const d = Math.hypot(a.x - b.x, a.y - b.y); + const popDemand = Math.sqrt(Math.max(5000, a.population || 0) * Math.max(5000, b.population || 0)); + const roleBonus = + (a.role?.includes("capital") || b.role?.includes("capital") ? 0.24 : 0) + + (a.role?.includes("regional") || b.role?.includes("regional") ? 0.18 : 0) + + (a.role?.includes("port") || b.role?.includes("port") ? 0.14 : 0) + + (a.role?.includes("external") || b.role?.includes("external") ? 0.20 : 0); + const distanceBand = mode === "branch" + ? clamp(1 - Math.abs(d - 24) / 34) + : clamp(1 - Math.abs(d - 58) / 74); + return popDemand / (mode === "branch" ? 95000 : 145000) + roleBonus + distanceBand * (mode === "branch" ? 0.18 : 0.28); + } + + function pairScore(a, b, mode = "trunk") { + const d = Math.hypot(a.x - b.x, a.y - b.y); + const stats = lineStats(a, b); + if (stats.seaShare > 0.06) return null; + if (stats.highShare > (mode === "branch" ? 0.22 : 0.16)) return null; + const demand = odDemand(a, b, mode); + const crossRegion = a.regionId !== b.regionId ? 0.10 : 0; + const barrierPenalty = 1 + stats.barrier * (mode === "branch" ? 1.15 : 1.45) + stats.avgCost * 0.30 + stats.seaShare * 4.0; + const score = d * barrierPenalty / Math.max(0.18, demand + crossRegion); + return { a, b, d, demand, stats, score }; + } + + function routeRailPair(pair, penalty, branch = false) { + const path = routeBetweenTrafficCandidates(pair.a, pair.b, "rail", transportFields.rail, penalty, { + curvePenalty: branch ? 0.135 : 0.150, + penaltyStrength: branch ? 0.92 : 1.28, + terrainFlowBias: branch ? 0.16 : 0.13, + surfaceGrain: 0.010, + relaxRadius: 1, + relaxLineWeight: branch ? 0.44 : 0.50, + snapRadius: branch ? 2.4 : 2.8, + searchPad: Math.ceil(Math.max(22, Math.min(68, pair.d * 0.48))), + maxPathLength: pair.d * (branch ? 2.28 : 2.48) + (branch ? 18 : 36), + maxSeaRun: 1, + maxSeaShare: 0.006, + }); + const len = pathLengthCells(path); + if (len < (branch ? 5 : 14)) { reject(branch ? "branchTooShort" : "trunkTooShort"); return []; } + if (len > pair.d * (branch ? 2.36 : 2.58) + (branch ? 24 : 42)) { reject(branch ? "branchTooLong" : "trunkTooLong"); return []; } + if (!transportRouteAcceptable(path, "rail", transportFields.railPotential, penalty, { + minLength: branch ? 4 : 12, + maxLength: pair.d * (branch ? 2.40 : 2.66) + (branch ? 26 : 46), + maxCompactness: branch ? 2.85 : 2.65, + maxSteepShare: branch ? 0.24 : 0.20, + maxHighElevationShare: branch ? 0.04 : 0.02, + minAvgPotential: branch ? 0.06 : 0.10, + })) { reject(branch ? "branchQuality" : "trunkQuality"); return []; } + if (pathAverageField(path, transportFields.railPotential) < (branch ? 0.08 : 0.13) && pair.d > 24) { reject(branch ? "branchLowPotential" : "trunkLowPotential"); return []; } + return path; + } + + function keyOf(p) { return `${p.x},${p.y}`; } + + const regionalCityNodes = modernCities + .filter((c) => c.isRegionalCapital || c.isPrefecturalCapital || (c.population || 0) >= 90000) + .map((c) => nearbyRailAnchor(c, c.isRegionalCapital ? "regional-capital-rail" : c.isPrefecturalCapital ? "prefectural-capital-rail" : "major-city-rail", { outer: 8 })) + .filter(Boolean); + const secondaryCityNodes = modernCities + .filter((c) => !regionalCityNodes.some((n) => n.source === c) && (c.population || 0) >= 38000) + .map((c) => nearbyRailAnchor(c, "secondary-city-rail", { outer: 7 })) + .filter(Boolean); + const portNodes = [...commercialPorts, ...ports] + .filter((p, idx, arr) => arr.findIndex((q) => q.x === p.x && q.y === p.y) === idx) + .filter((p) => p.portClass === "major" || p.portClass === "regional" || (p.population || 0) >= 16000) + .map((p) => nearbyRailAnchor(p, "port-rail", { outer: 8 })) + .filter(Boolean); + const externalNodes = externalGateways + .map((g) => nearbyRailAnchor({ ...g, population: 60000 }, "external-rail-gateway", { outer: 5 })) + .filter(Boolean); + const anchorNodes = geographicUrbanAnchors + .filter((a) => (a.score || 0) > 0.76) + .slice(0, 8) + .map((a) => nearbyRailAnchor({ ...a, population: a.population || 52000 }, "geo-anchor-rail", { outer: 6 })) + .filter(Boolean); + + let trunkNodes = dedupeNodes([...regionalCityNodes, ...secondaryCityNodes, ...portNodes, ...externalNodes, ...anchorNodes], 7.5) + .sort((a, b) => (b.population || 0) - (a.population || 0)) + .slice(0, 34); + if (trunkNodes.length < 2) { + trunkNodes = dedupeNodes([ + ...modernCities.map((c) => nearbyRailAnchor(c, "fallback-city-rail", { outer: 8 })), + ...markets.filter((m) => (m.population || 0) >= 14000).map((m) => nearbyRailAnchor(m, "fallback-market-rail", { outer: 5 })), + ], 7).slice(0, 18); + } + debug.nodeCounts = { + regionalCityNodes: regionalCityNodes.length, + secondaryCityNodes: secondaryCityNodes.length, + portNodes: portNodes.length, + externalNodes: externalNodes.length, + geographicAnchorNodes: anchorNodes.length, + trunkNodes: trunkNodes.length, + }; + debug.nodes = trunkNodes.map((n) => ({ x: n.x, y: n.y, role: n.role, population: n.population, regionId: n.regionId })); + + const trunkPairs = []; + for (let a = 0; a < trunkNodes.length; a++) { + for (let b = a + 1; b < trunkNodes.length; b++) { + const A = trunkNodes[a]; + const B = trunkNodes[b]; + const d = Math.hypot(A.x - B.x, A.y - B.y); + if (d < 16 || d > 150) { reject("trunkDistanceEnvelope"); continue; } + const pair = pairScore(A, B, "trunk"); + if (!pair) { reject("trunkLineStats"); continue; } + trunkPairs.push(pair); + } + } + trunkPairs.sort((a, b) => a.score - b.score); + debug.trunkPairsConsidered = trunkPairs.length; + + const uf = makeUnionFind(trunkNodes, keyOf); + const penalty = new Float32Array(SIZE); + const maxTrunk = Math.min(18, Math.max(4, trunkNodes.length - 1)); + let connectedEdges = 0; + for (const pair of trunkPairs) { + if (connectedEdges >= maxTrunk) break; + const ak = keyOf(pair.a); + const bk = keyOf(pair.b); + if (uf.find(ak) === uf.find(bk)) continue; + const path = routeRailPair(pair, penalty, false); + if (!path.length) continue; + railways.push(path); + addCorridorInfluencePenalty(penalty, path, 9, 0.74); + uf.unite(ak, bk); + connectedEdges++; + debug.trunkPairsRouted++; + debug.trunkCorridors.push({ from: pair.a.role, to: pair.b.role, distance: Math.round(pair.d), demand: Math.round(pair.demand * 100) / 100, length: Math.round(pathLengthCells(path)), path }); + } + + // Add a few loops / redundant high-demand links after MST. These are the + // Shinkansen/main-line analogues around dense corridors and port approaches. + let loopAdded = 0; + const trunkInfluenceBeforeLoops = cachedInfluenceFromPaths(railways, 5, "rail-od:trunk-before-loops"); + for (const pair of trunkPairs) { + if (loopAdded >= Math.min(7, Math.max(2, Math.ceil(trunkNodes.length / 5)))) break; + const ai = indexOf(pair.a.x, pair.a.y); + const bi = indexOf(pair.b.x, pair.b.y); + if ((trunkInfluenceBeforeLoops[ai] || 0) > 0.62 && (trunkInfluenceBeforeLoops[bi] || 0) > 0.62 && pair.demand < 0.88) continue; + if (pair.score > 92 && pair.demand < 0.78) continue; + const path = routeRailPair(pair, penalty, false); + if (!path.length) continue; + railways.push(path); + addCorridorInfluencePenalty(penalty, path, 11, 0.66); + loopAdded++; + debug.loopPairsRouted++; + debug.loopCorridors.push({ from: pair.a.role, to: pair.b.role, distance: Math.round(pair.d), demand: Math.round(pair.demand * 100) / 100, length: Math.round(pathLengthCells(path)), path }); + } + + const railInfluence = cachedInfluenceFromPaths(railways, 8, "rail-od:trunk-for-branches"); + const branchCandidates = dedupeNodes([ + ...modernCities + .filter((c) => (c.population || 0) >= 22000 && (c.population || 0) < 90000) + .map((c) => nearbyRailAnchor(c, "branch-city-rail", { outer: 6 })), + ...markets + .filter((m) => (m.population || 0) >= 16000) + .map((m) => nearbyRailAnchor(m, "branch-market-rail", { outer: 5 })), + ...ports + .filter((p) => p.portClass === "regional" || (p.population || 0) >= 10000) + .map((p) => nearbyRailAnchor(p, "branch-port-rail", { outer: 7 })), + ], 6).sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, 36); + + const trunkTargets = []; + for (const path of railways) { + const stride = Math.max(5, Math.floor(path.length / 18)); + for (let k = 0; k < path.length; k += stride) { + const [x, y] = path[k]; + if (inside(x, y) && !sea[indexOf(x, y)]) trunkTargets.push({ x, y, role: "rail-trunk-cell", population: 70000, score: 0.8, regionId: regionIdAt(x, y) }); + } + } + trunkTargets.push(...trunkNodes); + + let branchAdded = 0; + for (const node of branchCandidates) { + if (branchAdded >= 14) break; + const ni = indexOf(node.x, node.y); + if ((railInfluence[ni] || 0) > 0.34) continue; + const options = trunkTargets + .map((q) => { + const d = Math.hypot(q.x - node.x, q.y - node.y); + if (d < 8 || d > 54) return null; + const pair = pairScore(node, q, "branch"); + if (!pair) return null; + return pair; + }) + .filter(Boolean) + .sort((a, b) => a.score - b.score); + for (const pair of options.slice(0, 5)) { + if (odDemand(pair.a, pair.b, "branch") < 0.34 && pair.d > 32) { reject("branchWeakDemand"); continue; } + const path = routeRailPair(pair, penalty, true); + if (!path.length) continue; + branchRailways.push(path); + addCorridorInfluencePenalty(penalty, path, 6, 0.42); + branchAdded++; + debug.branchPairsRouted++; + debug.branchCorridors.push({ from: pair.a.role, to: pair.b.role, distance: Math.round(pair.d), demand: Math.round(pair.demand * 100) / 100, length: Math.round(pathLengthCells(path)), path }); + break; + } + } + + debug.parallelPruning = pruneParallelSameMode([...railways, ...branchRailways], "rail", transportFields.railPotential, { + minKeep: Math.min(3, railways.length), + radius: 2, + threshold: 0.62, + shortLength: 24, + }); + // pruneParallelSameMode mutates only the temporary array above, so repeat a + // conservative in-place pass per layer to preserve trunk/branch classification. + debug.trunkParallelPruning = pruneParallelSameMode(railways, "rail", transportFields.railPotential, { + minKeep: 2, + radius: 2, + threshold: 0.66, + shortLength: 30, + }); + debug.branchParallelPruning = pruneParallelSameMode(branchRailways, "rail", transportFields.railPotential, { + minKeep: 0, + radius: 2, + threshold: 0.70, + shortLength: 18, + }); + + debug.finalRailwayCount = railways.length; + debug.finalBranchRailwayCount = branchRailways.length; + + return { railways, branchRailways, debug }; +} diff --git a/mapTransportUtils.js b/mapTransportUtils.js new file mode 100644 index 0000000..870beaf --- /dev/null +++ b/mapTransportUtils.js @@ -0,0 +1,229 @@ +import { SIZE, clamp, indexOf, inside } from "./mapUtils.js"; + +export function pathSetSignature(paths) { + let cells = 0; + let endpoints = 0; + for (const path of paths || []) { + cells += path?.length || 0; + const a = path?.[0]; + const b = path?.[path.length - 1]; + if (a) endpoints = (endpoints + a[0] * 17 + a[1] * 31) | 0; + if (b) endpoints = (endpoints + b[0] * 47 + b[1] * 73) | 0; + } + return `${paths?.length || 0}:${cells}:${endpoints >>> 0}`; +} + +export function createPathInfluenceCache(influenceFromPaths) { + const cache = new Map(); + return (paths, radius, label = "paths") => { + const key = `${label}:${radius}:${pathSetSignature(paths)}`; + let grid = cache.get(key); + if (!grid) { + grid = influenceFromPaths(paths, radius); + cache.set(key, grid); + } + return grid; + }; +} + +export function packDebugField(field) { + const out = new Uint8Array(SIZE); + for (let i = 0; i < SIZE; i++) out[i] = Math.round(clamp(field?.[i] || 0) * 255); + return out; +} + +export function pathLengthCells(path) { + let total = 0; + for (let i = 1; i < (path?.length || 0); i++) { + total += Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]); + } + return total; +} + +export function pathAverageField(path, field) { + if (!path?.length || !field) return 0; + let sum = 0; + let n = 0; + for (const [x, y] of path) { + if (!inside(x, y)) continue; + sum += field[indexOf(x, y)] || 0; + n++; + } + return n ? sum / n : 0; +} + +export function routeQualityStats(path, fields = {}) { + if (!path?.length) return { length: 0, compactness: Infinity, highElevationShare: 1, steepShare: 1, waterShare: 1, avgPotential: 0, avgPenalty: 0 }; + const length = pathLengthCells(path); + const first = path[0]; + const last = path[path.length - 1]; + const direct = first && last ? Math.hypot(first[0] - last[0], first[1] - last[1]) : 0; + let high = 0; + let steep = 0; + let water = 0; + let potential = 0; + let penalty = 0; + let n = 0; + for (const [x, y] of path) { + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (fields.sea?.[i]) water++; + if ((fields.elevation?.[i] || 0) > (fields.highElevationThreshold ?? 0.72)) high++; + if ((fields.slope?.[i] || 0) > (fields.steepThreshold ?? 0.44)) steep++; + potential += fields.potential?.[i] || 0; + penalty += fields.penalty?.[i] || 0; + n++; + } + return { + length, + compactness: direct > 0.001 ? length / direct : Infinity, + highElevationShare: high / Math.max(1, n), + steepShare: steep / Math.max(1, n), + waterShare: water / Math.max(1, n), + avgPotential: potential / Math.max(1, n), + avgPenalty: penalty / Math.max(1, n), + }; +} + +export function routeQualityAcceptable(path, fields = {}, limits = {}) { + const q = routeQualityStats(path, fields); + if (q.length < (limits.minLength ?? 2)) return false; + if (q.length > (limits.maxLength ?? Infinity)) return false; + if (q.compactness > (limits.maxCompactness ?? 3.2)) return false; + if (q.highElevationShare > (limits.maxHighElevationShare ?? 0.0)) return false; + if (q.steepShare > (limits.maxSteepShare ?? 0.38)) return false; + if (q.waterShare > (limits.maxWaterShare ?? 0)) return false; + if (q.avgPenalty > (limits.maxAvgPenalty ?? Infinity) && q.avgPotential < (limits.minPotentialIfPenalty ?? 0.35)) return false; + if (q.avgPotential < (limits.minAvgPotential ?? 0)) return false; + return true; +} + +export const TRANSPORT_ROUTE_POLICIES = { + mountain: { + road: { maxHighAltitudeShare: 0, longLength: 72, longStraightness: 0.90, maxLongMountain: 0.34, extremeLength: 92, extremeStraightness: 0.86, maxExtremeBoundary: 0.20 }, + national: { maxHighAltitudeShare: 0, longLength: 72, longStraightness: 0.90, maxLongMountain: 0.34, extremeLength: 92, extremeStraightness: 0.86, maxExtremeBoundary: 0.20 }, + local: { maxHighAltitudeShare: 0.04, longLength: 72, longStraightness: 0.90, maxLongMountain: 0.34, extremeLength: 92, extremeStraightness: 0.86, maxExtremeBoundary: 0.20 }, + expressway: { maxHighAltitudeShare: 0, maxDenseShare: 0.12, maxCityCoreShare: 0.11, maxVillageCoreShare: 0.08, longLength: 72, longStraightness: 0.90, maxLongMountain: 0.34, extremeLength: 92, extremeStraightness: 0.86, maxExtremeBoundary: 0.20 }, + expresswayMountainOnly: { maxHighAltitudeShare: 0, longLength: 72, longStraightness: 0.90, maxLongMountain: 0.36, extremeLength: 96, extremeStraightness: 0.86, maxExtremeBoundary: 0.22 }, + }, + expresswayAcceptance: { + strict: { maxCityCoreHits: 0, maxVillageHits: 2, maxDenseShare: 0.16, maxCityCoreShare: 0.13, maxVillageCoreShare: 0.11, maxHighAltitudeShare: 0, maxMountain: 0.64, maxBoundary: 0.44 }, + fallback: { maxCityCoreHits: 0, maxVillageHits: 6, maxDenseShare: 0.24, maxCityCoreShare: 0.20, maxVillageCoreShare: 0.20, maxHighAltitudeShare: 0, maxMountain: 0.74, maxBoundary: 0.56 }, + approach: { maxCityCoreHits: 5, maxVillageHits: 18, maxDenseShare: 0.34, maxCityCoreShare: 0.30, maxVillageCoreShare: 0.32, maxHighAltitudeShare: 0, maxMountain: 0.90, maxBoundary: 0.72 }, + }, + fieldBackbone: { + expressway: { minLength: 18, maxLengthMultiplier: 2.85, maxLengthAdd: 92, parallelConnected: 0.075, parallelExtra: 0.030 }, + national: { minLength: 5, maxLengthMultiplier: 2.65, maxLengthAdd: 48, maxHighElevationShare: 0.34, maxSteepShare: 0.48, parallelConnected: 0.54, parallelExtra: 0.44 }, + }, +}; + +export function routeGeometry(path) { + if (!path || path.length < 2) return { len: 0, direct: 0, straightness: 1 }; + const len = pathLengthCells(path); + const a = path[0]; + const b = path[path.length - 1]; + const direct = Math.hypot(a[0] - b[0], a[1] - b[1]); + return { len, direct, straightness: direct / Math.max(1, len) }; +} + +export function assessMountainRoute(path, mode, pathTerrainRisk, policies = TRANSPORT_ROUTE_POLICIES) { + if (!path || path.length < 2) return { ok: false, reason: "empty" }; + const policy = policies.mountain[mode] || policies.mountain.road; + const { len, straightness } = routeGeometry(path); + const risk = pathTerrainRisk(path); + if (risk.highAltitudeShare > policy.maxHighAltitudeShare) return { ok: false, reason: "highAltitude", risk }; + if (policy.maxDenseShare !== undefined && (risk.denseShare > policy.maxDenseShare || risk.cityCoreShare > policy.maxCityCoreShare || risk.villageCoreShare > policy.maxVillageCoreShare)) { + return { ok: false, reason: "settlementCore", risk }; + } + if (len > policy.longLength && straightness > policy.longStraightness && risk.mountain > policy.maxLongMountain) return { ok: false, reason: "straightMountain", risk }; + if (len > policy.extremeLength && straightness > policy.extremeStraightness && risk.boundary > policy.maxExtremeBoundary) return { ok: false, reason: "straightBoundary", risk }; + return { ok: true, reason: "ok", risk }; +} + +export function expresswayAcceptancePolicy(options = {}, policies = TRANSPORT_ROUTE_POLICIES) { + if (options.allowApproach) return policies.expresswayAcceptance.approach; + if (options.allowFallback) return policies.expresswayAcceptance.fallback; + return policies.expresswayAcceptance.strict; +} + +export function assessExpresswayRoute(path, options, pathTerrainRisk, expresswayProximityRisk, policies = TRANSPORT_ROUTE_POLICIES) { + const risk = pathTerrainRisk(path); + const prox = expresswayProximityRisk(path); + const policy = expresswayAcceptancePolicy(options, policies); + if (prox.cityCoreHits > policy.maxCityCoreHits) return { ok: false, reason: "cityCoreHits", risk, prox }; + if (prox.villageHits > policy.maxVillageHits) return { ok: false, reason: "villageHits", risk, prox }; + if (risk.denseShare > policy.maxDenseShare) return { ok: false, reason: "denseShare", risk, prox }; + if (risk.cityCoreShare > policy.maxCityCoreShare) return { ok: false, reason: "cityCoreShare", risk, prox }; + if (risk.villageCoreShare > policy.maxVillageCoreShare) return { ok: false, reason: "villageCoreShare", risk, prox }; + if (risk.highAltitudeShare > policy.maxHighAltitudeShare) return { ok: false, reason: "highAltitude", risk, prox }; + if (risk.mountain > policy.maxMountain) return { ok: false, reason: "mountain", risk, prox }; + if (risk.boundary > policy.maxBoundary) return { ok: false, reason: "boundary", risk, prox }; + return { ok: true, reason: "ok", risk, prox }; +} + +export function countReason(stats, bucket, reason) { + if (!stats[bucket]) stats[bucket] = {}; + stats[bucket][reason] = (stats[bucket][reason] || 0) + 1; +} + +export function fieldBackbonePolicy(mode, policies = TRANSPORT_ROUTE_POLICIES) { + return policies.fieldBackbone[mode] || policies.fieldBackbone.national; +} + +export function squaredDistance(a, b, x, y) { + const dx = a - x; + const dy = b - y; + return dx * dx + dy * dy; +} + +export function makeSpatialIndex(points, cellSize = 16) { + const buckets = new Map(); + const bucketKey = (x, y) => `${Math.floor(x / cellSize)},${Math.floor(y / cellSize)}`; + for (const point of points || []) { + if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) continue; + const key = bucketKey(point.x, point.y); + let bucket = buckets.get(key); + if (!bucket) { + bucket = []; + buckets.set(key, bucket); + } + bucket.push(point); + } + return { + near(x, y, radius) { + const out = []; + const bx0 = Math.floor((x - radius) / cellSize); + const bx1 = Math.floor((x + radius) / cellSize); + const by0 = Math.floor((y - radius) / cellSize); + const by1 = Math.floor((y + radius) / cellSize); + for (let by = by0; by <= by1; by++) { + for (let bx = bx0; bx <= bx1; bx++) { + const bucket = buckets.get(`${bx},${by}`); + if (bucket) out.push(...bucket); + } + } + return out; + }, + }; +} + +export function makeUnionFind(nodes, keyOf) { + const parent = new Map(); + const find = (key) => { + let root = parent.get(key) || key; + if (root !== key) { + root = find(root); + parent.set(key, root); + } + return root; + }; + const unite = (a, b) => { + const ra = find(a); + const rb = find(b); + if (ra === rb) return false; + parent.set(rb, ra); + return true; + }; + for (const node of nodes || []) parent.set(keyOf(node), keyOf(node)); + return { find, unite }; +} diff --git a/names.js b/names.js index 4a089d2..2d8967e 100644 --- a/names.js +++ b/names.js @@ -1,24 +1,13 @@ -import { MAP_H, MAP_W, hash2, indexOf, inside } from "./mapUtils.js"; +import { MAP_H, MAP_W, hash2, indexOf, inside } from "./mapUtils.js"; -export const CUSTOM_NAME_LIST = ["加茂", "春日", "新庄", "常盤", "追分", "清水", "曙", "暁", "旭", "鳥羽", "長門", "吉野", "吉田", "美郷", "美里", "美山", "八幡", "相生",]; - -function nameCharCount(value) { - return Array.from(String(value || "")).length; -} - -function isAtomicNamePart(value) { - // The generator used to create visibly synthetic three-part names by joining - // a prefix with a compound terrain word. For template generation, keep each - // lexical slot atomic so a generated root is at most two visible elements. - return nameCharCount(value) <= 1; -} +export const CUSTOM_NAME_LIST = ["加茂", "瑞穂", "天神", "弁天", "千歳", "朝日", "春日", "新庄", "常盤", "追分", "清水", "曙", "暁", "旭", "鳥羽", "長門", "吉野", "吉田", "美郷", "美里", "美山"]; export const NAME_KANJI_POOLS = { modifiers: [ "大", "小", "上", "下", "中", "奥", "脇", "東", "西", "南", "北", "新", "古", "本", - "高", "長", "広", "深", "浅", "明", "重", "荒", + "高", "長", "広", "深", "浅", "明", "重", "荒", "富", "白", "黒", "青", "赤", "藍", "奥", "前", "後", "内", "外", "美", "吉", "福", "幸", "徳", @@ -26,7 +15,7 @@ export const NAME_KANJI_POOLS = { "霞", "朝", "日", "天", "土", "砂", "石", "岩", "卯", "辰", - "駒", "羽", "鳴", "横", "永", "早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", "徳", "栄" + "串","駒", "来", "武", "箱", "羽", "鳴", "横", "永", "早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", "徳", "栄" ], inlandTerrain: [ @@ -37,7 +26,7 @@ export const NAME_KANJI_POOLS = { "塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", "植", "生", "郷", "里", "馬", "鹿", "亀", "鷲", "鷹", "鶴", "鴨", "竜", "龍", "牛", "鳥", - "湯", "宍", + "湯", ], waterTerrain: [ @@ -60,20 +49,20 @@ export const NAME_KANJI_POOLS = { "松", "杉", "桜", "梅", "栗", "竹", "楠", "藤", "萩", "葦", "菅", "榎", "椿", "桐", "柳", - "橘", "柏", "槙", "柿", "桃", "稲", "花", "草", "菊", + "橘", "柏", "槙", "柿", "桃", "稲", "梨", "桑", "麻", "芦", "茅", "根", - "粟", "稲", "稗", "米", "飯", "糠", "茜", "葵", "篠", + "粟", "稲", "稗", "米", "飯", "糠", "茜", "葵", "篠", "芝", "柴", "榊", "楢", "檜", "椎", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑", "瓜" ], postfixes: [ "田", "川", "山", "岡", "森", "林", - "島", + "島", "尻", "江", "瀬", "井", "戸", "口", "辺", "里", "郷", "村", "町", "宿", "庄", "台", "坂", "橋", "明", - "本", "内", "窪", "平", "塚", "根", - "畑", "牧", "前", "見", "中", "羽", "生", "駒", "塚", "部", "栄", "永", "平", + "本", "内", "窪", "平", "塚", "根", "串", + "畑", "牧", "前", "見", "中", "羽", "生", "駒", "来", "富", "塚", "部", "栄", "永", "平", ], archaicPrefixes: [ @@ -88,7 +77,7 @@ export const NAME_KANJI_POOLS = { "和", "輪", "出", "播", "但", "因", "伯", "筑", "肥", "豊", - "日", "紀", "志", "尾", "駿", + "日", "紀", "志", "尾", "甲", "信", "越", "備", "能", "薩", "隠", "美", "三", "若", "遠", "近", "能", "加", "賀", "度", "飾", @@ -117,7 +106,7 @@ export const NAME_KANJI_POOLS = { ], settlementWords: [ - "里", "郷", "村", "町", "宿", "垣", "坪", "軒", "惣", "條", + "里", "郷", "村", "町", "宿", "垣", "坪", "軒", "惣", "庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂", "城", "館", "屋", "家", "所", "市", "場", "関", "地蔵", "辻", "角", "堰", "通", "路", "道", "筋", @@ -429,8 +418,7 @@ export function createNameDebug(probabilities = NAME_PROBABILITIES, pools = NAME customNameListUsed: 0, invalidNamesRejected: 0, repeatedKanjiNamesRejected: 0, - oneCharacterNamesPrevented: 0, - rejectedOneCharacterNames: 0, + shortNamesRejected: 0, duplicateRetries: 0, fallbackAttempts: 0, legacyFallbackUsed: 0, @@ -480,9 +468,8 @@ export function validateGeneratedName(name, options = {}) { if (!options.allowAsciiDiagnostic && value.startsWith(ASCII_DIAGNOSTIC_PREFIX) && /^N[0-9A-Z]+$/.test(value)) { return { valid: false, reason: "asciiDiagnostic" }; } - if (!options.allowOneCharacter && length < 2) return { valid: false, reason: "oneCharacter" }; + if (Number.isFinite(options.minLength) && length < options.minLength) return { valid: false, reason: "tooShort" }; if (Number.isFinite(options.maxLength) && length > options.maxLength) return { valid: false, reason: "tooLong" }; - if (!options.allowLong && length > 4) return { valid: false, reason: "tooLong" }; if (!options.allowRepeatedKanji && hasRepeatedKanji(value)) return { valid: false, reason: "repeatedKanji" }; return { valid: true, reason: "valid" }; } @@ -500,8 +487,6 @@ function generateTemplateNameDetails(seed, id, entity, fields, attempt, usedName const context = chooseNameContext(entity, fields); const templateKey = chooseTemplate(context, seed, id, attempt); const template = NAME_TEMPLATES[templateKey]; - // Do not generate old-style three-part random toponyms; names are now either - // curated list entries or at most two lexical elements plus any administrative suffix. if (!template || (template.slots?.length || 0) > 2) return { name: null, context, templateKey: null }; const parts = []; @@ -509,15 +494,13 @@ function generateTemplateNameDetails(seed, id, entity, fields, attempt, usedName const slot = template.slots[slotIndex]; const slotPool = resolveSlotPool(slot, context, pools, probabilities, seed, id, attempt + slotIndex); if (!slotPool?.pool?.length) return { name: null, context, templateKey }; - const atomicPool = slotPool.pool.filter(isAtomicNamePart); - if (!atomicPool.length) return { name: null, context, templateKey }; - const part = pick(atomicPool, seed, id, attempt, 2503 + slotIndex * 127 + stableHash(slotPool.key)); + const part = pick(slotPool.pool, seed, id, attempt, 2503 + slotIndex * 127 + stableHash(slotPool.key)); if (!part) return { name: null, context, templateKey }; parts.push(part); } const name = parts.join(""); - const validation = validateGeneratedName(name, { allowLong: false, maxLength: 2 }); + const validation = validateGeneratedName(name); if (!validation.valid) return { name: null, context, templateKey, invalidReason: validation.reason }; if (usedNames?.has(name)) return { name: null, context, templateKey, duplicate: true }; return { name, context, templateKey }; @@ -548,15 +531,10 @@ function tryCustomNameList(seed, id, usedNames, debug) { const start = Math.floor(roll(seed, id, 0, 3539) * CUSTOM_NAME_LIST.length) % CUSTOM_NAME_LIST.length; for (let offset = 0; offset < CUSTOM_NAME_LIST.length; offset++) { const customName = CUSTOM_NAME_LIST[(start + offset) % CUSTOM_NAME_LIST.length]; - if (nameCharCount(customName) > 2) { - debug.invalidNamesRejected++; - continue; - } const validation = validateGeneratedName(customName, { allowAsciiDiagnostic: true }); if (!validation.valid) { - if (validation.reason === "oneCharacter") { - debug.oneCharacterNamesPrevented++; - debug.rejectedOneCharacterNames++; + if (validation.reason === "tooShort") { + debug.shortNamesRejected++; } else if (validation.reason === "repeatedKanji") { debug.repeatedKanjiNamesRejected++; } else { @@ -589,9 +567,8 @@ export function generateEntityName(seed, id, entity, fields, usedNames = null, d continue; } if (result.invalidReason) { - if (result.invalidReason === "oneCharacter") { - debug.oneCharacterNamesPrevented++; - debug.rejectedOneCharacterNames++; + if (result.invalidReason === "tooShort") { + debug.shortNamesRejected++; } else if (result.invalidReason === "repeatedKanji") debug.repeatedKanjiNamesRejected++; else debug.invalidNamesRejected++; diff --git a/renderer.js b/renderer.js index 3cd3e60..276fcae 100644 --- a/renderer.js +++ b/renderer.js @@ -1,4 +1,4 @@ -import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf } from "./mapUtils.js"; +import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf, inside } from "./mapUtils.js"; const segmentVectorCache = new WeakMap(); @@ -208,15 +208,24 @@ function vectorPath(path) { if (cached) return cached; const points = path.map(([x, y]) => [x * CELL_SIZE + CELL_SIZE / 2, y * CELL_SIZE + CELL_SIZE / 2]); - // Transport routes are already cost-routed on the raster grid. A large RDP - // tolerance erases those small valley/contour bends and makes roads look like - // ruler-straight overlays, so smooth first and simplify only lightly. - const smoothedBase = chaikin(points, path.length > 8 ? 1 : 0, false); - const simplified = simplifyRdp(smoothedBase, CELL_SIZE * 0.14); + // Transport routes are raster-routed, so shallow diagonal corridors can look + // like stair steps. Two light Chaikin passes remove that visual artifact + // while a small RDP tolerance keeps valley and coastline bends intact. + const smoothIterations = path.length > 12 ? 2 : path.length > 6 ? 1 : 0; + const smoothedBase = chaikin(points, smoothIterations, false); + const simplified = simplifyRdp(smoothedBase, CELL_SIZE * 0.10); pathVectorCache.set(path, simplified); return simplified; } +function vectorPathMode(path, mode = "default") { + if (mode !== "expressway") return vectorPath(path); + if (!path || path.length < 2) return []; + const points = path.map(([x, y]) => [x * CELL_SIZE + CELL_SIZE / 2, y * CELL_SIZE + CELL_SIZE / 2]); + const smoothIterations = path.length > 18 ? 3 : path.length > 8 ? 2 : 1; + return simplifyRdp(chaikin(points, smoothIterations, false), CELL_SIZE * 0.18); +} + function drawPolylinePoints(ctx, points) { if (!points || points.length < 2) return; ctx.moveTo(points[0][0], points[0][1]); @@ -249,13 +258,31 @@ function sampleCellIndex(fx, fy) { return indexOf(x, y); } +function seaCoverageSample(map, fx, fy) { + if (!map?.sea) return 0; + // Coastlines are raster-derived, but the renderer should not expose the raw + // cell stair-steps. Sample a small footprint around each pixel and blend the + // land/sea color at the edge; this keeps the mask stable while giving the + // visible coastline a vector-like anti-aliased curve. + const offsets = [ + [0, 0], [-0.34, 0], [0.34, 0], [0, -0.34], [0, 0.34], + [-0.26, -0.26], [0.26, -0.26], [-0.26, 0.26], [0.26, 0.26], + ]; + let sum = 0; + for (const [ox, oy] of offsets) sum += fieldSample(map.sea, fx + ox, fy + oy); + return clamp(sum / offsets.length); +} + function isWaterSample(map, fx, fy) { - // The generated terrain arrays are cell-centered, while pixels are drawn across - // each cell. Water/land classification must therefore follow the discrete sea - // mask, not the interpolated elevation value. Interpolating elevation near a - // coast makes the right/bottom side of land cells inherit sea values and leaves - // visible unpainted strips inside the smoothed coastline. - return Boolean(map.sea[sampleCellIndex(fx, fy)]); + return seaCoverageSample(map, fx, fy) >= 0.50; +} + +function mixRgb(a, b, t) { + return [ + Math.round(a[0] + (b[0] - a[0]) * t), + Math.round(a[1] + (b[1] - a[1]) * t), + Math.round(a[2] + (b[2] - a[2]) * t), + ]; } @@ -315,16 +342,17 @@ function terrainColorContinuous(map, fx, fy, mode) { let color; - if (isWaterSample(map, fx, fy)) { - const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map.elevation, fx, fy)) * 2.4); - // Calm sky-blue water; less saturated than the previous bright cyan. - color = [Math.round(174 - depth * 7), Math.round(203 + depth * 2), Math.round(224 + depth * 5)]; - } else if (mode === "development") { + const waterCoverage = seaCoverageSample(map, fx, fy); + const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map.elevation, fx, fy)) * 2.4); + const waterColor = [Math.round(174 - depth * 7), Math.round(203 + depth * 2), Math.round(224 + depth * 5)]; + + let landColor; + if (mode === "development") { const dCity = distToNearest(map.modernCities, fx, fy); const urban = clamp(1 - dCity / 25); const density = map.populationDensity ? fieldSample(map.populationDensity, fx, fy) : urban; const base = 235; - color = [ + landColor = [ Math.round(base + density * 20), Math.round(base + density * 5), Math.round(230 + density * 10), @@ -333,7 +361,7 @@ function terrainColorContinuous(map, fx, fy, mode) { // 地形の基底色は標高のみに従わせる。 // 谷や微地形の見え方は陰影側で制御し、谷底だけが不自然に茶色化しないようにする。 const e = fieldSample(map.elevation, fx, fy); - color = interpolateColorStops(clamp(e), [ + landColor = interpolateColorStops(clamp(e), [ [0.20, [231, 236, 223]], [0.30, [223, 231, 214]], [0.40, [213, 223, 201]], @@ -351,6 +379,8 @@ function terrainColorContinuous(map, fx, fy, mode) { ]); } + const coastBlend = clamp((waterCoverage - 0.36) / 0.28); + color = coastBlend > 0 ? mixRgb(landColor, waterColor, coastBlend) : landColor; return blendOutside(color, isInside); } @@ -515,8 +545,8 @@ function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) { ctx.restore(); } -function drawPath(ctx, path, color, width, dashed = false) { - const points = vectorPath(path); +function drawPath(ctx, path, color, width, dashed = false, mode = "default") { + const points = vectorPathMode(path, mode); if (points.length < 2) return; ctx.save(); ctx.lineCap = "round"; @@ -530,6 +560,119 @@ function drawPath(ctx, path, color, width, dashed = false) { ctx.restore(); } +function landOnlySubpaths(map, path, minCells = 2) { + if (!path || path.length < 2 || !map?.sea) return path?.length >= minCells ? [path] : []; + const chunks = []; + let cur = []; + for (const p of path) { + const [x, y] = p; + const land = inside(x, y) && !map.sea[indexOf(x, y)]; + if (land) { + cur.push(p); + } else if (cur.length >= minCells) { + chunks.push(cur); + cur = []; + } else { + cur = []; + } + } + if (cur.length >= minCells) chunks.push(cur); + return chunks; +} + +function drawLandPath(ctx, map, path, color, width, dashed = false, minCells = 2, mode = "default") { + for (const chunk of landOnlySubpaths(map, path, minCells)) drawPath(ctx, chunk, color, width, dashed, mode); +} + +function specialTransportSubpaths(map, path, predicate, minCells = 1, includeShoulders = true, maxCoreCells = Infinity) { + if (!path || path.length < 2) return []; + const chunks = []; + let cur = []; + let core = 0; + function flush(nextPoint = null) { + if (cur.length && nextPoint && includeShoulders) cur.push(nextPoint); + if (cur.length >= Math.max(2, minCells) && core <= maxCoreCells) chunks.push(cur); + cur = []; + core = 0; + } + for (let idx = 0; idx < path.length; idx++) { + const [x, y] = path[idx]; + const i = inside(x, y) ? indexOf(x, y) : -1; + const hit = i >= 0 && predicate(i, x, y); + if (hit) { + if (!cur.length && includeShoulders && idx > 0) cur.push(path[idx - 1]); + cur.push(path[idx]); + core++; + } else if (cur.length) { + flush(path[idx]); + } + } + flush(null); + return chunks; +} + +function drawOffsetPolyline(ctx, points, offsetPx) { + if (!points || points.length < 2) return; + ctx.beginPath(); + for (let i = 0; i < points.length; i++) { + const prev = points[Math.max(0, i - 1)]; + const cur = points[i]; + const next = points[Math.min(points.length - 1, i + 1)]; + const dx = next[0] - prev[0]; + const dy = next[1] - prev[1]; + const len = Math.hypot(dx, dy) || 1; + const ox = -dy / len * offsetPx; + const oy = dx / len * offsetPx; + if (i === 0) ctx.moveTo(cur[0] + ox, cur[1] + oy); + else ctx.lineTo(cur[0] + ox, cur[1] + oy); + } + ctx.stroke(); +} + +function drawDottedOutlinePath(ctx, path, color, width, offsetPx, mode = "default") { + const points = vectorPathMode(path, mode); + if (points.length < 2) return; + ctx.save(); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.strokeStyle = color; + ctx.lineWidth = width; + ctx.setLineDash([1.8, 3.2]); + drawOffsetPolyline(ctx, points, offsetPx); + drawOffsetPolyline(ctx, points, -offsetPx); + ctx.restore(); +} + +function drawBridgeOverlay(ctx, map, path, width, mode = "road") { + const limit = mode === "expressway" ? 20 : 10; + const bridgeChunks = specialTransportSubpaths(map, path, (i) => map.sea?.[i], 2, true, limit); + for (const chunk of bridgeChunks) { + const vectorMode = mode === "expressway" ? "expressway" : "default"; + drawPath(ctx, chunk, "rgba(255,255,255,0.98)", width + 2.0, false, vectorMode); + drawPath(ctx, chunk, mode === "expressway" ? "rgba(135, 160, 135, 0.95)" : "rgba(245, 225, 130, 1)", width + 0.2, false, vectorMode); + drawDottedOutlinePath(ctx, chunk, "rgba(55, 85, 130, 0.95)", 1.0, Math.max(1.8, width * 0.72), vectorMode); + } +} + +function drawTunnelOverlay(ctx, map, path, width, mode = "road") { + if (mode !== "expressway") return; + const tunnelChunks = specialTransportSubpaths( + map, + path, + (i) => !map.sea?.[i] && (((map.elevation?.[i] || 0) >= 0.74 && (map.ridgeField?.[i] || 0) >= 0.46) || (map.naturalBarrierScore?.[i] || 0) >= 0.82), + 2, + true, + 10 + ); + for (const chunk of tunnelChunks) { + drawDottedOutlinePath(ctx, chunk, "rgba(42, 42, 42, 0.96)", 1.15, Math.max(1.9, width * 0.82), "expressway"); + } +} + +function drawExpresswayPath(ctx, map, path, color, width, dashed = false, minCells = 2) { + drawLandPath(ctx, map, path, color, width, dashed, minCells, "expressway"); +} + // 魚の骨(私鉄記号)スタイルを描画するための専用関数 function drawRailway(ctx, path, color, lineWidth, tickLen, spacing) { const points = vectorPath(path); @@ -576,6 +719,10 @@ function drawRailway(ctx, path, color, lineWidth, tickLen, spacing) { ctx.restore(); } +function drawLandRailway(ctx, map, path, color, lineWidth, tickLen, spacing) { + for (const chunk of landOnlySubpaths(map, path, 3)) drawRailway(ctx, chunk, color, lineWidth, tickLen, spacing); +} + function drawSegments(ctx, segments, color, width, dashed = false) { ctx.save(); ctx.strokeStyle = color; @@ -724,39 +871,64 @@ function boxesOverlap(a, b, pad = 3) { function labelWithCollision(ctx, p, occupied) { if (!p.name) return false; const isPrefectureLabel = p.kind === "Prefecture" || p.kind === "Current Prefecture" || p.isPrefectureLabel; + const isMunicipalityLabel = p.labelStyle === "municipality"; ctx.save(); ctx.font = isPrefectureLabel ? "900 20px ui-sans-serif, system-ui, -apple-system, sans-serif" - : "600 11.5px ui-sans-serif, system-ui, -apple-system, sans-serif"; + : isMunicipalityLabel + ? "600 10px ui-sans-serif, system-ui, -apple-system, sans-serif" + : "600 11.5px ui-sans-serif, system-ui, -apple-system, sans-serif"; const baseX = p.x * CELL_SIZE + CELL_SIZE / 2; const baseY = p.y * CELL_SIZE + CELL_SIZE / 2; const textW = ctx.measureText(p.name).width; - const textH = isPrefectureLabel ? 22 : 12; + const textH = isPrefectureLabel ? 22 : isMunicipalityLabel ? 10 : 12; const candidates = isPrefectureLabel ? [ [-textW / 2, 6], [-textW / 2, -12], [-textW / 2, 24], [10, 6], [-textW - 10, 6], ] - : [ - [7, -4], [7, 13], [-textW - 7, -4], [-textW - 7, 13], - [-textW / 2, -12], [-textW / 2, 20], [12, 4], [-textW - 12, 4], - ]; + : isMunicipalityLabel + ? [ + [6, -4], [6, 11], [-textW - 6, -4], [-textW - 6, 11], + [-textW / 2, -10], [-textW / 2, 17], [10, 3], [-textW - 10, 3], + [4, -12], [-textW - 4, -12], [4, 18], [-textW - 4, 18], + ] + : [ + [7, -4], [7, 13], [-textW - 7, -4], [-textW - 7, 13], + [-textW / 2, -12], [-textW / 2, 20], [12, 4], [-textW - 12, 4], + ]; + let fallback = null; for (const [ox, oy] of candidates) { const x = baseX + ox; const y = baseY + oy; const box = { x1: x - 3, y1: y - textH, x2: x + textW + 3, y2: y + 5 }; if (box.x1 < 0 || box.y1 < 0 || box.x2 > MAP_W * CELL_SIZE || box.y2 > MAP_H * CELL_SIZE) continue; - if (occupied.some((b) => boxesOverlap(box, b, isPrefectureLabel ? 5 : 3))) continue; - + const overlaps = occupied.filter((b) => boxesOverlap(box, b, isPrefectureLabel ? 5 : isMunicipalityLabel ? 1 : 3)); + if (!overlaps.length) { + ctx.lineJoin = "round"; + ctx.lineWidth = isPrefectureLabel ? 6.2 : isMunicipalityLabel ? 3.0 : 3.5; + ctx.strokeStyle = isPrefectureLabel ? "rgba(255, 255, 255, 0.98)" : "rgba(255, 255, 255, 0.95)"; + ctx.strokeText(p.name, x, y); + ctx.fillStyle = isPrefectureLabel ? "rgba(62, 38, 112, 0.98)" : p.isPrefecturalCapital ? "#111111" : isMunicipalityLabel ? "rgba(72, 62, 82, 0.92)" : "#333333"; + ctx.fillText(p.name, x, y); + occupied.push(box); + ctx.restore(); + return true; + } + if (p.forceLabel) { + const score = overlaps.length; + if (!fallback || score < fallback.score) fallback = { x, y, box, score }; + } + } + if (p.forceLabel && fallback) { ctx.lineJoin = "round"; - ctx.lineWidth = isPrefectureLabel ? 6.2 : 3.5; + ctx.lineWidth = isPrefectureLabel ? 6.2 : isMunicipalityLabel ? 3.0 : 3.5; ctx.strokeStyle = isPrefectureLabel ? "rgba(255, 255, 255, 0.98)" : "rgba(255, 255, 255, 0.95)"; - ctx.strokeText(p.name, x, y); - - ctx.fillStyle = isPrefectureLabel ? "rgba(62, 38, 112, 0.98)" : p.isPrefecturalCapital ? "#111111" : "#333333"; - ctx.fillText(p.name, x, y); - occupied.push(box); + ctx.strokeText(p.name, fallback.x, fallback.y); + ctx.fillStyle = isPrefectureLabel ? "rgba(62, 38, 112, 0.98)" : p.isPrefecturalCapital ? "#111111" : isMunicipalityLabel ? "rgba(72, 62, 82, 0.92)" : "#333333"; + ctx.fillText(p.name, fallback.x, fallback.y); + occupied.push(fallback.box); ctx.restore(); return true; } @@ -764,18 +936,19 @@ function labelWithCollision(ctx, p, occupied) { return false; } -function drawLabels(ctx, points, limit = Infinity) { - const occupied = []; +function drawLabels(ctx, points, limit = Infinity, occupied = null) { + const used = occupied || []; const prioritized = points .filter((p) => p?.name) .map((p) => ({ ...p, labelPriority: (p.labelPriorityBase || 0) + (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 900 + (p.portClass === "major" ? 170 : p.portClass === "regional" ? 95 : 0) })) .sort((a, b) => b.labelPriority - a.labelPriority); - for (const p of prioritized.slice(0, limit)) labelWithCollision(ctx, p, occupied); + for (const p of prioritized.slice(0, limit)) labelWithCollision(ctx, p, used); + return used; } function drawScaleBar(ctx) { const kmPerCell = 1; - const targetKm = 50; + const targetKm = 25; const lengthCells = Math.max(8, Math.round(targetKm / kmPerCell)); const lengthPx = lengthCells * CELL_SIZE; const margin = 14; @@ -879,11 +1052,12 @@ export function drawMap(canvas, map, options) { }, 1.0); } - const showHistory = ["history", "all", "terrain"].includes(mode); + const showHistory = mode === "history"; const showTransportDebug = mode === "transport-debug"; const showModern = ["modern", "all", "development", "landuse", "borders-debug", "transport-debug"].includes(mode); const showRoads = ["all", "development", "transport-debug"].includes(mode); const showMinorRoads = ["all", "modern", "development", "transport-debug"].includes(mode); + const showPremodernRoads = ["history", "all", "transport-debug"].includes(mode); const showAdmin = ["admin", "all", "borders-debug"].includes(mode); // 3. Borders @@ -917,50 +1091,54 @@ export function drawMap(canvas, map, options) { if (!showFeatures) return; // 4. Transport casings. Layer order: local roads, trunk roads, railways, expressways. - if (showHistory) { - for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(210, 210, 210, 0.7)", 2.5); - } - if (showMinorRoads) { - // Local roads need a visible casing on pale green lowland/farmland tiles. - // Keep the fill light, but use a warmer grey outline rather than a nearly - // invisible white-on-green stroke. - for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(132, 126, 112, 0.72)", 2.75); + const localRoadCasing = "rgba(112, 112, 104, 0.58)"; + const localRoadFill = "rgba(255, 255, 255, 0.98)"; + const generalRoadPaths = [ + ...(showPremodernRoads ? (map.premodernRoads || []) : []), + ...(showMinorRoads ? (map.minorRoads || []) : []), + ]; + if (generalRoadPaths.length) { + // Ordinary roads: white centerline with a restrained grey casing. Both the + // current generated local roads and premodernRoads use the same appearance + // in all / transport-debug so the old white layer no longer reads as a + // second road system. + for (const path of generalRoadPaths) drawLandPath(ctx, map, path, localRoadCasing, 3.05); } if (showRoads) { - for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8); - for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8); - for (const path of map.externalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8); + for (const path of map.nationalRoads) drawLandPath(ctx, map, path, "rgba(190, 175, 140, 1)", 3.8); + for (const path of map.ringRoads || []) drawLandPath(ctx, map, path, "rgba(190, 175, 140, 1)", 3.8); + for (const path of map.externalRoads) drawLandPath(ctx, map, path, "rgba(190, 175, 140, 1)", 3.8); } if (showModern || showRoads) { - for (const path of map.railways) drawPath(ctx, path, "rgba(255, 255, 255, 0.78)", 3.6); - for (const path of map.branchRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.64)", 2.6); - for (const path of map.externalRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.78)", 3.6); + for (const path of map.railways) drawLandPath(ctx, map, path, "rgba(255, 255, 255, 0.78)", 3.6, false, 3); + for (const path of map.branchRailways) drawLandPath(ctx, map, path, "rgba(255, 255, 255, 0.64)", 2.6, false, 3); + for (const path of map.externalRailways) drawLandPath(ctx, map, path, "rgba(255, 255, 255, 0.78)", 3.6, false, 3); } if (showRoads) { - for (const path of map.expressways) drawPath(ctx, path, "rgba(105, 125, 105, 0.78)", 4.6); - for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(105, 125, 105, 0.78)", 4.6); + for (const path of map.expressways) drawExpresswayPath(ctx, map, path, "rgba(105, 125, 105, 0.78)", 4.6); + for (const path of map.externalExpressways) drawExpresswayPath(ctx, map, path, "rgba(105, 125, 105, 0.78)", 4.6); } // 5. Transport fills. Expressways remain above railways; railways sit above ordinary roads. - if (showHistory) { - for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(255, 255, 255, 1)", 1.2, false); - } - if (showMinorRoads) { - for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(255, 253, 244, 0.98)", 1.25, false); + if (generalRoadPaths.length) { + for (const path of generalRoadPaths) drawLandPath(ctx, map, path, localRoadFill, 1.45, false); } if (showRoads) { - for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0); - for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0); - for (const path of map.externalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0); + for (const path of map.nationalRoads) drawLandPath(ctx, map, path, "rgba(245, 225, 130, 1)", 2.0); + for (const path of map.ringRoads || []) drawLandPath(ctx, map, path, "rgba(245, 225, 130, 1)", 2.0); + for (const path of map.externalRoads) drawLandPath(ctx, map, path, "rgba(245, 225, 130, 1)", 2.0); } if (showModern || showRoads) { - for (const path of map.railways) drawRailway(ctx, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0); - for (const path of map.branchRailways) drawRailway(ctx, path, "rgba(125, 125, 125, 1)", 1.05, 4.0, 6.0); - for (const path of map.externalRailways) drawRailway(ctx, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0); + for (const path of map.railways) drawLandRailway(ctx, map, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0); + for (const path of map.branchRailways) drawLandRailway(ctx, map, path, "rgba(125, 125, 125, 1)", 1.05, 4.0, 6.0); + for (const path of map.externalRailways) drawLandRailway(ctx, map, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0); } if (showRoads) { - for (const path of map.expressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4); - for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4); + for (const path of generalRoadPaths) { drawBridgeOverlay(ctx, map, path, 1.55, "road"); } + for (const path of map.nationalRoads) { drawBridgeOverlay(ctx, map, path, 2.0, "road"); } + for (const path of map.externalRoads) { drawBridgeOverlay(ctx, map, path, 2.0, "road"); } + for (const path of map.expressways) { drawBridgeOverlay(ctx, map, path, 2.45, "expressway"); drawTunnelOverlay(ctx, map, path, 2.25, "expressway"); } + for (const path of map.externalExpressways) { drawBridgeOverlay(ctx, map, path, 2.45, "expressway"); drawTunnelOverlay(ctx, map, path, 2.25, "expressway"); } } // 6. Icons & Labels @@ -1008,23 +1186,26 @@ export function drawMap(canvas, map, options) { drawScaleBar(ctx); return; } + // In All mode, draw town/village dots above but suppress town/village labels. + // The Admin/Municipal Borders view still labels municipal centers normally. const allLayerTowns = mode === "all" ? [ ...(map.markets || []).filter((p) => (p.population || 0) >= 5000), ...(map.villages || []).filter((p) => (p.population || 0) >= 5000), - ] - .filter((p) => !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y)) - .map((p) => ({ ...p, forceAllLayerTownLabel: true, labelPriorityBase: p.kind === "Village" || p.kind === "Valley Village" || p.kind === "Coastal Village" ? 75 : 135 })) + ].filter((p) => !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y)) : []; const important = [ ...prefectureLabels, ...map.modernCities, - ...map.ports, + ...(map.ports || []).map((p) => ({ + ...p, + labelPriorityBase: p.portClass === "major" ? 170 : p.portClass === "regional" ? 120 : p.portClass === "fishing" ? 95 : 85, + })), ...(map.logisticsParks || []).map((p) => ({ ...p, labelPriorityBase: 60 })), ...(map.satelliteCities || []), - ...allLayerTowns, + ...allLayerTowns.map((p) => ({ ...p, forceAllLayerTownLabel: true, labelPriorityBase: 80 })), ].filter((p) => p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000); - drawLabels(ctx, important, mode === "all" ? 85 : 60); + drawLabels(ctx, important, mode === "all" ? 95 : 60); } drawScaleBar(ctx); } diff --git a/styles.css b/styles.css index d1a403e..e42808d 100644 --- a/styles.css +++ b/styles.css @@ -8,7 +8,7 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px} .header h1{margin:0 0 6px;font-size:24px;letter-spacing:-0.02em;color:#1a1a1a;font-weight:700} .header p{margin:0;color:#5a5a5a;line-height:1.6;font-size:14px} .canvas-shell,.card{background:#ffffff;border:1px solid rgba(0,0,0,0.08);border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.04)} -.canvas-shell{padding:12px;overflow:auto;position:relative} +.canvas-shell{padding:12px;overflow:auto;position:relative;cursor:grab;user-select:none;touch-action:none} .map-canvas{display:block;border-radius:8px;background:#f8f9fa} .sidebar{display:flex;flex-direction:column;gap:12px} .card{padding:14px} @@ -69,3 +69,6 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px} .progress-stage{color:#5f6368;margin-bottom:10px} .progress-timings{display:flex;flex-direction:column;gap:4px;font-family:ui-monospace,monospace;font-size:12px;color:#3c4043} .progress-timing-row{display:flex;justify-content:space-between;gap:16px;border-top:1px solid rgba(0,0,0,0.06);padding-top:4px} + +.canvas-shell.panning{cursor:grabbing} +.canvas-shell.panning .map-canvas{pointer-events:none}