This commit is contained in:
33333-33333 2026-05-28 15:48:42 +09:00
commit 860471f805
13 changed files with 1643 additions and 356 deletions

View file

@ -302,6 +302,101 @@ export function removeMunicipalExclaves(adminId, prefectureMask, sea, adminCente
}
}
export function enforceMunicipalityConnectivityStrict(adminId, prefectureMask, sea, adminCenters = [], protectedPoints = [], maxPasses = 6) {
// Final cell-level invariant: each municipality should be one contiguous land
// component. Earlier stages are allowed to leave sizeable satellite pieces
// while boundaries are still being snapped; this pass removes the remaining
// visual exclaves by attaching every non-primary component to the neighboring
// municipality with the largest shared boundary. A component that contains a
// protected point may become the primary component, but it no longer protects
// additional detached pieces.
const protectedByAdmin = new Map();
for (const p of [...(adminCenters || []), ...(protectedPoints || [])]) {
if (!p || !inside(p.x, p.y)) continue;
const i = indexOf(Math.round(p.x), Math.round(p.y));
const id = adminId[i];
if (id < 0) continue;
if (!protectedByAdmin.has(id)) protectedByAdmin.set(id, new Set());
protectedByAdmin.get(id).add(i);
}
let changed = 0;
const queue = [];
for (let pass = 0; pass < maxPasses; pass++) {
let passChanged = 0;
const ids = new Set();
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && adminId[i] >= 0) ids.add(adminId[i]);
for (const id of ids) {
const seen = new Uint8Array(SIZE);
const components = [];
for (let i = 0; i < SIZE; i++) {
if (seen[i] || adminId[i] !== id || !prefectureMask[i] || sea[i]) continue;
const comp = [];
let protectedHits = 0;
queue.length = 0;
queue.push(i);
seen[i] = 1;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
comp.push(cur);
if (protectedByAdmin.get(id)?.has(cur)) protectedHits++;
const [x, y] = xyOf(cur);
for (const [nx, ny] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
if (seen[ni] || adminId[ni] !== id || !prefectureMask[ni] || sea[ni]) continue;
seen[ni] = 1;
queue.push(ni);
}
}
components.push({ cells: comp, protectedHits });
}
if (components.length <= 1) continue;
components.sort((a, b) =>
(b.protectedHits ? 1_000_000 : 0) + b.cells.length -
((a.protectedHits ? 1_000_000 : 0) + a.cells.length)
);
const primary = components[0];
for (const component of components.slice(1)) {
const counts = new Map();
for (const ci of component.cells) {
const [x, y] = xyOf(ci);
for (const [nx, ny] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni]) continue;
const other = adminId[ni];
if (other >= 0 && other !== id) counts.set(other, (counts.get(other) || 0) + 1);
}
}
let target = -1;
let best = -1;
for (const [other, count] of counts) {
const bonus = protectedByAdmin.get(other)?.size ? 0.25 : 0;
const score = count + bonus;
if (score > best || (score === best && other < target)) { best = score; target = other; }
}
if (target < 0) {
// Very rare: a detached island component has no labeled neighbor.
// Keep the largest/protected primary and merge the component into it
// only if it is directly adjacent after previous changes; otherwise
// leave it for the next pass rather than inventing over-sea ownership.
target = id;
}
if (target >= 0 && target !== id) {
for (const ci of component.cells) adminId[ci] = target;
passChanged += component.cells.length;
} else if (component !== primary) {
// If no external target exists, still mark it as handled by keeping it;
// another pass may expose a target after surrounding cells change.
}
}
}
changed += passChanged;
if (!passChanged) break;
}
return changed;
}
export function terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse) {
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);

View file

