From b17be0e0d2fa3d63bf48501c9a48dd52cc141a23 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Thu, 28 May 2026 00:30:09 +0900 Subject: [PATCH] chinko --- adminRegionsCore.js | 268 +++- app.js | 77 +- mapAdminCompartmentRepair.js | 744 ++++++++++ mapAdminSeedLifecycle.js | 200 +++ mapAdminShared.js | 80 ++ mapAdminStage.js | 2101 +-------------------------- mapAdminTargets.js | 190 +++ mapAdminUrbanCatchments.js | 239 +++ mapFeatures.js | 1276 ++++++++++------ mapGeography.js | 387 +++++ mapOutput.js | 237 ++- mapPipeline.js | 24 +- mapPostAdminTransport.js | 312 ++++ mapPrefectureStage.js | 863 +++++++++++ mapTerrain.js | 96 +- mapTransport.js | 2632 ++++++++++++++++++++++++++++++++-- mapTransportOD.js | 415 ++++++ mapTransportUtils.js | 229 +++ names.js | 63 +- renderer.js | 331 ++++- styles.css | 5 +- 21 files changed, 8033 insertions(+), 2736 deletions(-) create mode 100644 mapAdminCompartmentRepair.js create mode 100644 mapAdminSeedLifecycle.js create mode 100644 mapAdminShared.js create mode 100644 mapAdminTargets.js create mode 100644 mapAdminUrbanCatchments.js create mode 100644 mapGeography.js create mode 100644 mapPostAdminTransport.js create mode 100644 mapPrefectureStage.js create mode 100644 mapTransportOD.js create mode 100644 mapTransportUtils.js 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}