@ -4,6 +4,7 @@ import {
lockSmallUrbanComponentsToMunicipality,
mergeTinyMunicipalities,
removeMunicipalExclaves,
enforceMunicipalityConnectivityStrict,
smoothAdminRegionsTerrainAware,
snapAdminBoundariesToTerrain,
} from "./adminRegions.js";
@ -162,8 +163,10 @@ function generateAdminLayoutForMask({
});
const changedAfterFinalCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
const compacted = compactWholeCompartmentMunicipalities(adminId, adminCentersRaw, prefectureMask, sea, { populationDensity, plain, slope });
const strictConnectivityChangedCells = enforceMunicipalityConnectivityStrict(compacted.adminId, prefectureMask, sea, compacted.adminCentersRaw, [...modernCities, ...(compacted.adminCentersRaw || [])], 8);
const strictEnclaveRepairChangedCells = repairAdminSingleOwnerEnclaves(compacted.adminId, prefectureMask, sea, 4);
const adminBorders = extractAdminBorderSegments(compacted.adminId, prefectureMask);
const actualMunicipalityCount = compacted.activeMunicipalityCount;
const actualMunicipalityCount = new Set([...compacted.adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i])).size;
const naturalCompartmentCount = compartmentAssignment.debug?.naturalCompartmentCount || naturalCompartments.filter((unit) => unit && unit.area > 0).length;
const adminDebug = {
...compartmentAssignment.debug,
@ -179,6 +182,8 @@ function generateAdminLayoutForMask({
targetMunicipalityCount,
actualMunicipalityCount,
finalMunicipalityCount: actualMunicipalityCount,
changedAfterStrictMunicipalityConnectivity: strictConnectivityChangedCells,
changedAfterStrictMunicipalityEnclaveRepair: strictEnclaveRepairChangedCells,
candidateSeedCount: adminCentersRaw.length,
municipalOfficePointCount: compacted.adminCentersRaw.length,
seedCellRevivalCount: 0,
@ -436,6 +441,8 @@ function generateAdminLayoutForMask({
adminDebug.changedAfterUrbanUnification = lockCompactUrbanAreasToDominantAdmin(adminId, prefectureMask, sea, landuse, populationDensity, 980);
adminDebug.changedAfterAdminEnclaveRepair = repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, 6);
adminDebug.changedAfterFinalCompartmentOwnership += enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
adminDebug.changedAfterStrictMunicipalityConnectivity = enforceMunicipalityConnectivityStrict(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 8);
adminDebug.changedAfterStrictMunicipalityEnclaveRepair = repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, 4);
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
const satelliteAreas = [];

View file

@ -124,7 +124,6 @@ export function buildFeatureLanduse(ctx) {
const i = indexOf(x, y);
if (sea[i] || baseLanduse[i] === LANDUSE.FOREST) continue;
const transport = Math.max(roadLanduseInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82);
const densityTransport = Math.max(roadDensityInfluence[i] * 0.95, stationDensityInfluence[i] * 1.05, railInfluence2[i] * 0.85);
let urbanNeighbors = 0;
let cbdNeighbors = 0;
for (let dy = -1; dy <= 1; dy++) {

View file

@ -1,11 +1,12 @@
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, valueNoise, xyOf } from "./mapUtils.js";
import { distanceToNearest, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js";
import { createPointSpatialIndex, distanceToNearest, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js";
import { buildDensityFlowRoadTransportSystem, createPathInfluenceCache, packDebugField, pathAverageField, pathLengthCells, routeQualityAcceptable } from "./mapTransport.js";
import { buildUnifiedRailODNetwork } from "./mapTransportOD.js";
import { buildFeatureContext } from "./mapFeatureContext.js";
import { buildFeatureLanduse } from "./mapFeatureLanduse.js";
import { buildSettlementDemandFields } from "./mapFeatureSettlements.js";
import { buildFeatureTransportCostFields } from "./mapFeatureTransportTools.js";
import { buildCoarseCostGraph, refineCoarsePath, routeCoarsePath } from "./mapTransportGraph.js";
// Lightweight Human Geography V2
// --------------------------------
@ -17,6 +18,16 @@ import { buildFeatureTransportCostFields } from "./mapFeatureTransportTools.js";
// 4. synthesize population and land-use fields in one raster pass
export function generateMapFeatures(seed, terrain) {
const SPEED_TOLERANCE = 0.90;
const featureTimings = [];
const nowMs = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
let timingMark = nowMs();
function markFeatureTiming(key) {
const t = nowMs();
featureTimings.push({ key, ms: Math.round((t - timingMark) * 10) / 10 });
timingMark = t;
}
const {
elevation,
slope,
@ -65,6 +76,7 @@ export function generateMapFeatures(seed, terrain) {
pickRegionalPoints,
pickGlobalPoints,
} = featureContext;
markFeatureTiming("context");
// --- 2. Sparse points ----------------------------------------------------
let ports = pickGlobalPoints(portSuitability || coastalSettlement, {
threshold: 0.30 + rand(seed, 1001) * 0.08,
@ -84,6 +96,8 @@ export function generateMapFeatures(seed, terrain) {
ports[0].kind = "Major Port";
}
const commercialPorts = ports.filter((p) => p.portClass === "major" || p.portClass === "regional");
const portIndex = createPointSpatialIndex(ports, 12);
const commercialPortIndex = createPointSpatialIndex(commercialPorts, 12);
const crossings = pickGlobalPoints(crossingSuitability || confluenceField, {
threshold: 0.30 + rand(seed, 1011) * 0.06,
@ -92,6 +106,7 @@ export function generateMapFeatures(seed, terrain) {
seedOffset: 1010,
predicate: (x, y, i) => river[i] > 0.12 || confluenceField[i] > 0.09,
}).map((p) => ({ ...p, kind: "River Crossing" }));
const crossingIndex = createPointSpatialIndex(crossings, 8);
const passes = pickGlobalPoints(passSuitability || valleySettlement, {
threshold: 0.18 + rand(seed, 1021) * 0.06,
@ -199,6 +214,7 @@ export function generateMapFeatures(seed, terrain) {
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 * 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 initialVillageIndex = createPointSpatialIndex(villages, 8);
const supplementalPlainVillages = pickRegionalPoints(openPlainVillageScore, {
stride: 2,
threshold: 0.235 + rand(seed, 1036) * 0.020,
@ -216,7 +232,7 @@ export function generateMapFeatures(seed, terrain) {
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) >= 6.5)
}).filter((p) => !initialVillageIndex.hasWithin(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;
@ -233,8 +249,8 @@ export function generateMapFeatures(seed, terrain) {
if (sea[i]) continue;
const openPlainMarket = Math.max(0, plain[i] * 0.68 + agriculture[i] * 0.52 + basinField[i] * 0.24 + (depositionalLowland?.[i] || 0) * 0.18 - river[i] * 0.16 - valleyField[i] * 0.06 - slope[i] * 0.18);
const featurePull = Math.max(
distanceToNearest(ports, x, y) < 10 ? 0.16 : 0,
distanceToNearest(crossings, x, y) < 6 ? 0.035 : 0,
portIndex.hasWithin(x, y, 10) ? 0.16 : 0,
crossingIndex.hasWithin(x, y, 6) ? 0.035 : 0,
confluenceField[i] * 0.08
);
const valleyMouth = valleyField[i] > 0.22 && (plain[i] > 0.22 || basinField[i] > 0.18 || coastalLowland[i] > 0.18) ? 0.14 : 0;
@ -278,10 +294,10 @@ export function generateMapFeatures(seed, terrain) {
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,
extraScore: (x, y, i) => (commercialPortIndex.hasWithin(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,
}).map((p, n) => {
const i = indexOf(p.x, p.y);
const kind = coastalSettlement[i] > 0.38 && distanceToNearest(ports, p.x, p.y) < 11 ? "Port Town" : valleySettlement[i] > 0.48 ? "Valley Market Town" : "Market Town";
const kind = coastalSettlement[i] > 0.38 && portIndex.hasWithin(p.x, p.y, 11) ? "Port Town" : valleySettlement[i] > 0.48 ? "Valley Market Town" : "Market Town";
const population = Math.round((9000 + Math.pow(rand(seed, 18100 + n * 19 + p.x * 5 + p.y), 1.02) * 70000 + marketScore[i] * 40000 + villageInfluence[i] * 7800 + Math.max(0, plain[i] * 0.48 + agriculture[i] * 0.30 + basinField[i] * 0.18 - river[i] * 0.14) * 22000 + coastalSettlement[i] * 12000) / 1000) * 1000;
return { ...p, kind, population };
});
@ -292,6 +308,8 @@ export function generateMapFeatures(seed, terrain) {
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 * 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 preSupplementalMarketIndex = createPointSpatialIndex(markets, 12);
const villageIndexForMarketSpacing = createPointSpatialIndex(villages, 8);
const supplementalPlainMarkets = pickRegionalPoints(openPlainMarketScore, {
stride: 2,
threshold: 0.335 + rand(seed, 1046) * 0.025,
@ -309,13 +327,15 @@ export function generateMapFeatures(seed, terrain) {
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) >= 10.5 && distanceToNearest(villages, p.x, p.y) >= 4.5)
}).filter((p) => !preSupplementalMarketIndex.hasWithin(p.x, p.y, 10.5) && !villageIndexForMarketSpacing.hasWithin(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;
return { ...p, kind: "Plain Market Town", population };
});
markets = [...markets, ...supplementalPlainMarkets];
let marketIndex = createPointSpatialIndex(markets, 12);
let villageIndex = createPointSpatialIndex(villages, 8);
// 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
@ -330,6 +350,10 @@ export function generateMapFeatures(seed, terrain) {
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 preSparseMarketIndex = marketIndex;
const preSparseVillageIndex = villageIndex;
const marketCountByRegion = new Map();
for (const m of markets) marketCountByRegion.set(m.regionId, (marketCountByRegion.get(m.regionId) || 0) + 1);
const sparseMarkets = pickRegionalPoints(sparseTownScore, {
stride: 2,
threshold: 0.315 + rand(seed, 1049) * 0.025,
@ -341,19 +365,20 @@ export function generateMapFeatures(seed, terrain) {
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 underServed = clamp(1.0 - ((marketCountByRegion.get(regionId) || 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)
.filter((p) => !preSparseMarketIndex.hasWithin(p.x, p.y, 12) && !preSparseVillageIndex.hasWithin(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];
marketIndex = createPointSpatialIndex(markets, 12);
const defenseScore = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
@ -477,7 +502,7 @@ 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)))));
const regionalCapitalSlots = Math.max(1, Math.min(2, Math.floor(Math.sqrt(Math.max(1, modernCities.length)) / 2)));
for (const [rank, city] of modernCities.entries()) {
const i = indexOf(city.x, city.y);
const st = regionStats.get(city.regionId);
@ -489,11 +514,35 @@ export function generateMapFeatures(seed, terrain) {
fieldValue(geoAccessibility, i, 0) * 0.18 +
Math.log10((city.capacity || 26000) + 1) / 7 * 0.26
);
const isTopCenter = rank < regionalCapitalSlots || geoTierScore > 0.58;
const isTopCenter = rank < regionalCapitalSlots || geoTierScore > 0.8;
const isRegionalCapital = isFirstInRegion && (isTopCenter || (city.capacity || 0) > 210000 || (st?.highCentralityCells || 0) > 220);
const rawPop = isRegionalCapital
? 180000 + rand(seed, 12201 + city.regionId * 17) * (isTopCenter ? 760000 : 360000)
: 52000 + Math.pow(rand(seed, 12202 + rank * 19 + city.x), 0.68) * 360000;
const u = rand(st.seed, city.x, city.y, 9101);
const v = rand(st.seed, city.x, city.y, 9102);
const w = rand(st.seed, city.x, city.y, 9103);
let rawPop;
if (isRegionalCapital) {
if (isTopCenter) {
// largest 3M - 11M
rawPop =
3000000 +
Math.pow(u, 0.42) * 5200000 +
Math.pow(v, 3.2) * 2800000;
} else {
// larger 0.25M - 2.5M
rawPop =
250000 +
Math.pow(u, 0.55) * 1450000 +
Math.pow(v, 2.4) * 900000;
}
} else {
// normal 5k - 0.75k
rawPop =
52000 +
Math.pow(u, 0.72) * 520000 +
Math.pow(v, 3.0) * 320000;
}
const capMultiplier = isRegionalCapital ? (isTopCenter ? 1.66 : 1.42) : 1.20;
const population = Math.round(Math.min(rawPop, city.capacity * capMultiplier) / 1000) * 1000;
const floor = isRegionalCapital ? (isTopCenter ? 210000 : 120000) : 42000;
@ -524,14 +573,18 @@ export function generateMapFeatures(seed, terrain) {
if (!nearestCity.city) return true;
return nearestCity.d >= urbanSettlementExclusionRadius(nearestCity.city, "market");
});
marketIndex = createPointSpatialIndex(markets, 12);
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;
return !marketIndex.hasWithin(v.x, v.y, 3.4);
});
marketIndex = createPointSpatialIndex(markets, 12);
villageIndex = createPointSpatialIndex(villages, 8);
const cityIndex = createPointSpatialIndex(modernCities, 12);
villageInfluence = influenceFromPoints(villages, 6, (v) => clamp((v.population || 1800) / 4200, 0.35, 1.2));
const settlementHierarchyDebug = {
version: "phase2-unified-settlement-hierarchy",
@ -542,6 +595,7 @@ export function generateMapFeatures(seed, terrain) {
villagesAfterHierarchyFilter: villages.length,
regionalCapitalSlots,
};
markFeatureTiming("settlement-placement");
function cityPopulationCap(city) {
const radius = city?.isRegionalCapital ? 29 : city?.population >= 350000 ? 26 : 18;
@ -561,6 +615,7 @@ export function generateMapFeatures(seed, terrain) {
sea, agriculture, plain, basinField, coastalLowland, slope, ridgeField,
modernCities, markets, commercialPorts, villages,
});
markFeatureTiming("settlement-demand");
const transportFields = buildFeatureTransportCostFields({
seed,
sea, elevation, slope, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland,
@ -569,7 +624,26 @@ export function generateMapFeatures(seed, terrain) {
preliminaryTownInfluence, preliminaryVillageInfluence,
valleySettlement, coastalSettlement, developable,
});
markFeatureTiming("transport-cost-fields");
const cachedInfluenceFromPaths = createPathInfluenceCache(influenceFromPaths);
const coarseRouteStats = { graphBuilds: 0, attempted: 0, routed: 0, refined: 0, fallback: 0, skipped: 0 };
const coarseGraphCache = new WeakMap();
function coarseGraphFor(costField, mode = "national") {
const scale = mode === "local" ? 5 : mode === "rail" ? 4 : mode === "expressway" ? 4 : 4;
let byMode = coarseGraphCache.get(costField);
if (!byMode) {
byMode = new Map();
coarseGraphCache.set(costField, byMode);
}
const key = `${mode}:${scale}`;
let graph = byMode.get(key);
if (!graph) {
graph = buildCoarseCostGraph({ sea, costField }, { scale });
byMode.set(key, graph);
coarseRouteStats.graphBuilds++;
}
return graph;
}
const componentCityInfluence = influenceFromPoints([...modernCities, ...markets], 11, (p) => clamp((p.population || 8000) / 50000, 0.18, 8.0));
const componentCapitalInfluence = influenceFromPoints(modernCities.filter((p) => p.isPrefecturalCapital), 16, () => 5.0);
@ -637,17 +711,30 @@ export function generateMapFeatures(seed, terrain) {
return support > 0.14 || transportFields.nationalPotential[i] > 0.37;
}
const routeScratch = {
score: new Float32Array(SIZE),
cameFrom: new Int32Array(SIZE),
seen: new Int32Array(SIZE),
closed: new Int32Array(SIZE),
epoch: 0,
};
function traceCorridorByCost(start, goalRegionPredicate, costField, penaltyField, options = {}) {
if (!start || !inside(start.x, start.y)) return [];
const startIndex = indexOf(start.x, start.y);
if (sea[startIndex] || costField[startIndex] >= INF) return [];
const score = new Float32Array(SIZE);
const cameFrom = new Int32Array(SIZE);
const closed = new Uint8Array(SIZE);
score.fill(INF);
cameFrom.fill(-1);
const { score, cameFrom, seen, closed } = routeScratch;
let epoch = ++routeScratch.epoch;
if (routeScratch.epoch > 2000000000) {
routeScratch.seen.fill(0);
routeScratch.closed.fill(0);
routeScratch.epoch = 1;
epoch = 1;
}
const heap = new MinHeap();
seen[startIndex] = epoch;
score[startIndex] = 0;
cameFrom[startIndex] = -1;
heap.push({ i: startIndex, f: 0 });
const curvePenalty = options.curvePenalty ?? 0.12;
const penaltyStrength = options.penaltyStrength ?? 1.0;
@ -662,9 +749,10 @@ export function generateMapFeatures(seed, terrain) {
while (heap.length && expanded++ < maxExpanded) {
const current = heap.pop();
if (!current || closed[current.i]) continue;
closed[current.i] = 1;
const [cx, cy] = xyOf(current.i);
if (!current || closed[current.i] === epoch) continue;
closed[current.i] = epoch;
const cx = current.i % MAP_W;
const cy = Math.floor(current.i / MAP_W);
if (current.i !== startIndex && Math.hypot(cx - start.x, cy - start.y) >= minGoalDistance && goalRegionPredicate(cx, cy, current.i)) {
goalIndex = current.i;
break;
@ -677,12 +765,13 @@ export function generateMapFeatures(seed, terrain) {
if (!inside(nx, ny)) continue;
if (bounds && (nx < bounds.minX || nx > bounds.maxX || ny < bounds.minY || ny > bounds.maxY)) continue;
const ni = indexOf(nx, ny);
if (closed[ni] || sea[ni] || costField[ni] >= INF) continue;
if (closed[ni] === epoch || sea[ni] || costField[ni] >= INF) continue;
if (sameRegion >= 0 && options.keepRegion !== false && regionIdAt(nx, ny) !== sameRegion) continue;
const prev = cameFrom[current.i];
let turn = 0;
if (prev >= 0) {
const [px, py] = xyOf(prev);
const px = prev % MAP_W;
const py = Math.floor(prev / MAP_W);
const ax = cx - px;
const ay = cy - py;
turn = Math.abs(ax * dy - ay * dx) > 0 ? curvePenalty : 0;
@ -699,7 +788,8 @@ export function generateMapFeatures(seed, terrain) {
);
const surfaceGrain = (options.surfaceGrain ?? 0) * valueNoise(nx, ny, seed + 13941, 18);
const nd = score[current.i] + Math.max(0.08, costField[ni] + antiConcentration + turn - terrainFlowBias + surfaceGrain) * Math.hypot(dx, dy);
if (nd < score[ni]) {
if (seen[ni] !== epoch || nd < score[ni]) {
seen[ni] = epoch;
score[ni] = nd;
cameFrom[ni] = current.i;
const h = goalHint ? Math.hypot(nx - goalHint.x, ny - goalHint.y) * heuristicWeight : 0;
@ -711,7 +801,7 @@ export function generateMapFeatures(seed, terrain) {
if (goalIndex < 0) return [];
const path = [];
for (let p = goalIndex; p >= 0; p = cameFrom[p]) {
path.push(xyOf(p));
path.push([p % MAP_W, Math.floor(p / MAP_W)]);
if (p === startIndex) break;
}
return path.reverse();
@ -1407,7 +1497,7 @@ export function generateMapFeatures(seed, terrain) {
...markets.filter(inRegion).map((p) => ({ ...p, nodeWeight: 3.2 + (p.population || 0) / 25000 })),
...commercialPorts.filter(inRegion).map((p) => ({ ...p, nodeWeight: p.portClass === "major" ? 6.5 : 4.6 })),
...passes.filter(inRegion).map((p) => ({ ...p, nodeWeight: 2.2 })),
].sort((a, b) => b.nodeWeight - a.nodeWeight).slice(0, (regionStats.get(regionId)?.area || 0) > 2200 ? 16 : 10);
].sort((a, b) => b.nodeWeight - a.nodeWeight).slice(0, (regionStats.get(regionId)?.area || 0) > 2200 ? 13 : 8);
}
function dedupePointCandidates(points, minDistance = 5) {
@ -1474,13 +1564,13 @@ export function generateMapFeatures(seed, terrain) {
}
function transportCandidatePoints(mode, potentialField, options = {}) {
const maxCells = options.maxCells ?? (mode === "expressway" ? 18 : mode === "rail" ? 26 : 40);
const maxSettlements = options.maxSettlements ?? (mode === "expressway" ? 20 : mode === "rail" ? 34 : 56);
const maxCells = options.maxCells ?? (mode === "expressway" ? 14 : mode === "rail" ? 20 : 30);
const maxSettlements = options.maxSettlements ?? (mode === "expressway" ? 16 : mode === "rail" ? 27 : 44);
const minDistance = options.minDistance ?? (mode === "expressway" ? 13 : mode === "rail" ? 10 : 8);
const cells = preferenceCellAnchors(mode, potentialField, maxCells, minDistance);
const settlements = modeSettlementAnchors(mode, potentialField, maxSettlements);
return dedupePointCandidates([...settlements, ...cells].sort((a, b) => b.score - a.score), Math.max(4, minDistance * 0.55))
.slice(0, options.maxTotal ?? (mode === "expressway" ? 32 : mode === "rail" ? 48 : 74));
.slice(0, options.maxTotal ?? (mode === "expressway" ? 26 : mode === "rail" ? 38 : 58));
}
function routeBetweenTrafficCandidates(a, b, mode, costField, penaltyField, options = {}) {
@ -1491,14 +1581,38 @@ export function generateMapFeatures(seed, terrain) {
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" ? 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 searchPad = options.searchPad ?? Math.ceil(Math.max(16, Math.min(48, d * (mode === "expressway" ? 0.40 : mode === "rail" ? 0.37 : 0.31))));
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(
const coarseThreshold = options.coarseThreshold ?? (mode === "local" ? 18 : mode === "rail" ? 22 : mode === "expressway" ? 30 : 20);
let path = [];
if (!options.forceFullResolution && d >= coarseThreshold) {
coarseRouteStats.attempted++;
const graph = coarseGraphFor(costField, mode);
const coarse = routeCoarsePath(start, target, graph, { heuristicWeight: mode === "rail" ? 0.72 : 0.86 });
if (coarse.length >= 2) {
const refined = refineCoarsePath([[start.x, start.y], ...coarse.slice(1, -1), [target.x, target.y]], costField, {
sea,
snapRadius: mode === "local" ? 2 : 1,
});
if (refined.length >= 4) {
path = refined;
coarseRouteStats.routed++;
coarseRouteStats.refined++;
} else {
coarseRouteStats.skipped++;
}
} else {
coarseRouteStats.skipped++;
}
}
if (!path.length) {
coarseRouteStats.fallback++;
path = traceCorridorByCost(
start,
(x, y) => Math.hypot(x - target.x, y - target.y) <= snap,
costField,
@ -1508,17 +1622,19 @@ export function generateMapFeatures(seed, terrain) {
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(1800, Math.floor(d * d * (mode === "expressway" ? 3.8 : mode === "rail" ? 4.6 : 5.2)))),
maxExpanded: Math.min(Math.floor(SIZE * SPEED_TOLERANCE), Math.max(1300, Math.floor(d * d * (mode === "expressway" ? 2.9 : mode === "rail" ? 3.5 : 3.9)))),
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, {
const skipRelax = options.skipRelax ?? (mode === "local" && path.length > 42);
const relaxed = skipRelax ? path : relaxRouteToTerrain(path, costField, {
radius: options.relaxRadius ?? (mode === "national" ? 2 : 1),
lineWeight: options.relaxLineWeight ?? (mode === "national" ? 0.30 : mode === "expressway" ? 0.24 : 0.48),
grain: options.surfaceGrain ?? 0.020,
@ -1695,12 +1811,77 @@ const premodernRoads = [];
const interchanges = [];
const externalGateways = [];
// Premodern roads connect castles/markets/ports sparsely.
for (const c of castles) {
const near = [...markets, ...ports, ...crossings].sort((a, b) => Math.hypot(a.x - c.x, a.y - c.y) - Math.hypot(b.x - c.x, b.y - c.y)).slice(0, 2);
for (const n of near) {
const path = routeLight(c, n, 2);
if (path.length > 2) premodernRoads.push(path);
function pathEntirelyLand(path) {
if (!path || path.length < 2) return false;
for (let k = 1; k < path.length; k++) {
const a = path[k - 1];
const b = path[k];
if (!a || !b) return false;
const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[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)) return false;
const i = indexOf(x, y);
if (sea[i] || transportFields.local[i] >= INF) return false;
}
}
return true;
}
function addPremodernRoad(a, b, edgeSet) {
if (!a || !b) return false;
const keyA = `${Math.round(a.x)},${Math.round(a.y)}`;
const keyB = `${Math.round(b.x)},${Math.round(b.y)}`;
const key = keyA < keyB ? `${keyA}|${keyB}` : `${keyB}|${keyA}`;
if (edgeSet.has(key)) return false;
edgeSet.add(key);
const d = Math.hypot(a.x - b.x, a.y - b.y);
if (d < 5 || d > 96) return false;
const path = routeLight(a, b, 2.4, transportFields.local);
if (path.length <= 2 || !pathEntirelyLand(path)) return false;
if (!routePhysicalAcceptable(path, 'local', { maxSeaRun: 0, maxSeaShare: 0, maxTunnelRun: 0, maxTunnelShare: 0 })) return false;
premodernRoads.push(path);
return true;
}
// Premodern roads are a loose land-only network around the labelled historical
// places (castles, market/village nodes, ports, crossings), not sea chords.
{
const edgeSet = new Set();
const labelledPremodernNodes = dedupePointCandidates([
...castles.map((p) => ({ ...p, premodernRole: "castle", premodernWeight: 1.35 })),
...castleTowns.map((p) => ({ ...p, premodernRole: "castle-town", premodernWeight: 1.25 })),
...markets.filter((p) => (p.population || 0) >= 2500).map((p) => ({ ...p, premodernRole: "market", premodernWeight: 1.0 + Math.min(0.45, (p.population || 0) / 60000) })),
...villages.filter((p) => (p.population || 0) >= 4200).map((p) => ({ ...p, premodernRole: "village", premodernWeight: 0.74 })),
...commercialPorts.map((p) => ({ ...p, premodernRole: "port", premodernWeight: p.portClass === "major" ? 1.22 : 1.0 })),
...crossings.map((p) => ({ ...p, premodernRole: "crossing", premodernWeight: 0.78 })),
].filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)]), 5.0)
.sort((a, b) => (b.premodernWeight || 0) - (a.premodernWeight || 0))
.slice(0, 54);
for (const c of castles) {
const near = labelledPremodernNodes
.filter((n) => n !== c && Math.hypot(n.x - c.x, n.y - c.y) <= 88)
.sort((a, b) => Math.hypot(a.x - c.x, a.y - c.y) - Math.hypot(b.x - c.x, b.y - c.y))
.slice(0, 2);
for (const n of near) addPremodernRoad(c, n, edgeSet);
}
for (const node of labelledPremodernNodes) {
if (premodernRoads.length >= 68) break;
const quota = node.premodernRole === "castle" || node.premodernRole === "castle-town" ? 3 : node.premodernRole === "market" ? 2 : 1;
const near = labelledPremodernNodes
.filter((n) => n !== node)
.map((n) => ({ n, d: Math.hypot(n.x - node.x, n.y - node.y), cost: Math.hypot(n.x - node.x, n.y - node.y) / Math.max(0.45, n.premodernWeight || 0.8) }))
.filter((e) => e.d >= 7 && e.d <= 82)
.sort((a, b) => a.cost - b.cost)
.slice(0, quota);
for (const { n } of near) {
if (premodernRoads.length >= 68) break;
addPremodernRoad(node, n, edgeSet);
}
}
}
@ -1750,10 +1931,12 @@ const premodernRoads = [];
transportFields, settlementDemand, preliminaryUrbanInfluence, preliminaryTownInfluence, preliminaryVillageInfluence,
modernCities, markets, ports, commercialPorts, externalGateways, geographicUrbanAnchors,
regionIdAt, routeBetweenTrafficCandidates, addCorridorInfluencePenalty, transportRouteAcceptable, pruneParallelSameMode, cachedInfluenceFromPaths,
speedTolerance: SPEED_TOLERANCE,
});
railways.push(...railOD.railways);
branchRailways.push(...railOD.branchRailways);
railODDebug = railOD.debug;
markFeatureTiming("rail-od");
const { transportDebugLayers, runLocalAccessPass, stitchRasterNearContacts, stitchLongLocalBranches, sanitizeLocalRoads, downgradeShortNationalRoads, connectAllRoadNetworksFinal } = buildDensityFlowRoadTransportSystem({
@ -1771,6 +1954,7 @@ const premodernRoads = [];
relaxRouteToTerrain, transportRouteAcceptable, repairTransportConnectivity,
repairDanglingTransportEndpoints, pruneDanglingTerminalSegments, pruneParallelSameMode,
});
markFeatureTiming("road-system");
// Land-use road influence intentionally excludes expressways. Expressways
// are through-corridors here, not automatic suburbanization generators.
@ -1798,9 +1982,9 @@ const premodernRoads = [];
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 nearCity = (preliminaryUrbanInfluence[i] || 0) > 0.12 || cityIndex.hasWithin(x, y, 4.8);
const nearTown = (preliminaryTownInfluence[i] || 0) > 0.13 || marketIndex.hasWithin(x, y, 4.2);
const nearVillage = (preliminaryVillageInfluence[i] || 0) > 0.20 || villageIndex.hasWithin(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);
@ -1822,6 +2006,7 @@ const premodernRoads = [];
}
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);
markFeatureTiming("transport-influence-stations");
// --- 5. Approximate city/town influence and land-use ---------------------
const cityInfluence = new Float32Array(SIZE);
@ -1921,7 +2106,7 @@ const premodernRoads = [];
slope[i] * 0.44 -
ridgeField[i] * 0.32
);
const nearMajorCity = modernCities.some((c) => Math.hypot(c.x - x, c.y - y) < 4);
const nearMajorCity = cityIndex.hasWithin(x, y, 4);
logisticsScore[i] = nearMajorCity ? 0 : flatAgriculturalCorridor;
}
}
@ -1950,9 +2135,9 @@ const premodernRoads = [];
return { ...p, score: (p.population || 1200) / 16000 + remoteness * 3.2 + ruralValue + (p.portClass ? 0.8 : 0) + (p.kind === "Logistics Park" ? 1.0 : 0) + transportFields.localPotential[i] };
})
.sort((a, b) => b.score - a.score)
.slice(0, 130);
.slice(0, 62);
const localPenalty = cachedInfluenceFromPaths(minorRoads, 4, "final-local:minor");
runLocalAccessPass({ candidates, accessInfluence, localPenalty, maxAdded: 90, maxLength: 92, debugMode: "local-access", from: "unserved", to: "network" });
runLocalAccessPass({ candidates, accessInfluence, localPenalty, maxAdded: 42, maxLength: 78, debugMode: "local-access", from: "unserved", to: "network" });
}
function addRuralRoadMeshConnectors() {
@ -1967,14 +2152,14 @@ const premodernRoads = [];
})
.filter((p) => p.score > 0.30)
.sort((a, b) => b.score - a.score)
.slice(0, 170);
.slice(0, 78);
runLocalAccessPass({
candidates,
accessInfluence: roadInfluenceNow,
localPenalty,
maxAdded: 70,
maxAdded: 32,
minSpacing: 3.0,
maxLength: 76,
maxLength: 64,
debugMode: "rural-mesh",
from: "rural-settlement",
to: "local-network",
@ -1990,7 +2175,8 @@ const premodernRoads = [];
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);
transportDebugLayers.finalRoadNetworkConnectivity = connectAllRoadNetworksFinal(46);
markFeatureTiming("local-road-cleanup");
function dedupeTransportPathSet(paths, options = {}) {
const before = paths.length;
@ -2143,10 +2329,12 @@ const premodernRoads = [];
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++;
const first = path[0];
const last = path[path.length - 1];
if (ensureInterchangePoint(first[0], first[1], 'Terminal IC', 'expressway-terminal-endpoint')) added++;
if (ensureInterchangePoint(last[0], last[1], 'Terminal IC', 'expressway-terminal-endpoint')) added++;
}
return { added, total: interchanges.length };
return { added, total: interchanges.length, strategy: "terminal ICs restored" };
}
function ensureNationalRoadCoverageForTowns(minPopulation = 5000) {
@ -2167,7 +2355,7 @@ const premodernRoads = [];
.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)) {
for (const cand of candidates.slice(0, 5)) {
const path = routeBetweenTrafficCandidates(town, cand.q, 'national', transportFields.national, nationalPenalty, {
curvePenalty: 0.055,
penaltyStrength: 0.60,
@ -2192,26 +2380,73 @@ const premodernRoads = [];
return debug;
}
function expresswayFringeAnchorForCity(city) {
if (!city || !inside(city.x, city.y)) return null;
const inner = Math.max(10, Math.round((city.coreRadius || 4) + 7));
const outer = Math.max(inner + 8, Math.round(Math.min(34, (city.urbanRadius || 13) * 2.0)));
let best = null;
for (let dy = -outer; dy <= outer; dy++) {
for (let dx = -outer; dx <= outer; dx++) {
const x = Math.round(city.x + dx);
const y = Math.round(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] || elevation[i] >= 0.70 || transportFields.expressway[i] >= INF) continue;
const radialBand = clamp(1 - Math.abs(d - (inner + outer) * 0.54) / Math.max(2, (outer - inner) * 0.55));
const corePenalty = clamp(settlementDemand[i] * 0.78 + preliminaryTownInfluence[i] * 0.58 + preliminaryVillageInfluence[i] * 0.42);
const score =
(transportFields.expresswayPotential[i] || 0) * 1.18 +
(urbanEdge[i] || 0) * 0.54 +
(logisticsPreSuitability[i] || 0) * 0.40 +
(plain[i] || 0) * 0.20 +
(basinField[i] || 0) * 0.12 +
radialBand * 0.32 -
corePenalty * 1.18 -
(slope[i] || 0) * 1.05 -
(ridgeField[i] || 0) * 0.82 +
hash2(x, y, seed + 24891 + city.x * 11 + city.y * 17) * 0.04;
if (!best || score > best.score) best = { x, y, score, city, population: city.population || 0, regionId: regionIdAt(x, y), role: 'expressway-fringe-anchor' };
}
}
return best;
}
function nearPath(path, p, radius) {
return (path || []).some(([x, y]) => Math.hypot(x - p.x, y - p.y) <= radius);
}
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: [] };
const debug = { minPopulation, cityCount: majorCities.length, added: 0, pairs: [], strategy: 'fringe-anchor-only' };
if (majorCities.length < 2) return debug;
const anchors = majorCities
.map((city) => expresswayFringeAnchorForCity(city))
.filter(Boolean)
.sort((a, b) => (b.population || 0) - (a.population || 0));
debug.anchorCount = anchors.length;
if (anchors.length < 2) return debug;
const permissiveExpresswayCost = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea[i] || elevation[i] >= 0.70) {
permissiveExpresswayCost[i] = INF;
continue;
}
const urbanCore = clamp(settlementDemand[i] * 0.78 + preliminaryTownInfluence[i] * 0.62 + preliminaryVillageInfluence[i] * 0.46);
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;
: 0.40 + Math.max(0, slope[i] - 0.18) * 1.25 + Math.max(0, elevation[i] - 0.58) * 2.1 + ridgeField[i] * 0.80;
permissiveExpresswayCost[i] = terrainBase + urbanCore * 2.6 + Math.max(0, elevation[i] - 0.62) * 3.0;
}
const componentOfCities = () => {
const componentOfAnchors = () => {
const parent = new Map();
const keyOf = (city) => city.name || `${city.x},${city.y}`;
const keyOf = (anchor) => anchor.city?.name || `${anchor.x},${anchor.y}`;
function find(k) {
const p = parent.get(k);
if (p === k) return k;
@ -2223,74 +2458,70 @@ const premodernRoads = [];
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));
for (const anchor of anchors) parent.set(keyOf(anchor), keyOf(anchor));
for (const path of [...expressways, ...externalExpressways]) {
const near = anchors.filter((anchor) => nearPath(path, anchor, 8.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))]));
return new Map(anchors.map((anchor) => [keyOf(anchor), find(keyOf(anchor))]));
};
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 keyOf = (anchor) => anchor.city?.name || `${anchor.x},${anchor.y}`;
for (let iter = 0; iter < anchors.length * 2; iter++) {
const comps = componentOfAnchors();
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) };
for (const a of anchors) {
for (const b of anchors) {
if (a === b || comps.get(keyOf(a)) === comps.get(keyOf(b))) continue;
const d = Math.hypot(a.x - b.x, a.y - b.y);
if (d < 32 || d > 220) continue;
const pop = Math.sqrt((a.population || minPopulation) * (b.population || minPopulation));
const score = d / Math.max(1, Math.log2(pop)) - ((a.score || 0) + (b.score || 0)) * 0.18;
if (!best || score < best.score) best = { a, b, score, d };
}
}
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,
curvePenalty: 0.125,
penaltyStrength: 0.95,
terrainFlowBias: 0.09,
surfaceGrain: 0.006,
relaxRadius: 1,
relaxLineWeight: 0.20,
maxPathLength: best.d * 2.70 + 84,
snapRadius: 2.0,
searchPad: Math.ceil(Math.max(42, Math.min(112, best.d * 0.58 + 12))),
heuristicWeight: 0.72,
maxSeaRun: 0,
maxTunnelRun: 10,
maxSeaShare: 0,
maxTunnelShare: 0.12,
});
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;
if (path.length >= 4 && transportRouteAcceptable(path, 'expressway', transportFields.expresswayPotential, null, { minLength: 16, maxLength: best.d * 2.85 + 96, maxSeaRun: 0, maxTunnelRun: 10, maxSeaShare: 0, maxTunnelShare: 0.12 })) {
path = smoothRasterPath(path, 1);
if (transportRouteAcceptable(path, 'expressway', transportFields.expresswayPotential, null, { minLength: 16, maxLength: best.d * 2.95 + 104, maxSeaRun: 0, maxTunnelRun: 10, maxSeaShare: 0, maxTunnelShare: 0.12 })) {
expressways.push(path);
debug.added++;
debug.pairs.push({ from: best.a.city?.name, to: best.b.city?.name, distance: Math.round(best.d), length: Math.round(pathLengthCells(path)) });
continue;
}
}
break;
}
debug.finalExpresswayCount = expressways.length;
return debug;
}
transportDebugLayers.postConnectivityNationalCoverage = ensureNationalRoadCoverageForTowns(5000);
transportDebugLayers.postConnectivityMajorCityExpresswayGuarantee = ensureMajorCityExpresswayConnections(100000);
transportDebugLayers.postConnectivityNationalCoverage = ensureNationalRoadCoverageForTowns(6000);
transportDebugLayers.postConnectivityMajorCityExpresswayGuarantee = ensureMajorCityExpresswayConnections(110000);
transportDebugLayers.postConnectivityExpresswayDedup = dedupeTransportPathSet(expressways, { minLength: 8, sampleStep: 2 });
transportDebugLayers.postConnectivityExpresswayEndpointICs = ensureExpresswayEndpointsHaveICs();
markFeatureTiming("post-connectivity-guarantees");
const { landuse, populationDensity } = buildFeatureLanduse({
seed,
elevation, slope, sea, river, floodplain, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland,
@ -2299,8 +2530,11 @@ const premodernRoads = [];
roadLanduseInfluence, roadInfluence, roadDensityInfluence, railInfluence2, stationInfluence, stationDensityInfluence, villageInfluence,
cityInfluence, coreInfluence, oldTownInfluence, townInfluence, industrialInfluence, logisticsInfluence,
});
markFeatureTiming("landuse");
const transportDebug = {
humanStageVersion: "v2-sparse-raster",
featureTimings,
coarseRouting: coarseRouteStats,
aStarRoutes: 0,
regionalNodeCount: [...regionStats.keys()].reduce((sum, regionId) => sum + importantNodesForRegion(regionId).length, 0),
fieldCorridorTransport: false,

View file

@ -31,6 +31,61 @@ export function distanceToNearest(points, x, y, fallback = 999) {
return best;
}
export function createPointSpatialIndex(points, cellSize = 12) {
const buckets = new Map();
const normalized = (points || [])
.filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y))
.map((p) => ({ ...p, x: Math.round(p.x), y: Math.round(p.y) }));
const keyOf = (cx, cy) => `${cx},${cy}`;
for (const p of normalized) {
const cx = Math.floor(p.x / cellSize);
const cy = Math.floor(p.y / cellSize);
const key = keyOf(cx, cy);
let bucket = buckets.get(key);
if (!bucket) {
bucket = [];
buckets.set(key, bucket);
}
bucket.push(p);
}
function nearestDistanceSq(x, y, maxDistance = Math.max(MAP_W, MAP_H)) {
if (!normalized.length) return maxDistance * maxDistance;
const cx = Math.floor(x / cellSize);
const cy = Math.floor(y / cellSize);
const maxRing = Number.isFinite(maxDistance) ? Math.ceil(maxDistance / cellSize) : Math.ceil(Math.max(MAP_W, MAP_H) / cellSize);
let best = maxDistance * maxDistance;
for (let ring = 0; ring <= maxRing; ring++) {
for (let by = cy - ring; by <= cy + ring; by++) {
for (let bx = cx - ring; bx <= cx + ring; bx++) {
if (ring > 0 && bx > cx - ring && bx < cx + ring && by > cy - ring && by < cy + ring) continue;
const bucket = buckets.get(keyOf(bx, by));
if (!bucket) continue;
for (const p of bucket) {
const dx = p.x - x;
const dy = p.y - y;
const d2 = dx * dx + dy * dy;
if (d2 < best) best = d2;
}
}
}
}
return best;
}
return {
points: normalized,
hasWithin(x, y, radius) {
return nearestDistanceSq(x, y, radius) < radius * radius;
},
distance(x, y, fallback = 999) {
const d2 = nearestDistanceSq(x, y, fallback);
return d2 < fallback * fallback ? Math.sqrt(d2) : fallback;
},
nearestDistanceSq,
};
}
export function aStar(start, goal, costAt) {
const startIndex = indexOf(start.x, start.y);
const goalIndex = indexOf(goal.x, goal.y);
@ -83,15 +138,18 @@ export function aStar(start, goal, costAt) {
export function influenceFromPaths(paths, radius) {
const grid = new Float32Array(SIZE);
const r = Math.ceil(radius);
const r2 = radius * radius;
for (const path of paths) {
for (const [x, y] of path) {
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const nx = x + dx;
const ny = y + dy;
if (!inside(nx, ny)) continue;
const d = Math.hypot(dx, dy);
if (d > radius) continue;
const d2 = dx * dx + dy * dy;
if (d2 > r2) continue;
const d = Math.sqrt(d2);
const i = indexOf(nx, ny);
grid[i] = Math.max(grid[i], 1 / (1 + d));
}
@ -239,15 +297,18 @@ export function averagePathField(path, field) {
export function influenceFromPoints(points, radius, weightFn = () => 1) {
const grid = new Float32Array(SIZE);
const r = Math.ceil(radius);
const r2 = radius * radius;
for (const p of points) {
const weight = weightFn(p);
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const nx = p.x + dx;
const ny = p.y + dy;
if (!inside(nx, ny)) continue;
const d = Math.hypot(dx, dy);
if (d > radius) continue;
const d2 = dx * dx + dy * dy;
if (d2 > r2) continue;
const d = Math.sqrt(d2);
const i = indexOf(nx, ny);
grid[i] = Math.max(grid[i], weight / (1 + d));
}

View file

@ -177,8 +177,9 @@ function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCiti
}
target.isPrefecturalCapital = true;
target.isRegionalCapital = true;
target.rank = target.rank || "Regional Capital";
target.kind = target.kind === "Market Town" || target.kind === "Port Town" || target.kind === "Valley Market Town" ? "Regional Capital" : (target.kind || "Regional Capital");
target.rank = "Prefectural Capital";
target.kind = "Prefectural Capital";
target.labelPriorityBase = Math.max(target.labelPriorityBase || 0, 1150);
target.urbanRadius = clamp(6.2 + Math.sqrt(target.population) / 80, 9, 30);
target.coreRadius = clamp(2.0 + Math.sqrt(target.population) / 390, 2.4, 6.4);
target.sprawlRadius = clamp(target.urbanRadius * 1.34, target.urbanRadius + 2, 38);
@ -210,8 +211,9 @@ function buildPrefectureRegions(prefectureRegionId, sea, fields, seed, usedNames
const prefId = prefectureRegionId[indexOf(city.x, city.y)];
if (prefId < 0) continue;
const current = capitalNameByPref.get(prefId);
const score = (city.isPrefecturalCapital ? 2_000_000 : 0) + (city.isRegionalCapital ? 500_000 : 0) + (city.population || 0);
if (!current || score > current.score) capitalNameByPref.set(prefId, { name: stripMunicipalSuffix(city.name), score, x: city.x, y: city.y, population: city.population || 0, source: "city" });
const tier = city.isPrefecturalCapital || city.rank === "Prefectural Capital" || city.kind === "Prefectural Capital" ? 3 : city.isRegionalCapital || city.rank === "Regional Capital" || city.kind === "Regional Capital" ? 2 : 1;
const score = tier * 50_000_000 + (city.population || 0);
if (!current || score > current.score) capitalNameByPref.set(prefId, { name: stripMunicipalSuffix(city.name), score, x: city.x, y: city.y, population: city.population || 0, source: tier === 3 ? "prefecture-capital" : "city" });
}
for (const center of adminCenters || []) {
if (!center || !inside(center.x, center.y) || !center.name) continue;
@ -265,7 +267,7 @@ function buildPrefectureRegions(prefectureRegionId, sea, fields, seed, usedNames
x = labelI % MAP_W;
y = Math.floor(labelI / MAP_W);
}
regions.push({ id: row.id, x, y, area: row.area, kind: row.id === 0 ? "Current Prefecture" : "Prefecture", labelPriorityBase: 900 + Math.sqrt(row.area), capitalX: capitalInfo?.x, capitalY: capitalInfo?.y });
regions.push({ id: row.id, x, y, area: row.area, kind: row.id === 0 ? "Current Prefecture" : "Prefecture", labelPriorityBase: 950 + Math.sqrt(row.area), forceLabel: true, capitalX: capitalInfo?.x, capitalY: capitalInfo?.y });
}
const named = attachIdsAndNames(regions, "prefecture", seed + 41000, null, fields, usedNames, nameDebug)
.map((region, index) => ({ ...region, featureId: region.id, id: regions[index].id, labelName: region.name }));
@ -720,6 +722,24 @@ export function finishMapOutput({
return false;
}
function pathNearRequiredExpresswayCity(path) {
if (!path || path.length < 2) return false;
for (const city of modernCities || []) {
if (!city || (city.population || 0) < 100000 || !inside(city.x, city.y)) continue;
const inner = Math.max(7, (city.coreRadius || 4) + 4.5);
const outer = Math.max(inner + 7, (city.urbanRadius || 12) * 2.15);
let inBand = false;
let exits = false;
for (const [x, y] of path) {
const d = Math.hypot(city.x - x, city.y - y);
if (d >= inner && d <= outer) inBand = true;
if (d >= Math.max(22, (city.urbanRadius || 12) * 1.45)) exits = true;
if (inBand && exits) 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) => {
@ -751,8 +771,165 @@ export function finishMapOutput({
}
return out.sort((a, b) => b.size - a.size);
}
function buildLandComponentIds() {
const ids = new Int32Array(MAP_W * MAP_H);
ids.fill(-1);
let id = 0;
const q = [];
for (let i = 0; i < ids.length; i++) {
if (ids[i] >= 0 || sea[i]) continue;
ids[i] = id;
q.length = 0;
q.push(i);
for (let h = 0; h < q.length; h++) {
const cur = q[h];
const [x, y] = xyOf(cur);
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)) continue;
const ni = indexOf(nx, ny);
if (sea[ni] || ids[ni] >= 0) continue;
ids[ni] = id;
q.push(ni);
}
}
id++;
}
return ids;
}
function majorityLandId(cells, landIds) {
const counts = new Map();
for (const i of cells || []) {
const id = landIds[i];
if (id < 0) continue;
counts.set(id, (counts.get(id) || 0) + 1);
}
let best = -1, bestN = 0;
for (const [id, n] of counts) if (n > bestN) { best = id; bestN = n; }
return best;
}
function componentNearAdminCenter(comp, radius = 3.2) {
if (!comp?.cells?.length) return false;
const mask = new Uint8Array(MAP_W * MAP_H);
for (const ci of comp.cells) mask[ci] = 1;
const r = Math.ceil(radius);
for (const center of adminCenters || []) {
if (!center || !inside(center.x, center.y)) continue;
const cx = Math.round(center.x), cy = Math.round(center.y);
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
if (dx * dx + dy * dy > radius * radius) continue;
const x = cx + dx, y = cy + dy;
if (inside(x, y) && mask[indexOf(x, y)]) return true;
}
}
return false;
}
function routeIsolatedComponentToMain(comp, mainMask, mainCentroid, landIds, landIdValue) {
if (!comp?.cells?.length || landIdValue < 0) return [];
const dist = new Float64Array(MAP_W * MAP_H);
dist.fill(INF);
const prev = new Int32Array(MAP_W * MAP_H);
prev.fill(-1);
const heap = new MinHeap();
let seeded = 0;
const stride = Math.max(1, Math.floor(comp.cells.length / 96));
for (let k = 0; k < comp.cells.length; k += stride) {
const i = comp.cells[k];
if (sea[i] || landIds[i] !== landIdValue) continue;
dist[i] = 0;
prev[i] = i;
const [x, y] = xyOf(i);
heap.push({ i, f: Math.hypot(x - mainCentroid.x, y - mainCentroid.y) * 0.22 });
seeded++;
}
if (!seeded) return [];
let goal = -1;
let expanded = 0;
const maxExpanded = 22000;
while (heap.length && expanded < maxExpanded) {
const current = heap.pop();
if (!current) break;
const cur = current.i;
expanded++;
if (mainMask[cur] && dist[cur] > 2) { goal = cur; break; }
const [x, y] = xyOf(cur);
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)) continue;
const ni = indexOf(nx, ny);
if (sea[ni] || landIds[ni] !== landIdValue) continue;
const step = Math.hypot(dx, dy);
const terrainCost = 1.0 + (slope?.[ni] || 0) * 1.28 + (ridgeField?.[ni] || 0) * 0.66 + Math.max(0, (elevation?.[ni] || 0) - 0.66) * 1.75 - (valleyField?.[ni] || 0) * 0.42 - (plain?.[ni] || 0) * 0.18 - (coastalLowland?.[ni] || 0) * 0.08;
const nd = dist[cur] + step * Math.max(0.38, terrainCost);
if (nd >= dist[ni]) continue;
dist[ni] = nd;
prev[ni] = cur;
const h = Math.hypot(nx - mainCentroid.x, ny - mainCentroid.y) * 0.22;
heap.push({ i: ni, f: nd + h });
}
}
if (goal < 0) return [];
const path = [];
let cur = goal;
for (let guard = 0; guard < 240 && cur >= 0; guard++) {
const [x, y] = xyOf(cur);
path.push([x, y]);
if (prev[cur] === cur) break;
cur = prev[cur];
}
path.reverse();
if (path.length < 4 || path.length > 150) return [];
return routeQualityAcceptable(path, { sea, elevation, slope, potential: populationDensity, highElevationThreshold: 0.80, steepThreshold: 0.55 }, {
minLength: 4,
maxLength: 150,
maxCompactness: 5.4,
maxHighElevationShare: 0.42,
maxSteepShare: 0.66,
}) ? path : [];
}
function attemptConnectSameLandmassAdminRoadComponents(comps) {
const result = { attempted: 0, added: 0, skippedIsland: 0, failed: 0 };
if (!comps || comps.length <= 1) return result;
const landIds = buildLandComponentIds();
const mainLand = majorityLandId(comps[0].cells, landIds);
const mainMask = new Uint8Array(MAP_W * MAP_H);
let sx = 0, sy = 0, sn = 0;
for (const ci of comps[0].cells) {
const [cx, cy] = xyOf(ci);
sx += cx; sy += cy; sn++;
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;
}
}
const centroid = { x: sx / Math.max(1, sn), y: sy / Math.max(1, sn) };
for (const comp of comps.slice(1, 24)) {
if (!componentNearAdminCenter(comp)) continue;
const land = majorityLandId(comp.cells, landIds);
if (land !== mainLand) { result.skippedIsland++; continue; }
result.attempted++;
const path = routeIsolatedComponentToMain(comp, mainMask, centroid, landIds, land);
if (path.length >= 4) {
minorRoads.push(path);
result.added++;
} else {
result.failed++;
}
}
return result;
}
let comps = components();
const before = comps.length;
const mountainConnect = attemptConnectSameLandmassAdminRoadComponents(comps);
if (mountainConnect.added > 0) comps = components();
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);
@ -776,7 +953,7 @@ export function finishMapOutput({
for (const [key, paths] of groups) {
const kept = [];
for (const path of paths || []) {
if (touchesMain(path) || pathNearAdminCenter(path)) kept.push(path);
if (touchesMain(path) || pathNearAdminCenter(path) || ((key === "expressway" || key === "externalExpressway") && pathNearRequiredExpresswayCity(path))) kept.push(path);
else pruned[key]++;
}
paths.length = 0;
@ -784,7 +961,7 @@ export function finishMapOutput({
}
comps = components();
}
debugLayers.finalOutputRoadConnectivity = { beforeComponents: before, afterComponents: comps.length, pruned };
debugLayers.finalOutputRoadConnectivity = { beforeComponents: before, afterComponents: comps.length, pruned, mountainAdminConnections: mountainConnect };
}
pruneIsolatedFinalRoadComponents();
@ -815,6 +992,178 @@ export function finishMapOutput({
}
ensureAdminCenterCellsAfterOutputPrune();
function connectNearbyRoadEndpoints() {
const debugLayers = transportDebug ? (transportDebug.layers || (transportDebug.layers = {})) : {};
const ordinaryGroups = [
{ key: "minor", paths: minorRoads || [] },
{ key: "national", paths: nationalRoads || [] },
{ key: "external", paths: externalRoads || [] },
{ key: "ring", paths: ringRoads || [] },
];
const occ = new Uint8Array(MAP_W * MAP_H);
function rasterize(path, fn) {
for (let k = 1; k < (path?.length || 0); k++) {
const [x0, y0] = path[k - 1];
const [x1, y1] = path[k];
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)]) fn(x, y);
}
}
}
for (const group of ordinaryGroups) for (const path of group.paths || []) rasterize(path, (x, y) => { occ[indexOf(x, y)] = 1; });
const comp = new Int32Array(MAP_W * MAP_H);
comp.fill(-1);
let compId = 0;
const queue = [];
for (let i = 0; i < occ.length; i++) {
if (!occ[i] || comp[i] >= 0) continue;
comp[i] = compId;
queue.length = 0;
queue.push(i);
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const [x, y] = xyOf(cur);
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)) continue;
const ni = indexOf(nx, ny);
if (!occ[ni] || comp[ni] >= 0) continue;
comp[ni] = compId;
queue.push(ni);
}
}
compId++;
}
function endpointComponent(x, y) {
if (!inside(x, y) || sea[indexOf(x, y)]) return -1;
const here = comp[indexOf(x, y)];
if (here >= 0) return here;
for (let r = 1; r <= 2; r++) {
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny) || sea[indexOf(nx, ny)]) continue;
const id = comp[indexOf(nx, ny)];
if (id >= 0) return id;
}
}
return -1;
}
function directConnector(a, b) {
const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y)));
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) || sea[indexOf(x, y)]) return [];
if (!path.length || path[path.length - 1][0] !== x || path[path.length - 1][1] !== y) path.push([x, y]);
}
return path.length >= 2 ? path : [];
}
const endpoints = [];
for (const group of ordinaryGroups) {
for (let pathIdx = 0; pathIdx < (group.paths?.length || 0); pathIdx++) {
const path = group.paths[pathIdx];
if (!path || path.length < 2) continue;
for (const end of [0, 1]) {
const raw = end === 0 ? path[0] : path[path.length - 1];
const x = Math.round(raw[0]), y = Math.round(raw[1]);
if (!inside(x, y) || sea[indexOf(x, y)]) continue;
const ci = indexOf(x, y);
const ruralBias = Math.max(0, 0.42 - (populationDensity?.[ci] || 0));
endpoints.push({ group: group.key, pathIdx, end, x, y, comp: endpointComponent(x, y), ruralBias });
}
}
}
const pairs = [];
for (let i = 0; i < endpoints.length; i++) {
const a = endpoints[i];
if (a.comp < 0) continue;
for (let j = i + 1; j < endpoints.length; j++) {
const b = endpoints[j];
if (b.comp < 0 || a.comp === b.comp) continue;
if (a.group === b.group && a.pathIdx === b.pathIdx) continue;
const d = Math.hypot(a.x - b.x, a.y - b.y);
const limit = (a.ruralBias + b.ruralBias) > 0.38 ? 6.5 : 4.4;
if (d < 1.1 || d > limit) continue;
const path = directConnector(a, b);
if (path.length < 2 || path.length > 9) continue;
pairs.push({ a, b, d, path, score: d - (a.ruralBias + b.ruralBias) * 1.25 + (a.group === "minor" && b.group === "minor" ? 0.25 : 0) });
}
}
pairs.sort((a, b) => a.score - b.score || a.d - b.d);
const used = new Set();
let added = 0;
for (const pair of pairs) {
if (added >= 180) break;
const ak = `${pair.a.group}:${pair.a.pathIdx}:${pair.a.end}`;
const bk = `${pair.b.group}:${pair.b.pathIdx}:${pair.b.end}`;
if (used.has(ak) || used.has(bk)) continue;
minorRoads.push(pair.path);
used.add(ak); used.add(bk);
added++;
}
debugLayers.nearbyRoadEndpointConnectorsAdded = added;
return added;
}
connectNearbyRoadEndpoints();
function renameInterchangesFromMunicipalities() {
if (!interchanges?.length || !adminCenters?.length || !adminId) return 0;
const centerByAdmin = new Map();
for (const center of adminCenters || []) {
if (!center || !inside(center.x, center.y)) continue;
const id = adminId[indexOf(center.x, center.y)];
if (id >= 0 && !centerByAdmin.has(id)) centerByAdmin.set(id, center);
}
const allCenters = [...centerByAdmin.values()].filter((c) => c?.name);
const used = new Set();
const directionNames = ["北", "東", "南", "西", "中央", "上", "下", "新"];
let renamed = 0;
function cleanBase(name) {
return String(name || "").replace(/[ICインターチェンジ\s]+$/u, "").replace(/[市町村区]$/u, "");
}
for (const [idx, ic] of interchanges.entries()) {
if (!ic || !inside(ic.x, ic.y)) continue;
const cell = indexOf(ic.x, ic.y);
const admin = adminId[cell];
const primary = centerByAdmin.get(admin) || allCenters.slice().sort((a, b) => Math.hypot(a.x - ic.x, a.y - ic.y) - Math.hypot(b.x - ic.x, b.y - ic.y))[0];
const nearbyCenters = allCenters
.slice()
.sort((a, b) => Math.hypot(a.x - ic.x, a.y - ic.y) - Math.hypot(b.x - ic.x, b.y - ic.y));
const candidates = [];
if (primary?.name) candidates.push(`${cleanBase(primary.municipalityName || primary.name)}IC`);
for (const center of nearbyCenters.slice(0, 12)) {
const base = cleanBase(center.municipalityName || center.name);
if (base) candidates.push(`${base}IC`);
}
if (primary?.name) {
const base = cleanBase(primary.municipalityName || primary.name);
for (const dir of directionNames) candidates.push(`${base}${dir}IC`);
}
candidates.push(`自治${idx + 1}IC`);
let name = candidates.find((candidate) => candidate && !used.has(candidate));
if (!name) name = `自治${idx + 1}IC`;
ic.name = name;
ic.labelName = name;
ic.municipalityNameBased = true;
used.add(name);
renamed++;
}
if (transportDebug) {
transportDebug.layers ||= {};
transportDebug.layers.municipalityBasedInterchangeNames = renamed;
}
return renamed;
}
renameInterchangesFromMunicipalities();
nameDebug.maxDerivedPerBase = 0;
const prefectureRegions = buildPrefectureRegions(prefectureRegionId, sea, nameFields, seed, usedNames, nameDebug, { modernCities, adminCenters });
const regionalPrefectureBorders = adminRegionalPrefectureBorders || [];

View file

@ -1,4 +1,4 @@
import { MAP_W, MAP_H, SIZE, indexOf, inside } from "./mapUtils.js";
import { INF, MAP_W, MAP_H, SIZE, MinHeap, indexOf, inside, xyOf } from "./mapUtils.js";
import { pathLengthCells } from "./mapTransport.js";
function cellKey(x, y) { return `${Math.round(x)},${Math.round(y)}`; }
@ -19,15 +19,38 @@ function pathTerrainRuns(path, terrain = null) {
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;
let sampled = 0;
const visit = (x, y) => {
if (!inside(x, y)) {
seaRun++;
maxSeaRun = Math.max(maxSeaRun, seaRun);
tunnelRun = 0;
sampled++;
return;
}
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);
// Use the same sensitive tunnel proxy as the main transport validator.
// Sampling every raster cell along each segment prevents smoothed or direct
// paths from hiding over-limit tunnel runs between sparse vertices.
const isTunnel = !isSea && (((elevation?.[i] || 0) >= 0.72 && (ridgeField?.[i] || 0) >= 0.34) || (naturalBarrierScore?.[i] || 0) >= 0.72);
if (isSea) { seaRun++; maxSeaRun = Math.max(maxSeaRun, seaRun); } else seaRun = 0;
if (isTunnel) { tunnelRun++; maxTunnelRun = Math.max(maxTunnelRun, tunnelRun); } else tunnelRun = 0;
sampled++;
};
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(b[0] - a[0], b[1] - a[1])));
for (let s = 0; s <= steps; s++) {
if (k > 1 && s === 0) continue;
const t = s / steps;
visit(Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t));
}
}
return { maxSeaRun, maxTunnelRun };
if ((path?.length || 0) === 1) visit(path[0][0], path[0][1]);
return { maxSeaRun, maxTunnelRun, sampled };
}
function directPath(a, b, options = {}) {
@ -50,6 +73,70 @@ function directPath(a, b, options = {}) {
return out;
}
function routeTerrainPath(a, b, terrain = null, options = {}) {
if (!a || !b || !inside(a.x, a.y) || !inside(b.x, b.y)) return [];
const sea = terrain?.sea;
const elevation = terrain?.elevation;
const slope = terrain?.slope;
const ridgeField = terrain?.ridgeField;
const start = indexOf(Math.round(a.x), Math.round(a.y));
const goal = indexOf(Math.round(b.x), Math.round(b.y));
if (sea?.[start] || sea?.[goal]) return [];
const straight = Math.hypot(a.x - b.x, a.y - b.y);
const maxLength = options.maxLength ?? straight * 2.8 + 60;
const maxExpanded = Math.min(SIZE, options.maxExpanded ?? Math.max(9000, Math.floor(straight * straight * 5.5)));
const dist = new Float64Array(SIZE);
dist.fill(INF);
const prev = new Int32Array(SIZE);
prev.fill(-1);
const closed = new Uint8Array(SIZE);
const heap = new MinHeap();
dist[start] = 0;
prev[start] = start;
heap.push({ i: start, f: straight * 0.42 });
let hit = -1;
let expanded = 0;
while (heap.length && expanded++ < maxExpanded) {
const current = heap.pop();
if (!current || closed[current.i]) continue;
const cur = current.i;
closed[cur] = 1;
const [x, y] = xyOf(cur);
if (Math.hypot(x - b.x, y - b.y) <= (options.snapRadius ?? 2.0)) { hit = cur; break; }
if (Math.hypot(x - a.x, y - a.y) > maxLength) continue;
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)) continue;
const ni = indexOf(nx, ny);
if (closed[ni] || sea?.[ni]) continue;
const step = Math.hypot(dx, dy);
const terrainCost = 1.0 + (slope?.[ni] || 0) * 1.55 + (ridgeField?.[ni] || 0) * 0.82 + Math.max(0, (elevation?.[ni] || 0) - 0.66) * 2.05;
const nd = dist[cur] + step * Math.max(0.42, terrainCost);
if (nd >= dist[ni]) continue;
dist[ni] = nd;
prev[ni] = cur;
const h = Math.hypot(nx - b.x, ny - b.y) * 0.42;
heap.push({ i: ni, f: nd + h });
}
}
if (hit < 0) return [];
const path = [];
let cur = hit;
for (let guard = 0; guard < Math.max(80, maxLength * 3) && cur >= 0; guard++) {
const [x, y] = xyOf(cur);
path.push([x, y]);
if (prev[cur] === cur) break;
cur = prev[cur];
}
path.reverse();
if (path.length < 2 || pathLengthCells(path) > maxLength) return [];
const runs = pathTerrainRuns(path, terrain);
if (Number.isFinite(options.maxSeaRun) && runs.maxSeaRun > options.maxSeaRun) return [];
if (Number.isFinite(options.maxTunnelRun) && runs.maxTunnelRun > options.maxTunnelRun) return [];
return path;
}
function nearestPointOnPaths(paths, p, maxDistance = Infinity) {
let best = null;
for (const path of paths || []) {
@ -194,6 +281,44 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge
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 relayGeometryAcceptable(points, options = {}) {
const pts = (points || []).filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y));
if (pts.length < 3) return true;
const first = pts[0];
const last = pts[pts.length - 1];
const vx = last.x - first.x;
const vy = last.y - first.y;
const direct = Math.hypot(vx, vy);
if (direct < 0.001) return false;
let via = 0;
for (let i = 1; i < pts.length; i++) via += Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
const maxDetour = options.maxDetour ?? 1.68;
if (via > direct * maxDetour + (options.detourSlack ?? 16)) return false;
const maxOffset = Math.max(options.minOffset ?? 14, Math.min(options.maxOffset ?? 30, direct * (options.offsetRatio ?? 0.32)));
for (let i = 1; i < pts.length - 1; i++) {
const p = pts[i];
const wx = p.x - first.x;
const wy = p.y - first.y;
const t = (wx * vx + wy * vy) / Math.max(0.0001, direct * direct);
const projX = first.x + vx * t;
const projY = first.y + vy * t;
const offset = Math.hypot(p.x - projX, p.y - projY);
if (t < (options.minProjection ?? -0.10) || t > (options.maxProjection ?? 1.10)) return false;
if (offset > maxOffset) return false;
}
return true;
}
function pathGeometryAcceptable(path, options = {}) {
if (!path || path.length < 3) return true;
const step = Math.max(1, Math.floor(path.length / 10));
const pts = [];
for (let k = 0; k < path.length; k += step) pts.push({ x: path[k][0], y: path[k][1] });
const last = path[path.length - 1];
pts.push({ x: last[0], y: last[1] });
return relayGeometryAcceptable(pts, options);
}
function nearestTrunkOrHub(p, maxDistance = 85) {
const trunk = nearestPointOnPaths([...nationalRoads, ...externalRoads], p, maxDistance);
if (trunk) return trunk;
@ -225,20 +350,31 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge
let townsCovered = 0;
while (uncovered.length) {
const start = uncovered.shift();
const chain = buildTownChain(start, uncovered, 7);
let chain = buildTownChain(start, uncovered, 7);
uncovered = uncovered.filter((town) => !chain.includes(town));
const parts = [];
const before = nearestTrunkOrHub(chain[0], 80);
let before = nearestTrunkOrHub(chain[0], 80);
let after = chain.length >= 2 ? nearestTrunkOrHub(chain[chain.length - 1], 80) : null;
const relayPoints = [before || chain[0], ...chain, after || chain[chain.length - 1]];
if (!relayGeometryAcceptable(relayPoints, { maxDetour: 1.62, minOffset: 12, maxOffset: 26, offsetRatio: 0.30 })) {
// The town-chain pass is a coverage fallback, not a mandate to drag a
// road through a remote off-axis waypoint. Collapse to a single spur
// when the waypoint chain would create a hooked or S-shaped route.
chain = [chain[0]];
before = nearestTrunkOrHub(chain[0], 80);
after = null;
}
if (before) {
const p = directPath(before, chain[0], { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 10 });
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 segmentPoints = [chain[i - 1], chain[i]];
if (!relayGeometryAcceptable(segmentPoints, { maxDetour: 1.25, minOffset: 10, maxOffset: 18 })) continue;
const p = directPath(chain[i - 1], chain[i], { maxLength: Math.max(28, d * 1.35 + 8), terrain, maxSeaRun: 10, maxTunnelRun: 10 });
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: 10 });
if (p.length) parts.push(p);
@ -248,7 +384,7 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge
const target = nearestTrunkOrHub(chain[0], 90);
path = target ? directPath(chain[0], target, { maxLength: 100, terrain, maxSeaRun: 10, maxTunnelRun: 10 }) : [];
}
if (path.length >= 2 && chain.some((town) => pathTouchesCell(path, town.x, town.y, 0.65))) {
if (path.length >= 2 && pathGeometryAcceptable(path, { maxDetour: 1.78, minOffset: 14, maxOffset: 32, offsetRatio: 0.34 }) && 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;
@ -286,30 +422,123 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge
return new Map(cities.map((city) => [majorCityKey(city), find(majorCityKey(city))]));
}
function suburbanExpresswayAnchorForCity(city, target = null) {
if (!city || !inside(city.x, city.y)) return null;
const sea = terrain?.sea;
const elevation = terrain?.elevation;
const slope = terrain?.slope;
const ridgeField = terrain?.ridgeField;
const inner = Math.max(8, Math.round((city.coreRadius || 4) + 6));
const outer = Math.max(inner + 7, Math.round((city.urbanRadius || 12) * 1.9));
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]) continue;
const radial = Math.abs(d - (inner + outer) * 0.52);
const targetBias = target ? Math.hypot(x - target.x, y - target.y) * 0.038 : 0;
const score = -radial * 0.26 - targetBias - (slope?.[i] || 0) * 0.70 - (ridgeField?.[i] || 0) * 0.55 - Math.max(0, (elevation?.[i] || 0) - 0.70) * 0.75;
if (!best || score > best.score) best = { x, y, score };
}
}
return best;
}
function suburbanExpresswayStubForCity(city, preferredAnchor = null) {
if (!city || !inside(city.x, city.y)) return [];
const angles = [];
if (preferredAnchor) angles.push(Math.atan2(preferredAnchor.y - city.y, preferredAnchor.x - city.x));
for (let k = 0; k < 8; k++) angles.push((Math.PI * 2 * k) / 8 + (k % 2 ? 0.18 : 0));
const seenAngles = new Set();
for (const angle of angles) {
const bucket = Math.round(angle * 100) / 100;
if (seenAngles.has(bucket)) continue;
seenAngles.add(bucket);
const hint = { x: Math.round(city.x + Math.cos(angle) * 120), y: Math.round(city.y + Math.sin(angle) * 120) };
const anchor = suburbanExpresswayAnchorForCity(city, hint);
if (!anchor) continue;
const minD = Math.max(20, (city.urbanRadius || 12) * 1.35);
const maxD = Math.max(minD + 10, (city.urbanRadius || 12) * 2.65);
let bestEnd = null;
for (let d = minD; d <= maxD; d += 2) {
const x = Math.round(city.x + Math.cos(angle) * d);
const y = Math.round(city.y + Math.sin(angle) * d);
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (terrain?.sea?.[i]) continue;
bestEnd = { x, y };
}
if (!bestEnd || Math.hypot(bestEnd.x - anchor.x, bestEnd.y - anchor.y) < 8) continue;
const d = Math.hypot(bestEnd.x - anchor.x, bestEnd.y - anchor.y);
let path = directPath(anchor, bestEnd, { maxLength: d * 1.8 + 12, terrain, maxSeaRun: 0, maxTunnelRun: 10 });
if (!path.length) path = routeTerrainPath(anchor, bestEnd, terrain, { maxLength: d * 2.6 + 20, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 1.6 });
if (path.length >= 4) return path;
}
return [];
}
function expresswayServesCityFringe(city) {
const inner = Math.max(7.0, (city.coreRadius || 4) + 4.5);
const outer = Math.max(inner + 7, (city.urbanRadius || 12) * 2.0);
for (const path of [...(features.expressways || []), ...(features.externalExpressways || [])]) {
let inBand = false;
let exits = false;
for (const [x, y] of path || []) {
const d = Math.hypot(x - city.x, y - city.y);
if (d >= inner && d <= outer) inBand = true;
if (d >= Math.max(24, (city.urbanRadius || 12) * 1.55)) exits = true;
if (inBand && exits) return true;
}
}
return false;
}
function ensureMajorCityExpresswayLinks(minPopulation = 100000) {
const cities = (features.modernCities || [])
.filter((city) => (city.population || 0) >= minPopulation && inside(city.x, city.y))
.sort((a, b) => (b.population || 0) - (a.population || 0));
const result = { minPopulation, checked: cities.length, added: 0 };
if (cities.length < 2) return result;
for (let iter = 0; iter < cities.length * 2; iter++) {
const comps = expresswayCityComponents(cities);
if (new Set(comps.values()).size <= 1) break;
let best = null;
for (const a of cities) {
for (const b of cities) {
if (a === b || comps.get(majorCityKey(a)) === comps.get(majorCityKey(b))) continue;
const d = Math.hypot(a.x - b.x, a.y - b.y);
const score = d / Math.max(1, Math.log2(Math.sqrt((a.population || 1) * (b.population || 1))));
if (!best || score < best.score) best = { a, b, d, score };
const result = { minPopulation, checked: cities.length, covered: 0, added: 0, noTarget: 0, noPath: 0 };
features.expressways ||= [];
if (!cities.length) return result;
for (const city of cities) {
if (expresswayServesCityFringe(city)) { result.covered++; continue; }
const existing = [...(features.expressways || []), ...(features.externalExpressways || [])];
let target = nearestPointOnPaths(existing, city, 145);
if (!target) {
const other = cities.find((c) => c !== city && expresswayServesCityFringe(c));
target = other ? suburbanExpresswayAnchorForCity(other, city) : null;
}
if (!target) { result.noTarget++; continue; }
const anchor = suburbanExpresswayAnchorForCity(city, target);
if (!anchor) { result.noTarget++; continue; }
const d = Math.hypot(anchor.x - target.x, anchor.y - target.y);
if (d < 4) { result.covered++; continue; }
let path = directPath(anchor, target, { maxLength: d * 1.35 + 18, terrain, maxSeaRun: 20, maxTunnelRun: 10 });
if (!path.length) path = directPath(anchor, target, { maxLength: d * 1.75 + 34, terrain, maxSeaRun: 20, maxTunnelRun: 10 });
if (!path.length) path = routeTerrainPath(anchor, target, terrain, { maxLength: d * 2.9 + 64, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 2.5 });
if (!path.length) {
const cityTargets = cities
.filter((other) => other !== city)
.map((other) => {
const otherAnchor = suburbanExpresswayAnchorForCity(other, anchor);
return otherAnchor ? { other, otherAnchor, d: Math.hypot(otherAnchor.x - anchor.x, otherAnchor.y - anchor.y) } : null;
})
.filter(Boolean)
.filter((row) => row.d >= 16 && row.d <= 185)
.sort((a, b) => a.d - b.d);
for (const row of cityTargets.slice(0, 6)) {
let candidate = directPath(anchor, row.otherAnchor, { maxLength: row.d * 1.6 + 26, terrain, maxSeaRun: 20, maxTunnelRun: 10 });
if (!candidate.length) candidate = routeTerrainPath(anchor, row.otherAnchor, terrain, { maxLength: row.d * 2.9 + 70, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 2.5 });
if (candidate.length >= 4) { path = candidate; break; }
}
}
if (!best) break;
let path = directPath(best.a, best.b, { maxLength: best.d * 1.18 + 12, terrain, maxSeaRun: 20 });
if (!path.length) path = directPath(best.a, best.b, { maxLength: best.d * 1.38 + 28, terrain });
if (!path.length) break;
path = smoothPath(path, 2);
expressways.push(path);
if (!path.length) path = suburbanExpresswayStubForCity(city, anchor);
if (!path.length || pathLengthCells(path) < 4) { result.noPath++; continue; }
features.expressways.push(smoothPath(path, 1));
result.added++;
}
return result;
@ -341,7 +570,7 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge
const a = chain[i - 1], b = chain[i];
const d = Math.hypot(a.x - b.x, a.y - b.y);
let p = directPath(a, b, { maxLength: d * 1.55 + 20, terrain, maxSeaRun: 10, maxTunnelRun: 10 });
if (!p.length) p = directPath(a, b, { maxLength: d * 1.85 + 40, terrain });
if (!p.length) p = directPath(a, b, { maxLength: d * 1.85 + 40, terrain, maxSeaRun: 10, maxTunnelRun: 10 });
if (p.length) parts.push(p);
}
const path = concatPaths(parts);
@ -360,22 +589,185 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge
const expressDebug = ensureMajorCityExpresswayLinks(100000);
debug.expresswayMajorCityLinksAdded = expressDebug.added;
debug.expresswayMajorCityLinksCovered = expressDebug.covered;
debug.expresswayMajorCityLinksNoTarget = expressDebug.noTarget;
debug.expresswayMajorCityLinksNoPath = expressDebug.noPath;
// 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++;
const runs = pathTerrainRuns(smoothed, terrain);
if (runs.maxTunnelRun <= 10 && runs.maxSeaRun <= 20) {
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++;
const expresswayBeforeTerrainPrune = expressways.length;
for (let i = expressways.length - 1; i >= 0; i--) {
const runs = pathTerrainRuns(expressways[i], terrain);
if (runs.maxTunnelRun > 10 || runs.maxSeaRun > 20) expressways.splice(i, 1);
}
debug.expresswaysPrunedForBridgeTunnelLimits = expresswayBeforeTerrainPrune - expressways.length;
function pointInsideCityNodeBuffer(x, y) {
for (const city of features.modernCities || []) {
if (!city || (city.population || 0) < 25000) continue;
const r = Math.max(4.2, (city.coreRadius || 3) + 1.6);
if (Math.hypot(x - city.x, y - city.y) <= r) return true;
}
return false;
}
function splitExpresswayAwayFromCityNodes(path) {
const chunks = [];
let cur = [];
for (const [x, y] of path || []) {
if (pointInsideCityNodeBuffer(x, y)) {
if (cur.length >= 2) chunks.push(cur);
cur = [];
continue;
}
if (!cur.length || cur[cur.length - 1][0] !== x || cur[cur.length - 1][1] !== y) cur.push([x, y]);
}
if (cur.length >= 2) chunks.push(cur);
return chunks.filter((chunk) => pathLengthCells(chunk) >= 12);
}
const expresswayBeforeCityNodePrune = expressways.length;
const separatedExpressways = [];
for (const path of expressways) separatedExpressways.push(...splitExpresswayAwayFromCityNodes(path));
expressways.length = 0;
expressways.push(...dedupePaths(separatedExpressways, 2));
debug.expresswaysPrunedForCityNodeSeparation = expresswayBeforeCityNodePrune - expressways.length;
function connectNearbyExpresswayTermini() {
const result = { candidates: 0, added: 0, failed: 0 };
const expressGroups = [
{ key: "expressway", paths: expressways },
{ key: "externalExpressway", paths: externalExpressways },
];
const endpoints = [];
for (const group of expressGroups) {
for (let pathIdx = 0; pathIdx < (group.paths?.length || 0); pathIdx++) {
const path = group.paths[pathIdx];
if (!path || path.length < 2) continue;
for (const end of [0, 1]) {
const raw = end === 0 ? path[0] : path[path.length - 1];
const x = Math.round(raw[0]), y = Math.round(raw[1]);
if (!inside(x, y) || terrain?.sea?.[indexOf(x, y)] || pointInsideCityNodeBuffer(x, y)) continue;
endpoints.push({ group: group.key, pathIdx, end, x, y });
}
}
}
const pairs = [];
for (let i = 0; i < endpoints.length; i++) {
const a = endpoints[i];
for (let j = i + 1; j < endpoints.length; j++) {
const b = endpoints[j];
if (a.group === b.group && a.pathIdx === b.pathIdx) continue;
const d = Math.hypot(a.x - b.x, a.y - b.y);
if (d < 3.0 || d > 24.0) continue;
pairs.push({ a, b, d, kind: "terminus-terminus" });
}
}
// Also snap a dead-end to the side of a nearby expressway if no terminal is
// close enough. This removes visible half-built expressway stubs without
// requiring every segment to be merged into a single polyline.
for (const a of endpoints) {
let best = null;
for (const group of expressGroups) {
for (let pathIdx = 0; pathIdx < (group.paths?.length || 0); pathIdx++) {
if (a.group === group.key && a.pathIdx === pathIdx) continue;
const path = group.paths[pathIdx];
for (let k = 1; k < (path?.length || 0) - 1; k += 2) {
const [x, y] = path[k];
const d = Math.hypot(a.x - x, a.y - y);
if (d < 3.0 || d > 14.0) continue;
if (!best || d < best.d) best = { a, b: { group: group.key, pathIdx, end: -1, x, y }, d, kind: "terminus-side" };
}
}
}
if (best) pairs.push(best);
}
pairs.sort((a, b) => a.d - b.d || (a.kind === "terminus-terminus" ? -1 : 1));
const used = new Set();
for (const pair of pairs) {
if (result.added >= 10) break;
const ak = `${pair.a.group}:${pair.a.pathIdx}:${pair.a.end}`;
const bk = `${pair.b.group}:${pair.b.pathIdx}:${pair.b.end}`;
if (used.has(ak) || (pair.b.end >= 0 && used.has(bk))) continue;
result.candidates++;
let path = directPath(pair.a, pair.b, { maxLength: pair.d * 1.65 + 18, terrain, maxSeaRun: 20, maxTunnelRun: 10 });
if (!path.length) path = routeTerrainPath(pair.a, pair.b, terrain, { maxLength: pair.d * 2.6 + 44, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 1.8 });
if (!path.length || pathLengthCells(path) < 3) { result.failed++; continue; }
const runs = pathTerrainRuns(path, terrain);
if (runs.maxTunnelRun > 10 || runs.maxSeaRun > 20) { result.failed++; continue; }
expressways.push(smoothPath(path, 1));
used.add(ak);
if (pair.b.end >= 0) used.add(bk);
result.added++;
}
return result;
}
const expresswayTerminusConnectDebug = connectNearbyExpresswayTermini();
debug.expresswayTerminusConnectionsAdded = expresswayTerminusConnectDebug.added;
debug.expresswayTerminusConnectionCandidates = expresswayTerminusConnectDebug.candidates;
debug.expresswayTerminusConnectionFailures = expresswayTerminusConnectDebug.failed;
function pointOnExpressway(p, radius = 1.5) {
return (expressways || []).some((path) => pathTouchesCell(path, p.x, p.y, radius));
}
const icBeforePrune = interchanges.length;
const pairedInterchanges = [];
const pairedAccessRoads = [];
for (let i = 0; i < interchanges.length; i++) {
const ic = interchanges[i];
const access = (features.icAccessRoads || [])[i];
if (ic && pointOnExpressway(ic, 1.8) && access && access.length >= 2) {
pairedInterchanges.push(ic);
pairedAccessRoads.push(access);
}
}
interchanges.length = 0;
interchanges.push(...pairedInterchanges);
features.icAccessRoads = pairedAccessRoads;
debug.interchangesPrunedWithoutExpresswayOrAccess = icBeforePrune - interchanges.length;
function ensureTerminalInterchangesWithAccess() {
const result = { endpointsChecked: 0, added: 0, accessAdded: 0, withoutAccess: 0 };
features.icAccessRoads ||= [];
const ordinaryRoads = [...(features.nationalRoads || []), ...(features.externalRoads || []), ...(features.minorRoads || [])];
for (const path of [...(features.expressways || []), ...(features.externalExpressways || [])]) {
if (!path || path.length < 2) continue;
for (const raw of [path[0], path[path.length - 1]]) {
const p = { x: Math.round(raw[0]), y: Math.round(raw[1]) };
result.endpointsChecked++;
if (!inside(p.x, p.y) || terrain?.sea?.[indexOf(p.x, p.y)]) continue;
if ((interchanges || []).some((ic) => Math.hypot(ic.x - p.x, ic.y - p.y) <= 2.8)) continue;
const hit = nearestPointOnPaths(ordinaryRoads, p, 58);
let access = [];
if (hit) {
const d = Math.hypot(p.x - hit.x, p.y - hit.y);
access = directPath(p, hit, { maxLength: d * 1.75 + 18, terrain, maxSeaRun: 0, maxTunnelRun: 8 });
if (access.length >= 2) {
features.icAccessRoads.push(access);
features.minorRoads ||= [];
features.minorRoads.push(access);
result.accessAdded++;
}
}
addInterchange(interchanges, p.x, p.y, access.length >= 2 ? "post-admin-terminal-ic" : "post-admin-terminal-ic-no-access");
result.added++;
if (access.length < 2) result.withoutAccess++;
}
}
return result;
}
const terminalIcDebug = ensureTerminalInterchangesWithAccess();
debug.expresswayTerminalInterchangesAdded = terminalIcDebug.added;
debug.expresswayTerminalInterchangeAccessAdded = terminalIcDebug.accessAdded;
debug.expresswayTerminalInterchangesWithoutAccess = terminalIcDebug.withoutAccess;
features.minorRoads = dedupePaths(minorRoads, 2);
features.nationalRoads = dedupePaths(nationalRoads, 1);

View file

@ -748,6 +748,69 @@ export function splitOversizedPrefecturesByMunicipalityCount(nodes, owner, maxCo
return changed;
}
export function relaxHighBoundaryShareMunicipalities(nodes, owner, options = {}) {
const threshold = options.threshold ?? 0.50;
const maxPasses = options.maxPasses ?? 5;
const minSharedToTarget = options.minSharedToTarget ?? 2;
let changed = 0;
for (let pass = 0; pass < maxPasses; pass++) {
let passChanged = 0;
const counts = prefectureMunicipalityCounts(owner);
const candidates = [];
for (const [id, pref] of owner) {
if (pref === undefined || pref < 0) continue;
const node = nodes.get(id);
if (!node || !node.adjacent?.size) continue;
if ((node.cityPopulation || 0) >= 180000 || (node.majorCityCount || 0) > 0) continue;
let totalBoundary = 0;
let sameBoundary = 0;
const byPref = new Map();
for (const [nextId, edge] of node.adjacent) {
const nPref = owner.get(nextId);
if (nPref === undefined || nPref < 0) continue;
const w = Math.max(1, edge.count || 1);
totalBoundary += w;
if (nPref === pref) sameBoundary += w;
else {
const row = byPref.get(nPref) || { pref: nPref, shared: 0, score: 0, minCrossing: INF };
row.shared += w;
row.score += w * 2.8 - (edge.crossingCost ?? (1 + (edge.barrier || 0) * 8.0)) * 0.55;
row.minCrossing = Math.min(row.minCrossing, edge.crossingCost ?? 1);
byPref.set(nPref, row);
}
}
if (totalBoundary <= 0) continue;
const borderBoundary = totalBoundary - sameBoundary;
const borderShare = borderBoundary / totalBoundary;
if (borderShare < threshold || !byPref.size) continue;
if ((counts.get(pref) || 0) <= Math.max(5, options.minSourceCount ?? 8)) continue;
if (!wouldRemainConnectedAfterRemoval(nodes, owner, id, pref)) continue;
const best = [...byPref.values()]
.filter((row) => row.shared >= minSharedToTarget)
.sort((a, b) => b.score - a.score || b.shared - a.shared || a.pref - b.pref)[0];
if (!best) continue;
const compactnessGain = best.shared - sameBoundary * 0.72 + borderShare * 6.0;
if (compactnessGain < 1.2 && best.score < 1.0) continue;
candidates.push({ id, from: pref, to: best.pref, borderShare, score: compactnessGain + best.score * 0.08 });
}
candidates.sort((a, b) => b.borderShare - a.borderShare || b.score - a.score || a.id - b.id);
const touched = new Set();
for (const cand of candidates) {
if (touched.has(cand.id) || owner.get(cand.id) !== cand.from) continue;
if (!wouldRemainConnectedAfterRemoval(nodes, owner, cand.id, cand.from)) continue;
owner.set(cand.id, cand.to);
touched.add(cand.id);
passChanged++;
}
if (!passChanged) break;
changed += passChanged;
repairPrefectureMunicipalityConnectivity(nodes, owner);
repairPrefectureMunicipalityEnclaves(nodes, owner, 6);
}
return changed;
}
export function lockPrefectureCapitalNeighborMunicipalities(owner, nodes, seeds = [], maxNeighbors = 6) {
let changed = 0;
const seedIds = new Set((seeds || []).map((node) => node?.id).filter((id) => id !== undefined));
@ -836,6 +899,10 @@ export function generatePrefecturesFromMunicipalities(context, adminResult) {
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 12);
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
const changedForBoundaryShareRelaxation = relaxHighBoundaryShareMunicipalities(graph.nodes, owner, { threshold: 0.50, maxPasses: 6 });
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 12);
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
const maxAdminId = Math.max(-1, ...[...graph.nodes.keys()]);
const municipalityToPrefectureId = new Int16Array(maxAdminId + 1);
municipalityToPrefectureId.fill(-1);
@ -880,6 +947,7 @@ export function generatePrefecturesFromMunicipalities(context, adminResult) {
prefectureTinyMunicipalityCountMergeChangedMunicipalities: changedForTinyPrefectureCountMerge || 0,
prefectureOversizedCountSplitChangedMunicipalities: changedForOversizedPrefectureSplit || 0,
prefectureCapitalNeighborLockChangedMunicipalities: changedForCapitalNeighborLock || 0,
prefectureBoundaryShareRelaxationChangedMunicipalities: changedForBoundaryShareRelaxation || 0,
finalRegionalMaxMunicipalityCount: Math.max(...prefectureMunicipalityCounts(owner).values()),
finalRegionalMunicipalityCountCap: 88,
finalRegionalMinMunicipalityCount: Math.min(...prefectureMunicipalityCounts(owner).values()),

View file

@ -4,6 +4,7 @@ import {
assessExpresswayRoute,
assessMountainRoute,
countReason,
createIncrementalPathInfluence,
fieldBackbonePolicy,
markPathInfluence as markPathInfluenceBase,
makeSpatialIndex,
@ -523,17 +524,17 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
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)
// Expressways are intercity corridors, but cities over 100k should still
// have a suburban motorway contact point. The portal search stays outside
// the dense core, so the rendered line does not snap to the city dot.
.filter((c) => (c.population || 0) >= 100000 || 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 fieldPortals = densityFieldAnchors("expressway", 11);
return dedupeAnchors([...urbanPortals, ...portPortals, ...gatewayPortals, ...fieldPortals].sort((a, b) => b.score - a.score), 11).slice(0, 30);
}
const cityPortals = modernCities.flatMap((c) => cityPortalAnchors(c, "national"));
@ -546,13 +547,13 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
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);
const fieldPortals = densityFieldAnchors("national", 40);
return dedupeAnchors([...cityPortals, ...marketPortals, ...villagePortals, ...portPortals, ...passPortals, ...gatewayPortals, ...fieldPortals].sort((a, b) => b.score - a.score), 5.5).slice(0, 68);
}
function buildTrafficFlowField(mode, anchors, baseCost, maxRoutes = 34) {
const flow = new Float32Array(SIZE);
const nodes = anchors.slice(0, mode === "expressway" ? 24 : 56);
const nodes = anchors.slice(0, mode === "expressway" ? 18 : 42);
const pairs = [];
for (let a = 0; a < nodes.length; a++) {
for (let b = a + 1; b < nodes.length; b++) {
@ -788,7 +789,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
const policy = fieldBackbonePolicy(config.fieldPolicyMode || mode);
let connectedAdds = 0;
let extraAdds = 0;
const maxAdded = options.maxAdded ?? (mode === "expressway" ? Math.min(10, Math.max(4, Math.ceil((options.nodeCount || 1) / 4))) : Math.min(34, Math.max(16, Math.ceil((options.nodeCount || 1) * 0.46))));
const maxAdded = options.maxAdded ?? (mode === "expressway" ? Math.min(7, Math.max(3, Math.ceil((options.nodeCount || 1) / 5))) : Math.min(26, Math.max(12, Math.ceil((options.nodeCount || 1) * 0.36))));
const maxExtra = options.maxExtra ?? config.backbone?.maxExtra ?? 0;
const maxDegree = options.maxDegree ?? config.backbone?.maxDegree ?? 3;
for (const pair of pairs) {
@ -800,7 +801,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
if ((degree.get(ak) || 0) >= maxDegree || (degree.get(bk) || 0) >= maxDegree) { skip.degree++; continue; }
const routeOptions = {
...config.routeOptions,
maxPathLength: pair.d * (mode === "expressway" ? 2.75 : 2.55) + (mode === "expressway" ? 84 : 42),
maxPathLength: pair.d * (mode === "expressway" ? 2.45 : 2.28) + (mode === "expressway" ? 66 : 32),
...options.routeOptions,
};
const path = routeBetweenTrafficCandidates(pair.A, pair.B, mode, costField, penalty, routeOptions);
@ -859,7 +860,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
function buildFieldBackbone(debug, mode, outPaths, anchors, costField, potentialField, options = {}) {
const config = roadMode(mode);
const nodes = anchors.slice(0, options.maxNodes ?? (mode === "expressway" ? 38 : 76));
const nodes = anchors.slice(0, options.maxNodes ?? (mode === "expressway" ? 30 : 58));
if (nodes.length < 2) return;
const penalty = new Float32Array(SIZE);
const accepted = new Float32Array(SIZE);
@ -952,6 +953,25 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
return false;
}
function pathServesSuburbanAnchor(path, city, anchor, radius = 7.0) {
if (!path?.length || !city || !anchor) return false;
const coreAvoid = Math.max(4.5, (city.coreRadius || 3.5) + 1.5);
const fringeRadius = Math.max(radius, (city.urbanRadius || 12) * 0.42);
const exitRadius = Math.max(24, (city.urbanRadius || 12) * 1.55);
let nearAnchor = false;
let outsideCore = false;
let exitsEnvelope = false;
for (const [x, y] of path) {
const da = Math.hypot(x - anchor.x, y - anchor.y);
const dc = Math.hypot(x - city.x, y - city.y);
if (da <= fringeRadius) nearAnchor = true;
if (dc >= coreAvoid) outsideCore = true;
if (dc >= exitRadius) exitsEnvelope = true;
if (nearAnchor && outsideCore && exitsEnvelope) return true;
}
return false;
}
function ensureCityOutboundConnections({
debug,
mode,
@ -962,7 +982,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
penalty,
paths,
acceptedPaths = [],
maxTargets = 8,
maxTargets = 5,
minDistance = 14,
maxDistance = 126,
routeOptions = {},
@ -978,7 +998,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
for (const city of cities) {
const start = startForCity(city);
if (!start) continue;
if (paths.some((path) => pathServesCityCenter(path, city, mode))) continue;
if (paths.some((path) => mode === "expressway" ? pathServesSuburbanAnchor(path, city, start, 7.0) : pathServesCityCenter(path, city, mode))) 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, costField) }))
@ -1042,7 +1062,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
return best;
}
const eligibleCities = modernCities
.filter((c) => (c.population || 0) >= 150000 || c.isRegionalCapital || c.isPrefecturalCapital)
.filter((c) => (c.population || 0) >= 100000 || c.isRegionalCapital || c.isPrefecturalCapital)
.sort((a, b) => (b.population || 0) - (a.population || 0));
const cityAnchors = eligibleCities
.map((city) => {
@ -1066,7 +1086,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
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; }
if (expressways.some((path) => pathServesSuburbanAnchor(path, city, anchor, 7.0))) { stats.covered++; continue; }
const candidates = otherTargets
.filter((q) => q !== anchor && q.city !== city)
.map((q) => {
@ -1082,7 +1102,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
return (a.d * a.c + aCity) - (b.d * b.c + bCity);
});
if (!candidates.length) stats.noCandidates++;
for (const opt of candidates.slice(0, 10)) {
for (const opt of candidates.slice(0, 6)) {
stats.tried++;
const path = routeBetweenTrafficCandidates(anchor, opt.q, "expressway", forceCost, penalty, {
curvePenalty: 0.13,
@ -1186,24 +1206,24 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
.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 expressFlow = buildTrafficFlowField("expressway", expressAnchors, expresswayCorridorCost, 10);
const expressCost = fieldAdjustedCost(expresswayCorridorCost, "expressway", expressFlow);
buildFieldBackbone(debug, "expressway", expressways, expressAnchors, expressCost, transportFields.expresswayPotential, {
...roadMode("expressway").backbone,
maxNodes: 48,
maxAdded: Math.min(4, Math.max(2, Math.ceil(expressAnchors.length / 12))),
maxNodes: 34,
maxAdded: Math.min(3, Math.max(2, Math.ceil(expressAnchors.length / 14))),
});
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 nationalFlow = buildTrafficFlowField("national", nationalAnchors, transportFields.national, 34);
const nationalCost = fieldAdjustedCost(transportFields.national, "national", nationalFlow);
buildFieldBackbone(debug, "national", nationalRoads, nationalAnchors, nationalCost, transportFields.nationalPotential, {
...roadMode("national").backbone,
maxNodes: 78,
maxAdded: Math.min(38, Math.max(18, Math.ceil(nationalAnchors.length * 0.45))),
maxNodes: 58,
maxAdded: Math.min(27, Math.max(13, Math.ceil(nationalAnchors.length * 0.34))),
});
// Guarantee light national access to large urban areas whose portals were
@ -1485,11 +1505,11 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
repairTransportConnectivity(expressways, "expressway", expresswayCorridorCost, transportFields.expresswayPotential, {
minImportance: 5.5,
minComponentCells: 10,
maxComponents: 8,
maxRepairs: 3,
maxRepairDistance: 145,
maxComponents: 6,
maxRepairs: 2,
maxRepairDistance: 125,
minRepairDistance: 18,
searchPad: 48,
searchPad: 38,
penaltyRadius: 18,
penaltyStrength: 4.2,
curvePenalty: 0.13,
@ -1502,11 +1522,11 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
repairTransportConnectivity(nationalRoads, "national", transportFields.national, transportFields.nationalPotential, {
minImportance: 4.0,
minComponentCells: 7,
maxComponents: 14,
maxRepairs: 12,
maxRepairDistance: 96,
maxComponents: 10,
maxRepairs: 7,
maxRepairDistance: 82,
minRepairDistance: 8,
searchPad: 36,
searchPad: 28,
penaltyRadius: 6,
penaltyStrength: 1.15,
curvePenalty: 0.060,
@ -1519,9 +1539,9 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
repairTransportConnectivity(railways, "rail", transportFields.rail, transportFields.railPotential, {
minImportance: 10.5,
minComponentCells: 18,
maxComponents: 6,
maxRepairs: 3,
maxRepairDistance: 105,
maxComponents: 5,
maxRepairs: 2,
maxRepairDistance: 92,
penaltyRadius: 7,
penaltyStrength: 2.0,
curvePenalty: 0.16,
@ -1550,9 +1570,9 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
}
const nationalEndpointRepair = repairDanglingTransportEndpoints(nationalRoads, "national", transportFields.national, [...externalRoads, ...expressways, ...railways], transportFields.nationalPotential, {
maxAdded: 18,
maxTargetDistance: 58,
maxPathLength: 86,
maxAdded: 11,
maxTargetDistance: 50,
maxPathLength: 74,
curvePenalty: 0.060,
terrainFlowBias: 0.24,
surfaceGrain: 0.030,
@ -1566,9 +1586,9 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
transportDebugLayers.repairedSegments.push(...nationalEndpointRepair.added);
const expressEndpointRepair = repairDanglingTransportEndpoints(expressways, "expressway", expresswayCorridorCost, [...externalExpressways, ...nationalRoads], transportFields.expresswayPotential, {
maxAdded: 3,
maxTargetDistance: 78,
maxPathLength: 128,
maxAdded: 2,
maxTargetDistance: 68,
maxPathLength: 110,
curvePenalty: 0.13,
terrainFlowBias: 0.09,
surfaceGrain: 0.006,
@ -1633,26 +1653,27 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
})
.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);
.slice(0, 92);
const localPenaltyAccumulator = createIncrementalPathInfluence([], 4, { sea });
const localPenalty = localPenaltyAccumulator.field;
const paths = [];
const served = [];
for (const start of candidates) {
if (paths.length >= 115) break;
if (paths.length >= 72) 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 }
{ curvePenalty: 0.08, penaltyStrength: 1.00, minGoalDistance: 5, regionId: start.regionId, maxExpanded: Math.floor(SIZE * 0.56) }
);
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);
localPenaltyAccumulator.add(path, 0.22, 4);
}
return paths;
}
@ -1679,9 +1700,9 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
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 }
{ curvePenalty: 0.020, penaltyStrength: 0.90, minGoalDistance: 3, regionId: start.regionId, maxExpanded: Math.floor(SIZE * 0.50), terrainFlowBias: 0.26, surfaceGrain: 0.042 }
);
path = relaxRouteToTerrain(path, transportFields.local, { radius: 2, lineWeight: 0.25, grain: 0.048, iterations: 1 });
if (path.length <= 48) 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 });
@ -1691,15 +1712,15 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
added++;
transportDebugLayers.repairedSegments.push({ mode: "local", path, from, to });
addCorridorInfluencePenalty(localPenalty, path, 4, 0.18);
if (accessInfluence) addCorridorInfluencePenalty(accessInfluence, path, 5, 0.22);
if (accessInfluence) markPathInfluence(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,
maxAdded: 22,
maxTargetDistance: 30,
curvePenalty: 0.055,
terrainFlowBias: 0.26,
surfaceGrain: 0.048,
@ -1858,6 +1879,10 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
const cum = pathCumulativeLengths(path);
const total = cum[cum.length - 1] || 0;
if (total < absoluteMinGap * 1.6) continue;
const firstEndpoint = { x: path[0][0], y: path[0][1], s: 0, terminal: true };
const lastEndpoint = { x: path[path.length - 1][0], y: path[path.length - 1][1], s: total, terminal: true };
addInterchange(firstEndpoint, nearestRoadForIc(firstEndpoint, 38.0 + icDemandAt(firstEndpoint) * 14.0, true));
addInterchange(lastEndpoint, nearestRoadForIc(lastEndpoint, 38.0 + icDemandAt(lastEndpoint) * 14.0, true));
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] };

145
mapTransportGraph.js Normal file
View file

@ -0,0 +1,145 @@
import { INF, MAP_H, MAP_W, MinHeap, indexOf, inside } from "./mapUtils.js";
export function buildCoarseCostGraph({ sea, costField }, options = {}) {
const scale = options.scale ?? 4;
const cw = Math.ceil(MAP_W / scale);
const ch = Math.ceil(MAP_H / scale);
const size = cw * ch;
const cost = new Float32Array(size);
const passable = new Uint8Array(size);
cost.fill(INF);
for (let cy = 0; cy < ch; cy++) {
for (let cx = 0; cx < cw; cx++) {
let best = INF;
let sum = 0;
let n = 0;
for (let dy = 0; dy < scale; dy++) {
for (let dx = 0; dx < scale; dx++) {
const x = cx * scale + dx;
const y = cy * scale + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (sea?.[i] || costField?.[i] >= INF) continue;
const v = costField[i];
best = Math.min(best, v);
sum += v;
n++;
}
}
const ci = cy * cw + cx;
if (n > 0) {
passable[ci] = 1;
cost[ci] = best * 0.55 + (sum / n) * 0.45;
}
}
}
return { scale, cw, ch, size, cost, passable };
}
export function routeCoarsePath(start, goal, graph, options = {}) {
if (!start || !goal || !graph) return [];
const sx = Math.floor(start.x / graph.scale);
const sy = Math.floor(start.y / graph.scale);
const gx = Math.floor(goal.x / graph.scale);
const gy = Math.floor(goal.y / graph.scale);
if (sx < 0 || sy < 0 || sx >= graph.cw || sy >= graph.ch || gx < 0 || gy < 0 || gx >= graph.cw || gy >= graph.ch) return [];
const startCi = sy * graph.cw + sx;
const goalCi = gy * graph.cw + gx;
if (!graph.passable[startCi] || !graph.passable[goalCi]) return [];
const score = new Float32Array(graph.size);
const cameFrom = new Int32Array(graph.size);
const closed = new Uint8Array(graph.size);
score.fill(INF);
cameFrom.fill(-1);
score[startCi] = 0;
const heap = new MinHeap();
heap.push({ i: startCi, f: Math.hypot(sx - gx, sy - gy) });
const maxExpanded = options.maxExpanded ?? graph.size;
let expanded = 0;
let found = -1;
while (heap.length && expanded++ < maxExpanded) {
const cur = heap.pop();
if (!cur || closed[cur.i]) continue;
closed[cur.i] = 1;
if (cur.i === goalCi) {
found = cur.i;
break;
}
const cx = cur.i % graph.cw;
const cy = Math.floor(cur.i / graph.cw);
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = cx + dx;
const ny = cy + dy;
if (nx < 0 || ny < 0 || nx >= graph.cw || ny >= graph.ch) continue;
const ni = ny * graph.cw + nx;
if (closed[ni] || !graph.passable[ni]) continue;
const nd = score[cur.i] + graph.cost[ni] * Math.hypot(dx, dy);
if (nd < score[ni]) {
score[ni] = nd;
cameFrom[ni] = cur.i;
heap.push({ i: ni, f: nd + Math.hypot(nx - gx, ny - gy) * (options.heuristicWeight ?? 0.85) });
}
}
}
}
if (found < 0) return [];
const path = [];
for (let p = found; p >= 0; p = cameFrom[p]) {
const cx = p % graph.cw;
const cy = Math.floor(p / graph.cw);
path.push([
Math.min(MAP_W - 1, Math.round(cx * graph.scale + graph.scale * 0.5)),
Math.min(MAP_H - 1, Math.round(cy * graph.scale + graph.scale * 0.5)),
]);
if (p === startCi) break;
}
return path.reverse();
}
export function refineCoarsePath(path, costField, options = {}) {
if (!path || path.length < 2) return [];
const sea = options.sea || null;
const radius = options.snapRadius ?? 1;
const out = [];
let lastKey = "";
for (let k = 0; k < path.length - 1; k++) {
const a = path[k];
const b = path[k + 1];
const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])));
for (let s = k === 0 ? 0 : 1; s <= steps; s++) {
const t = s / steps;
const tx = Math.round(a[0] + (b[0] - a[0]) * t);
const ty = Math.round(a[1] + (b[1] - a[1]) * t);
let best = null;
let bestScore = INF;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const x = tx + dx;
const y = ty + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (sea?.[i] || costField?.[i] >= INF) continue;
const score = costField[i] + Math.hypot(dx, dy) * 0.22;
if (score < bestScore) {
bestScore = score;
best = [x, y];
}
}
}
if (!best) return [];
const key = `${best[0]},${best[1]}`;
if (key !== lastKey) {
out.push(best);
lastKey = key;
}
}
}
return out.length >= 2 ? out : [];
}

View file

@ -40,7 +40,9 @@ export function buildUnifiedRailODNetwork(ctx) {
transportRouteAcceptable,
pruneParallelSameMode,
cachedInfluenceFromPaths,
speedTolerance = 1,
} = ctx;
const speedScale = clamp(speedTolerance, 0.75, 1);
const railways = [];
const branchRailways = [];
@ -220,8 +222,8 @@ export function buildUnifiedRailODNetwork(ctx) {
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),
searchPad: Math.ceil(Math.max(18, Math.min(56, pair.d * 0.40))),
maxPathLength: pair.d * (branch ? 2.12 : 2.30) + (branch ? 16 : 30),
maxSeaRun: 1,
maxSeaShare: 0.006,
});
@ -260,18 +262,18 @@ export function buildUnifiedRailODNetwork(ctx) {
.filter(Boolean);
const anchorNodes = geographicUrbanAnchors
.filter((a) => (a.score || 0) > 0.76)
.slice(0, 8)
.slice(0, Math.max(5, Math.round(8 * speedScale)))
.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);
.slice(0, Math.max(24, Math.round(34 * speedScale)));
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);
], 7).slice(0, Math.max(14, Math.round(18 * speedScale)));
}
debug.nodeCounts = {
regionalCityNodes: regionalCityNodes.length,
@ -300,7 +302,7 @@ export function buildUnifiedRailODNetwork(ctx) {
const uf = makeUnionFind(trunkNodes, keyOf);
const penalty = new Float32Array(SIZE);
const maxTrunk = Math.min(18, Math.max(4, trunkNodes.length - 1));
const maxTrunk = Math.min(Math.max(14, Math.round(18 * speedScale)), Math.max(4, trunkNodes.length - 1));
let connectedEdges = 0;
for (const pair of trunkPairs) {
if (connectedEdges >= maxTrunk) break;
@ -322,7 +324,7 @@ export function buildUnifiedRailODNetwork(ctx) {
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;
if (loopAdded >= Math.min(Math.max(4, Math.round(7 * speedScale)), Math.max(2, Math.ceil(trunkNodes.length / 6)))) 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;
@ -347,7 +349,7 @@ export function buildUnifiedRailODNetwork(ctx) {
...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);
], 6).sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, Math.max(26, Math.round(36 * speedScale)));
const trunkTargets = [];
for (const path of railways) {
@ -361,7 +363,7 @@ export function buildUnifiedRailODNetwork(ctx) {
let branchAdded = 0;
for (const node of branchCandidates) {
if (branchAdded >= 14) break;
if (branchAdded >= Math.max(10, Math.round(14 * speedScale))) break;
const ni = indexOf(node.x, node.y);
if ((railInfluence[ni] || 0) > 0.34) continue;
const options = trunkTargets
@ -374,7 +376,7 @@ export function buildUnifiedRailODNetwork(ctx) {
})
.filter(Boolean)
.sort((a, b) => a.score - b.score);
for (const pair of options.slice(0, 5)) {
for (const pair of options.slice(0, 3)) {
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;

View file

@ -70,6 +70,19 @@ export function markPathInfluence(field, path, radius = 5, strength = 1, sea = n
}
}
export function createIncrementalPathInfluence(initialPaths = [], radius = 5, options = {}) {
const field = new Float32Array(SIZE);
const sea = options.sea || null;
for (const path of initialPaths || []) markPathInfluence(field, path, radius, 1, sea);
return {
field,
add(path, strength = 1, addRadius = radius) {
markPathInfluence(field, path, addRadius, strength, sea);
return field;
},
};
}
export function sampledNetworkCells(paths, step = 2, sea = null) {
const cells = [];
for (let pathId = 0; pathId < (paths || []).length; pathId++) {

View file

@ -218,14 +218,6 @@ function vectorPath(path) {
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]);
@ -545,8 +537,8 @@ function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) {
ctx.restore();
}
function drawPath(ctx, path, color, width, dashed = false, mode = "default") {
const points = vectorPathMode(path, mode);
function drawPath(ctx, path, color, width, dashed = false) {
const points = vectorPath(path);
if (points.length < 2) return;
ctx.save();
ctx.lineCap = "round";
@ -580,99 +572,8 @@ function landOnlySubpaths(map, path, minCells = 2) {
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" && mode !== "national") return;
const vectorMode = mode === "expressway" ? "expressway" : "default";
const limit = mode === "expressway" ? 10 : 10;
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,
limit
);
for (const chunk of tunnelChunks) {
drawDottedOutlinePath(ctx, chunk, "rgba(42, 42, 42, 0.96)", mode === "expressway" ? 1.15 : 1.0, Math.max(1.75, width * 0.82), vectorMode);
}
}
function drawExpresswayPath(ctx, map, path, color, width, dashed = false, minCells = 2) {
drawLandPath(ctx, map, path, color, width, dashed, minCells, "expressway");
function drawLandPath(ctx, map, path, color, width, dashed = false, minCells = 2) {
for (const chunk of landOnlySubpaths(map, path, minCells)) drawPath(ctx, chunk, color, width, dashed);
}
// 魚の骨(私鉄記号)スタイルを描画するための専用関数
@ -949,7 +850,7 @@ function drawLabels(ctx, points, limit = Infinity, occupied = null) {
}
function drawScaleBar(ctx) {
const kmPerCell = 1;
const kmPerCell = 0.5;
const targetKm = 25;
const lengthCells = Math.max(8, Math.round(targetKm / kmPerCell));
const lengthPx = lengthCells * CELL_SIZE;
@ -1117,8 +1018,8 @@ export function drawMap(canvas, map, options) {
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) 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);
for (const path of map.expressways) drawLandPath(ctx, map, path, "rgba(105, 125, 105, 0.78)", 4.6);
for (const path of map.externalExpressways) drawLandPath(ctx, map, path, "rgba(105, 125, 105, 0.78)", 4.6);
}
// 5. Transport fills. Expressways remain above railways; railways sit above ordinary roads.
@ -1136,11 +1037,8 @@ export function drawMap(canvas, map, options) {
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 generalRoadPaths) { drawBridgeOverlay(ctx, map, path, 1.55, "road"); }
for (const path of map.nationalRoads) { drawBridgeOverlay(ctx, map, path, 2.0, "road"); drawTunnelOverlay(ctx, map, path, 1.9, "national"); }
for (const path of map.externalRoads) { drawBridgeOverlay(ctx, map, path, 2.0, "road"); drawTunnelOverlay(ctx, map, path, 1.9, "national"); }
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"); }
for (const path of map.expressways) drawLandPath(ctx, map, path, "rgba(135, 160, 135, 0.88)", 2.4);
for (const path of map.externalExpressways) drawLandPath(ctx, map, path, "rgba(135, 160, 135, 0.88)", 2.4);
}
// 6. Icons & Labels
@ -1148,15 +1046,22 @@ export function drawMap(canvas, map, options) {
for (const p of map.adminCenters || []) dot(ctx, p, 2.8, "rgba(255,255,255,0.96)", "rgba(75,60,90,0.95)");
}
const settlementIconLabelPoints = ["all", "modern", "history"].includes(mode)
? [
...(map.markets || []).filter((p) => (p.population || 0) >= 3000),
...(map.villages || []).filter((p) => (p.population || 0) >= 3000),
...(mode === "history" ? (map.ports || []).filter((p) => p.portClass === "major" || p.portClass === "regional") : []),
...(mode === "history" ? (map.castles || []) : []),
].filter((p) => !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y))
.map((p) => ({ ...p, forceAllLayerTownLabel: true, labelPriorityBase: p.labelPriorityBase || (p.kind === "Village" ? 58 : p.kind === "Castle" ? 88 : 66) }))
: [];
if (["all", "modern", "history"].includes(mode)) {
for (const p of settlementIconLabelPoints) dot(ctx, p, p.kind === "Castle" ? 3.2 : 3.1, "rgba(240, 110, 110, 0.95)", "rgba(255,255,255,0.88)");
}
if (showModern) {
for (const p of map.stations) dot(ctx, p, 2.5, "#fff", "#444");
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))
: [];
for (const p of allLayerTowns) dot(ctx, p, 3.1, "rgba(244, 164, 118, 0.92)", "rgba(255,255,255,0.88)");
for (const p of map.modernCities) {
const popRadius = p.population ? Math.min(9.4, 3.8 + Math.sqrt(p.population) / 360) : 4.8;
dot(ctx, p, popRadius, "rgba(240, 110, 110, 0.95)", "rgba(255,255,255,0.9)");
@ -1177,37 +1082,29 @@ export function drawMap(canvas, map, options) {
}
if (showLabels) {
const prefectureLabels = (map.prefectureRegions || []).map((p) => ({ ...p, isPrefectureLabel: true, labelPriorityBase: p.labelPriorityBase || 1700 }));
const prefectureLabels = (map.prefectureRegions || []).map((p) => ({ ...p, isPrefectureLabel: true, forceLabel: true, labelPriorityBase: p.labelPriorityBase || 1900 }));
if (mode === "admin") {
drawLabels(ctx, [...prefectureLabels, ...(map.adminCenters || [])], Infinity);
const municipalLabels = (map.adminCenters || [])
.filter((p) => p && p.name)
.map((p) => ({ ...p, labelStyle: "municipality", forceLabel: true, labelPriorityBase: 1200 }));
drawLabels(ctx, [...prefectureLabels, ...municipalLabels], Infinity);
drawScaleBar(ctx);
return;
}
if (mode === "borders-debug") {
drawLabels(ctx, [...prefectureLabels, ...(map.adminCenters || [])], Infinity);
drawLabels(ctx, prefectureLabels, Infinity);
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))
: [];
const important = [
...prefectureLabels,
...map.modernCities,
...(map.ports || []).map((p) => ({
...p,
labelPriorityBase: p.portClass === "major" ? 170 : p.portClass === "regional" ? 120 : p.portClass === "fishing" ? 95 : 85,
})),
...map.ports,
...(map.logisticsParks || []).map((p) => ({ ...p, labelPriorityBase: 60 })),
...(map.satelliteCities || []),
...allLayerTowns.map((p) => ({ ...p, forceAllLayerTownLabel: true, labelPriorityBase: 80 })),
...settlementIconLabelPoints,
].filter((p) => p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000);
drawLabels(ctx, important, mode === "all" ? 95 : 60);
drawLabels(ctx, important, mode === "all" || mode === "history" ? 78 : 60);
}
drawScaleBar(ctx);
}