This commit is contained in:
33333-33333 2026-05-28 00:30:09 +09:00
commit b17be0e0d2
21 changed files with 8034 additions and 2737 deletions

View file

@ -306,11 +306,11 @@ export function terrainBoundaryTargetScore(i, elevation, slope, river, ridgeFiel
const urbanPenalty = urbanBoundaryPenalty(i, 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); const majorRiver = clamp(Math.max(river[i] - 0.32, 0) * 1.9 + Math.max(flowAccum[i] - 0.38, 0) * 0.75);
const minorStream = clamp(river[i] * 0.34 + flowAccum[i] * 0.18); const minorStream = clamp(river[i] * 0.34 + flowAccum[i] * 0.18);
const ridgeDivide = clamp(ridgeField[i] * 1.55 + Math.max(0, elevation[i] - 0.54) * ridgeField[i] * 0.95); const ridgeDivide = clamp(ridgeField[i] * 2.12 + Math.max(0, elevation[i] - 0.52) * ridgeField[i] * 1.32);
const slopeBreak = clamp(slope[i] * 0.58 + Math.max(0, slope[i] - 0.32) * 0.68); const slopeBreak = clamp(slope[i] * 0.70 + Math.max(0, slope[i] - 0.30) * 0.88);
const highGround = Math.max(0, elevation[i] - 0.56) * 0.22; const highGround = Math.max(0, elevation[i] - 0.54) * 0.34;
const valleyFloorPenalty = valleyField[i] * (majorRiver > 0.34 ? -0.10 : -0.62); const valleyFloorPenalty = valleyField[i] * (majorRiver > 0.34 ? -0.08 : -0.68);
return clamp(ridgeDivide + majorRiver * 0.88 + minorStream * 0.22 + slopeBreak + highGround + valleyFloorPenalty - urbanPenalty * 0.72); return clamp(ridgeDivide + majorRiver * 0.92 + minorStream * 0.20 + slopeBreak + highGround + valleyFloorPenalty - urbanPenalty * 0.92);
} }
function urbanBoundaryPenalty(i, populationDensity, landuse) { function urbanBoundaryPenalty(i, populationDensity, landuse) {
@ -518,20 +518,20 @@ export function buildNaturalBarrierScore(prefectureMask, sea, elevation, slope,
for (const [nx, ny] of neighbors8(x, y)) if (sea[indexOf(nx, ny)]) coastEdge = 1; for (const [nx, ny] of neighbors8(x, y)) if (sea[indexOf(nx, ny)]) coastEdge = 1;
const urbanContinuity = populationDensity && landuse ? urbanBoundaryPenalty(i, populationDensity, landuse) : 0; const urbanContinuity = populationDensity && landuse ? urbanBoundaryPenalty(i, populationDensity, landuse) : 0;
const majorRiver = clamp(Math.max(river[i] - 0.34, 0) * 1.95 + Math.max(flowAccum[i] - 0.42, 0) * 0.82); const majorRiver = clamp(Math.max(river[i] - 0.34, 0) * 1.95 + Math.max(flowAccum[i] - 0.42, 0) * 0.82);
const ridgeDivide = clamp(ridgeField[i] * 1.65 + Math.max(0, elevation[i] - 0.52) * ridgeField[i] * 1.05); const ridgeDivide = clamp(ridgeField[i] * 2.05 + Math.max(0, elevation[i] - 0.50) * ridgeField[i] * 1.28);
const crest = crestCrossingScore ? crestCrossingScore[i] : clamp(Math.max(0, elevation[i] - 0.55) * ridgeField[i] * 1.4 + slope[i] * ridgeField[i] * 0.8); const crest = crestCrossingScore ? crestCrossingScore[i] : clamp(Math.max(0, elevation[i] - 0.53) * ridgeField[i] * 1.72 + slope[i] * ridgeField[i] * 1.02);
const basinRim = basinField ? clamp(Math.max(0, basinField[i] - 0.32) * Math.max(0, slope[i] - 0.18) * 1.15 + Math.max(0, ridgeField[i] - 0.34) * basinField[i] * 0.62) : 0; const basinRim = basinField ? clamp(Math.max(0, basinField[i] - 0.30) * Math.max(0, slope[i] - 0.16) * 1.32 + Math.max(0, ridgeField[i] - 0.32) * basinField[i] * 0.78) : 0;
const foothillBreak = clamp(Math.max(0, slope[i] - 0.30) * Math.max(ridgeField[i], Math.max(0, elevation[i] - 0.48)) * 0.82); const foothillBreak = clamp(Math.max(0, slope[i] - 0.28) * Math.max(ridgeField[i], Math.max(0, elevation[i] - 0.46)) * 1.02);
const livingCorridor = clamp((plain?.[i] || 0) * 0.34 + (agriculture?.[i] || 0) * 0.26 + valleyField[i] * (majorRiver > 0.34 ? 0.10 : 0.46) + coastalLowland[i] * 0.18); const livingCorridor = clamp((plain?.[i] || 0) * 0.34 + (agriculture?.[i] || 0) * 0.26 + valleyField[i] * (majorRiver > 0.34 ? 0.10 : 0.46) + coastalLowland[i] * 0.18);
score[i] = clamp( score[i] = clamp(
ridgeDivide * 0.92 + ridgeDivide * 1.42 +
crest * 0.72 + crest * 1.10 +
majorRiver * 0.86 + majorRiver * 0.86 +
basinRim * 0.54 + basinRim * 0.66 +
foothillBreak * 0.48 + foothillBreak * 0.58 +
coastEdge * 0.34 + coastEdge * 0.34 +
terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity || score, landuse || score) * 0.38 - terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity || score, landuse || score) * 0.44 -
livingCorridor * 0.50 - livingCorridor * 0.28 -
urbanContinuity * 0.72 urbanContinuity * 0.72
); );
} }
@ -568,7 +568,7 @@ function canShareNaturalCompartment(a, b, classA, classB, barrier, river, flowAc
const urbanEdge = ((landuse[a] >= 2 && landuse[a] <= 4) || landuse[a] === 7 || populationDensity[a] > 0.34) && const urbanEdge = ((landuse[a] >= 2 && landuse[a] <= 4) || landuse[a] === 7 || populationDensity[a] > 0.34) &&
((landuse[b] >= 2 && landuse[b] <= 4) || landuse[b] === 7 || populationDensity[b] > 0.34); ((landuse[b] >= 2 && landuse[b] <= 4) || landuse[b] === 7 || populationDensity[b] > 0.34);
const valleyContinuity = (valleyField[a] + valleyField[b]) * 0.5 > 0.48 && !majorRiverEdge; const valleyContinuity = (valleyField[a] + valleyField[b]) * 0.5 > 0.48 && !majorRiverEdge;
const threshold = urbanEdge ? 0.78 : valleyContinuity ? 0.62 : classA === 8 || classB === 8 ? 0.36 : 0.50; const threshold = urbanEdge ? 0.74 : valleyContinuity ? 0.56 : classA === 8 || classB === 8 ? 0.28 : 0.43;
return barrier < threshold && (!majorRiverEdge || urbanEdge); return barrier < threshold && (!majorRiverEdge || urbanEdge);
} }
@ -665,9 +665,9 @@ function splitOneNaturalCompartment(unit, newId, compartmentId, fields, seed) {
if (!cellSet.has(ni)) continue; if (!cellSet.has(ni)) continue;
const barrier = ((naturalBarrierScore?.[cur.i] || 0) + (naturalBarrierScore?.[ni] || 0)) * 0.5; const barrier = ((naturalBarrierScore?.[cur.i] || 0) + (naturalBarrierScore?.[ni] || 0)) * 0.5;
const riverBarrier = Math.max(river?.[cur.i] || 0, river?.[ni] || 0) + Math.max(flowAccum?.[cur.i] || 0, flowAccum?.[ni] || 0) * 0.32; const riverBarrier = Math.max(river?.[cur.i] || 0, river?.[ni] || 0) + Math.max(flowAccum?.[cur.i] || 0, flowAccum?.[ni] || 0) * 0.32;
const ridgeStep = Math.max(ridgeField[cur.i], ridgeField[ni]) * 0.80 + Math.abs(elevation[cur.i] - elevation[ni]) * 1.25; const ridgeStep = Math.max(ridgeField[cur.i], ridgeField[ni]) * 1.18 + Math.abs(elevation[cur.i] - elevation[ni]) * 1.48;
const corridorBonus = Math.min(0.48, ((valleyField[cur.i] + valleyField[ni]) * 0.5 + (plain?.[ni] || 0) * 0.18 + (coastalLowland?.[ni] || 0) * 0.12)); const corridorBonus = Math.min(0.42, ((valleyField[cur.i] + valleyField[ni]) * 0.5 + (plain?.[ni] || 0) * 0.15 + (coastalLowland?.[ni] || 0) * 0.10));
const stepCost = Math.max(0.18, 0.78 + barrier * 3.0 + riverBarrier * 1.10 + ridgeStep + slope[ni] * 0.38 - corridorBonus) * step; const stepCost = Math.max(0.18, 0.78 + barrier * 3.75 + riverBarrier * 1.10 + ridgeStep + slope[ni] * 0.48 - corridorBonus) * step;
const nd = cur.f + stepCost; const nd = cur.f + stepCost;
if (nd < dist[ni]) { if (nd < dist[ni]) {
dist[ni] = nd; dist[ni] = nd;
@ -735,6 +735,44 @@ function collectLandComponents(prefectureMask, sea) {
return components; return components;
} }
function collectNaturalGrowthComponents(prefectureMask, sea, watershedId = null) {
if (!watershedId) return collectLandComponents(prefectureMask, sea);
const seen = new Uint8Array(SIZE);
const components = [];
const queue = [];
for (let i = 0; i < SIZE; i++) {
if (seen[i] || !prefectureMask[i] || sea[i]) continue;
const wid = watershedId[i];
const cells = [];
queue.length = 0;
queue.push(i);
seen[i] = 1;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
cells.push(cur);
const [x, y] = xyOf(cur);
for (const [nx, ny] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue;
if (watershedId[ni] !== wid) continue;
seen[ni] = 1;
queue.push(ni);
}
}
components.push(cells);
}
return components;
}
function isWatershedBoundary(a, b, fields) {
const watershedId = fields?.watershedId;
if (!watershedId) return false;
const aw = watershedId[a];
const bw = watershedId[b];
return aw >= 0 && bw >= 0 && aw !== bw;
}
function naturalSeedScore(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, seed) { function naturalSeedScore(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, seed) {
const klassUrban = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8; const klassUrban = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8;
const lowland = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); const lowland = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
@ -762,8 +800,23 @@ function chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, seed
const { cells, componentIndex, area } = sortedComponents[componentOrder]; const { cells, componentIndex, area } = sortedComponents[componentOrder];
if (area <= 0) continue; if (area <= 0) continue;
const proportional = Math.round((targetCount || Math.round(totalArea / 42)) * area / Math.max(1, totalArea)); const proportional = Math.round((targetCount || Math.round(totalArea / 42)) * area / Math.max(1, totalArea));
let localTarget = Math.max(1, proportional); let highland = 0;
let rugged = 0;
for (const ci of cells) {
highland += clamp((elevation[ci] - 0.52) * 2.1 + ridgeField[ci] * 0.55 + slope[ci] * 0.45);
rugged += clamp(ridgeField[ci] * 0.75 + slope[ci] * 0.55 + Math.max(0, elevation[ci] - 0.58) * 0.85);
}
highland /= Math.max(1, area);
rugged /= Math.max(1, area);
// Watersheds can be very large in mountain ranges. The watershed switch is
// a hard stop, but a single watershed still needs several internal natural
// units; otherwise an entire mountain massif becomes one compartment. Keep
// the global target budget roughly intact by capping the terrain boost.
const terrainBoost = highland > 0.48 ? 2.0 : rugged > 0.38 ? 1.55 : 1.0;
let localTarget = Math.max(1, Math.round(Math.max(1, proportional) * terrainBoost));
localTarget = Math.min(localTarget, Math.max(1, Math.floor(area / minCellsPerUnit))); localTarget = Math.min(localTarget, Math.max(1, Math.floor(area / minCellsPerUnit)));
const reservedForRest = Math.max(0, sortedComponents.length - componentOrder - 1);
localTarget = Math.min(localTarget, Math.max(1, remainingTarget - reservedForRest));
if (componentOrder === sortedComponents.length - 1) localTarget = Math.max(1, Math.min(localTarget, remainingTarget)); if (componentOrder === sortedComponents.length - 1) localTarget = Math.max(1, Math.min(localTarget, remainingTarget));
remainingTarget -= localTarget; remainingTarget -= localTarget;
@ -800,6 +853,13 @@ function chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, seed
return { seeds, seedComponentId }; return { seeds, seedComponentId };
} }
function isHardNaturalRidgeCrossing(a, b, cellClass, fields) {
// Natural compartments now use drainage basins as the only hard barrier.
// Ridges still increase naturalStepCost, but they must not freeze an entire
// mountain block into one unsplittable component inside the same basin.
return isWatershedBoundary(a, b, fields);
}
function naturalStepCost(a, b, cellClass, fields) { function naturalStepCost(a, b, cellClass, fields) {
const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, flowAccum } = fields; const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, flowAccum } = fields;
const barrier = ((naturalBarrierScore?.[a] || 0) + (naturalBarrierScore?.[b] || 0)) * 0.5; const barrier = ((naturalBarrierScore?.[a] || 0) + (naturalBarrierScore?.[b] || 0)) * 0.5;
@ -819,12 +879,13 @@ function naturalStepCost(a, b, cellClass, fields) {
const valleyContinuity = Math.min(valleyField[a], valleyField[b]) * (majorRiverCrossing ? 0.10 : 0.45); const valleyContinuity = Math.min(valleyField[a], valleyField[b]) * (majorRiverCrossing ? 0.10 : 0.45);
const corridorBonus = Math.min(0.42, lowlandContinuity * 0.22 + valleyContinuity + (bothUrban ? 0.18 : 0)); const corridorBonus = Math.min(0.42, lowlandContinuity * 0.22 + valleyContinuity + (bothUrban ? 0.18 : 0));
const riverPenalty = majorRiverCrossing && !bothUrban ? 1.85 + flowEdge * 1.45 : riverEdge > 0.22 ? 0.38 : 0; const riverPenalty = majorRiverCrossing && !bothUrban ? 1.85 + flowEdge * 1.45 : riverEdge > 0.22 ? 0.38 : 0;
if (isHardNaturalRidgeCrossing(a, b, cellClass, fields)) return INF;
return Math.max(0.16, return Math.max(0.16,
0.72 + 0.72 +
barrier * 5.1 + barrier * 8.8 +
ridge * 0.82 + ridge * 2.35 +
elevationBreak * 3.0 + elevationBreak * 5.10 +
slopeBreak * 0.56 + slopeBreak * 1.08 +
riverPenalty + riverPenalty +
classBreak - classBreak -
corridorBonus corridorBonus
@ -894,6 +955,42 @@ function splitDisconnectedCompartments(compartmentId, compartments, prefectureMa
} }
} }
function splitCompartmentsByWatershed(compartmentId, compartments, fields) {
const watershedId = fields?.watershedId;
if (!watershedId) return 0;
let split = 0;
for (const unit of [...compartments]) {
if (!unit || unit.area === 0 || !unit.cells?.length) continue;
const groups = new Map();
for (const ci of unit.cells) {
const wid = watershedId[ci];
const key = wid >= 0 ? wid : -1;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(ci);
}
if (groups.size <= 1) continue;
const sorted = [...groups.values()].sort((a, b) => b.length - a.length);
unit.cells = sorted[0];
unit.area = sorted[0].length;
for (const extra of sorted.slice(1)) {
const newId = compartments.length;
for (const ci of extra) compartmentId[ci] = newId;
compartments.push({
id: newId,
cells: extra,
centerIds: [],
adjacent: new Map(),
area: extra.length,
classId: unit.classId,
dominantLandscapeClass: unit.dominantLandscapeClass,
});
split++;
}
}
return split;
}
function renumberCompartments(compartmentId, compartments, prefectureMask, sea) { function renumberCompartments(compartmentId, compartments, prefectureMask, sea) {
const active = compartments.filter((unit) => unit && unit.area > 0 && unit.cells?.length); const active = compartments.filter((unit) => unit && unit.area > 0 && unit.cells?.length);
const idMap = new Map(); const idMap = new Map();
@ -967,7 +1064,9 @@ function splitNaturalCompartmentCompact(unit, newId, compartmentId, fields, seed
for (const [nx, ny, step] of neighbors4(x, y)) { for (const [nx, ny, step] of neighbors4(x, y)) {
const ni = indexOf(nx, ny); const ni = indexOf(nx, ny);
if (!cellSet.has(ni)) continue; if (!cellSet.has(ni)) continue;
const nd = cur.f + naturalStepCost(cur.i, ni, cellClass, fields) * step; const stepCost = naturalStepCost(cur.i, ni, cellClass, fields);
if (stepCost >= INF) continue;
const nd = cur.f + stepCost * step;
if (nd < dist[ni]) { if (nd < dist[ni]) {
dist[ni] = nd; dist[ni] = nd;
owner[ni] = cur.owner; owner[ni] = cur.owner;
@ -986,14 +1085,43 @@ function splitNaturalCompartmentCompact(unit, newId, compartmentId, fields, seed
return newUnit; return newUnit;
} }
function splitNaturalCompartmentByAxis(unit, newId, compartmentId, fields, seed) {
if (!unit || unit.area < 20 || !unit.cells?.length) return null;
const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse } = fields;
const minPart = Math.max(7, Math.min(34, Math.floor(unit.area * 0.20)));
const horizontal = (unit.width || 0) >= (unit.height || 0);
const cx = unit.x || 0;
const cy = unit.y || 0;
const sorted = [...unit.cells].sort((a, b) => {
const [ax, ay] = xyOf(a);
const [bx, by] = xyOf(b);
const av = (horizontal ? ax : ay) + hashSeededTie(ax, ay, seed) * 0.35 + Math.abs((horizontal ? ay - cy : ax - cx)) * 0.015;
const bv = (horizontal ? bx : by) + hashSeededTie(bx, by, seed) * 0.35 + Math.abs((horizontal ? by - cy : bx - cx)) * 0.015;
return av - bv;
});
const cut = Math.max(minPart, Math.min(sorted.length - minPart, Math.floor(sorted.length * 0.50)));
if (cut <= 0 || sorted.length - cut < minPart) return null;
const aCells = sorted.slice(0, cut);
const bCells = sorted.slice(cut);
unit.cells = aCells;
unit.area = aCells.length;
for (const ci of aCells) compartmentId[ci] = unit.id;
for (const ci of bCells) compartmentId[ci] = newId;
const newUnit = { id: newId, cells: bCells, area: bCells.length, centerIds: [], adjacent: new Map(), classId: unit.classId, dominantLandscapeClass: unit.dominantLandscapeClass };
refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
refreshCompartmentStats(newUnit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
return newUnit;
}
function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) { function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) {
const progress = typeof options.progress === "function" ? options.progress : null; const progress = typeof options.progress === "function" ? options.progress : null;
const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse); const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse);
const cellClass = new Int16Array(SIZE); const cellClass = new Int16Array(SIZE);
cellClass.fill(-1); cellClass.fill(-1);
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) cellClass[i] = classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse); for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) cellClass[i] = classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse);
const fields = { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, naturalBarrierScore, cellClass }; const watershedId = options.watershedId || null;
const landComponents = collectLandComponents(prefectureMask, sea); const fields = { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, naturalBarrierScore, cellClass, watershedId };
const landComponents = collectNaturalGrowthComponents(prefectureMask, sea, watershedId);
const landArea = landComponents.reduce((sum, cells) => sum + cells.length, 0); const landArea = landComponents.reduce((sum, cells) => sum + cells.length, 0);
const requestedTarget = options.targetCompartmentCount || clamp(Math.round(landArea / 34), 40, 360); const requestedTarget = options.targetCompartmentCount || clamp(Math.round(landArea / 34), 40, 360);
const targetCount = clamp(Math.round(requestedTarget), Math.min(1, landArea), Math.max(1, Math.floor(landArea / 8))); const targetCount = clamp(Math.round(requestedTarget), Math.min(1, landArea), Math.max(1, Math.floor(landArea / 8)));
@ -1016,7 +1144,9 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r
for (const [nx, ny, step] of neighbors4(x, y)) { for (const [nx, ny, step] of neighbors4(x, y)) {
const ni = indexOf(nx, ny); const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni]) continue; if (!prefectureMask[ni] || sea[ni]) continue;
const nd = cur.f + naturalStepCost(cur.i, ni, cellClass, fields) * step; const stepCost = naturalStepCost(cur.i, ni, cellClass, fields);
if (stepCost >= INF) continue;
const nd = cur.f + stepCost * step;
if (nd < dist[ni]) { if (nd < dist[ni]) {
dist[ni] = nd; dist[ni] = nd;
compartmentId[ni] = cur.id; compartmentId[ni] = cur.id;
@ -1033,6 +1163,7 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r
mergeWeakArtificialLandscapeUnits(compartmentId, compartments, fields, prefectureMask, sea, Math.max(42, Math.round(landArea / Math.max(1, targetCount) * 2.15)), 5); mergeWeakArtificialLandscapeUnits(compartmentId, compartments, fields, prefectureMask, sea, Math.max(42, Math.round(landArea / Math.max(1, targetCount) * 2.15)), 5);
progress?.(`natural post-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`); progress?.(`natural post-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`);
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea); splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
splitCompartmentsByWatershed(compartmentId, compartments, fields);
compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea); compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea);
refreshAllCompartmentStats(compartments, fields); refreshAllCompartmentStats(compartments, fields);
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
@ -1051,7 +1182,8 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r
return sb - sa; return sb - sa;
})[0]; })[0];
if (!worst) break; if (!worst) break;
const newUnit = splitNaturalCompartmentCompact(worst, compartments.length, compartmentId, fields, (options.seed || 0) + guard * 97); const newUnit = splitNaturalCompartmentCompact(worst, compartments.length, compartmentId, fields, (options.seed || 0) + guard * 97)
|| splitNaturalCompartmentByAxis(worst, compartments.length, compartmentId, fields, (options.seed || 0) + guard * 131);
if (!newUnit) { if (!newUnit) {
worst._splitRejected = (worst._splitRejected || 0) + 1; worst._splitRejected = (worst._splitRejected || 0) + 1;
if (worst._splitRejected > 2) worst.elongation = Math.min(worst.elongation || 1, 3.1); if (worst._splitRejected > 2) worst.elongation = Math.min(worst.elongation || 1, 3.1);
@ -1070,6 +1202,7 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r
mergeWeakArtificialLandscapeUnits(compartmentId, compartments, fields, prefectureMask, sea, Math.max(48, Math.round(landArea / Math.max(1, targetCount) * 2.05)), 4); mergeWeakArtificialLandscapeUnits(compartmentId, compartments, fields, prefectureMask, sea, Math.max(48, Math.round(landArea / Math.max(1, targetCount) * 2.05)), 4);
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea); splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
splitCompartmentsByWatershed(compartmentId, compartments, fields);
compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea); compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea);
refreshAllCompartmentStats(compartments, fields); refreshAllCompartmentStats(compartments, fields);
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
@ -1214,8 +1347,14 @@ function mergeWeakArtificialLandscapeUnits(unitId, units, fields, prefectureMask
const lowlandContinuity = Math.min(unit.lowlandFitness || 0, other.lowlandFitness || 0); const lowlandContinuity = Math.min(unit.lowlandFitness || 0, other.lowlandFitness || 0);
const mountainContinuity = Math.min(unit.mountainFitness || 0, other.mountainFitness || 0); const mountainContinuity = Math.min(unit.mountainFitness || 0, other.mountainFitness || 0);
const urbanGuard = Math.max(unit.urbanWeight || 0, other.urbanWeight || 0); const urbanGuard = Math.max(unit.urbanWeight || 0, other.urbanWeight || 0);
const weakDivider = avgBarrier < (sameClass ? 0.46 : 0.36); const ridgeDivider = avgBarrier > 0.58 || Math.max(unit.ridgeExposure || 0, other.ridgeExposure || 0) > 0.62;
if (!weakDivider) continue; const weakDivider = avgBarrier < (sameClass ? 0.40 : 0.30);
const bothMountain = (unit.mountainFitness || 0) > 0.56 && (other.mountainFitness || 0) > 0.56;
// Large highland units are visually important. Do not erase their
// internal subdivision just because two neighbouring cells share a class
// inside the same watershed.
if (bothMountain && combinedArea > Math.max(32, maxMergedArea * 0.72)) continue;
if (!weakDivider || ridgeDivider) continue;
const score = const score =
(sameClass ? 1.7 : 0) + (sameClass ? 1.7 : 0) +
(sameGroup ? 1.2 : 0) + (sameGroup ? 1.2 : 0) +
@ -1241,8 +1380,10 @@ function naturalOwnershipAffinity(unit, neighbor, edge) {
const bothUrban = unit.classId <= 3 && neighbor.classId <= 3; const bothUrban = unit.classId <= 3 && neighbor.classId <= 3;
const bothCorridor = [5, 6, 7, 10].includes(unit.classId) && [5, 6, 7, 10].includes(neighbor.classId); const bothCorridor = [5, 6, 7, 10].includes(unit.classId) && [5, 6, 7, 10].includes(neighbor.classId);
const urbanContinuity = bothUrban ? 1.35 : (unit.urbanWeight + neighbor.urbanWeight) > 0.75 && Math.abs(unit.urbanWeight - neighbor.urbanWeight) < 0.35 ? 0.58 : 0; const urbanContinuity = bothUrban ? 1.35 : (unit.urbanWeight + neighbor.urbanWeight) > 0.75 && Math.abs(unit.urbanWeight - neighbor.urbanWeight) < 0.35 ? 0.58 : 0;
const strongDividerPenalty = boundaryTarget * (edge.count > 2 ? 2.8 : 1.8); const ridgeExposure = Math.max(unit.ridgeExposure || 0, neighbor.ridgeExposure || 0);
return edge.count * 0.55 + sameClass + sameGroup + urbanContinuity + (bothCorridor ? 0.72 : 0) - strongDividerPenalty; const hardRidgePenalty = boundaryTarget > 0.62 || ridgeExposure > 0.62 ? 2.4 : 0;
const strongDividerPenalty = boundaryTarget * (edge.count > 2 ? 5.2 : 3.8) + ridgeExposure * 1.35 + hardRidgePenalty;
return edge.count * 0.50 + sameClass + sameGroup + urbanContinuity + (bothCorridor ? 0.58 : 0) - strongDividerPenalty;
} }
function compartmentCrossingCost(unit, neighbor, edge) { function compartmentCrossingCost(unit, neighbor, edge) {
@ -1255,17 +1396,41 @@ function compartmentCrossingCost(unit, neighbor, edge) {
const ridgePenalty = Math.max(unit.ridgeExposure || 0, neighbor.ridgeExposure || 0); const ridgePenalty = Math.max(unit.ridgeExposure || 0, neighbor.ridgeExposure || 0);
return Math.max(0.18, return Math.max(0.18,
1.0 + 1.0 +
boundaryScore * 5.2 + boundaryScore * 9.2 +
mountainPenalty * 1.8 + mountainPenalty * 2.65 +
ridgePenalty * 0.9 - ridgePenalty * 2.75 +
(boundaryScore > 0.64 || ridgePenalty > 0.64 ? 5.5 : 0) -
sameClass * 0.45 - sameClass * 0.45 -
sameGroup * 0.35 - sameGroup * 0.35 -
lowlandContinuity * 1.15 - lowlandContinuity * 0.98 -
urbanContinuity * 0.70 - urbanContinuity * 0.64 -
Math.min(1.0, edge.count / 12) * 0.25 Math.min(1.0, edge.count / 12) * 0.25
); );
} }
function enrichCompartmentsWithUnifiedGeography(compartments, options = {}) {
const geo = options.geography || options || {};
const fields = [
["habitability", geo.habitability],
["accessibility", geo.accessibility],
["centrality", geo.centrality],
["boundaryAvoidance", geo.boundaryAvoidance],
["adminBoundaryPreference", geo.adminBoundaryPreference],
["geographicBarrier", geo.geographicBarrier],
];
if (!fields.some(([, field]) => field)) return false;
for (const unit of compartments || []) {
if (!unit || !unit.cells?.length) continue;
for (const [name, field] of fields) {
if (!field) continue;
let sum = 0;
for (const i of unit.cells) sum += field[i] || 0;
unit[name] = sum / Math.max(1, unit.cells.length);
}
}
return true;
}
function graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options = {}) { function graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options = {}) {
const owner = new Int16Array(compartments.length); const owner = new Int16Array(compartments.length);
const dist = new Float32Array(compartments.length); const dist = new Float32Array(compartments.length);
@ -1297,7 +1462,8 @@ function graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters
const crossing = compartmentCrossingCost(unit, neighbor, edge); const crossing = compartmentCrossingCost(unit, neighbor, edge);
const euclideanTie = center ? Math.hypot(neighbor.x - center.x, neighbor.y - center.y) * 0.006 : 0; const euclideanTie = center ? Math.hypot(neighbor.x - center.x, neighbor.y - center.y) * 0.006 : 0;
const hinterlandDrag = (neighbor.mountainFitness || 0) > 0.64 && (neighbor.population || 0) < 6 ? 0.35 : 0; const hinterlandDrag = (neighbor.mountainFitness || 0) > 0.64 && (neighbor.population || 0) < 6 ? 0.35 : 0;
const next = cur.f + crossing + euclideanTie + hinterlandDrag; const ridgeExpansionDrag = Math.max(0, (neighbor.ridgeExposure || 0) - (unit.ridgeExposure || 0)) * 1.75 + (crossing > 9.0 ? 2.8 : 0);
const next = cur.f + crossing + euclideanTie + hinterlandDrag + ridgeExpansionDrag;
if (next + 1e-5 < dist[neighborId]) { if (next + 1e-5 < dist[neighborId]) {
dist[neighborId] = next; dist[neighborId] = next;
owner[neighborId] = cur.owner; owner[neighborId] = cur.owner;
@ -1364,6 +1530,25 @@ function averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierS
return count ? sum / count : 0; return count ? sum / count : 0;
} }
function averageFinalBorderField(adminId, prefectureMask, sea, field) {
if (!field) return 0;
let sum = 0;
let count = 0;
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni] || adminId[ni] < 0 || adminId[ni] === adminId[i]) continue;
sum += ((field[i] || 0) + (field[ni] || 0)) * 0.5;
count++;
}
}
}
return count ? sum / count : 0;
}
function assignCompartmentsToAdminOwners(compartments, compartmentId, adminCenters, naturalBarrierScore, prefectureMask, sea) { function assignCompartmentsToAdminOwners(compartments, compartmentId, adminCenters, naturalBarrierScore, prefectureMask, sea) {
const owner = new Int16Array(compartments.length); const owner = new Int16Array(compartments.length);
owner.fill(-1); owner.fill(-1);
@ -1453,6 +1638,7 @@ export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, e
progress?.("natural compartments built"); progress?.("natural compartments built");
const adminId = new Int16Array(SIZE); const adminId = new Int16Array(SIZE);
adminId.fill(-1); adminId.fill(-1);
const unifiedGeographyApplied = enrichCompartmentsWithUnifiedGeography(compartments, options);
const owner = graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options); const owner = graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options);
progress?.("natural compartments assigned"); progress?.("natural compartments assigned");
for (const unit of compartments) { for (const unit of compartments) {
@ -1485,6 +1671,10 @@ export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, e
.sort((a, b) => (b.elongation * Math.sqrt(Math.max(1, b.area))) - (a.elongation * Math.sqrt(Math.max(1, a.area)))) .sort((a, b) => (b.elongation * Math.sqrt(Math.max(1, b.area))) - (a.elongation * Math.sqrt(Math.max(1, a.area))))
.slice(0, 8), .slice(0, 8),
finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore), finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore),
unifiedGeographyAppliedToAdminCompartments: unifiedGeographyApplied,
finalBorderUnifiedBoundaryPreferenceAverage: averageFinalBorderField(adminId, prefectureMask, sea, options.adminBoundaryPreference || options.geography?.adminBoundaryPreference),
finalBorderUnifiedBoundaryAvoidanceAverage: averageFinalBorderField(adminId, prefectureMask, sea, options.boundaryAvoidance || options.geography?.boundaryAvoidance),
finalBorderUnifiedCentralityAverage: averageFinalBorderField(adminId, prefectureMask, sea, options.centrality || options.geography?.centrality),
voronoiLikeRateBefore: 0, voronoiLikeRateBefore: 0,
voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore), voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore),
}, },

77
app.js
View file

@ -24,6 +24,7 @@ const state = {
}; };
const canvas = document.getElementById("mapCanvas"); const canvas = document.getElementById("mapCanvas");
const canvasShell = document.querySelector(".canvas-shell");
const seedInput = document.getElementById("seed"); const seedInput = document.getElementById("seed");
const randomSeedButton = document.getElementById("randomSeed"); const randomSeedButton = document.getElementById("randomSeed");
const showFeaturesInput = document.getElementById("showFeatures"); const showFeaturesInput = document.getElementById("showFeatures");
@ -38,6 +39,69 @@ let generationStartedAt = 0;
let generationCurrentStage = ""; let generationCurrentStage = "";
let generationTimer = null; let generationTimer = null;
const panState = { keys: new Set(), raf: null, lastTime: 0, speedPxPerSecond: 520 };
function mapClientToCell(event) {
if (!state.map) return null;
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return null;
const relX = (event.clientX - rect.left) / rect.width;
const relY = (event.clientY - rect.top) / rect.height;
return {
x: Math.floor(relX * state.map.width),
y: Math.floor(relY * state.map.height),
};
}
function isEditableTarget(target) {
if (!target) return false;
const tag = target.tagName?.toLowerCase?.();
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
}
function panFrame(time) {
if (!canvasShell || panState.keys.size === 0) {
panState.raf = null;
panState.lastTime = 0;
return;
}
const dt = panState.lastTime ? Math.min(0.05, (time - panState.lastTime) / 1000) : 0;
panState.lastTime = time;
let dx = 0;
let dy = 0;
if (panState.keys.has("a")) dx -= 1;
if (panState.keys.has("d")) dx += 1;
if (panState.keys.has("w")) dy -= 1;
if (panState.keys.has("s")) dy += 1;
if (dx || dy) {
const normalizer = dx && dy ? Math.SQRT1_2 : 1;
const amount = panState.speedPxPerSecond * dt;
canvasShell.scrollLeft += dx * normalizer * amount;
canvasShell.scrollTop += dy * normalizer * amount;
tooltipEl?.classList.remove("visible");
}
panState.raf = requestAnimationFrame(panFrame);
}
function startKeyboardPan() {
if (panState.raf == null) panState.raf = requestAnimationFrame(panFrame);
}
function handlePanKeyDown(event) {
const key = event.key?.toLowerCase?.();
if (!key || !"wasd".includes(key) || isEditableTarget(event.target)) return;
panState.keys.add(key);
startKeyboardPan();
event.preventDefault();
}
function handlePanKeyUp(event) {
const key = event.key?.toLowerCase?.();
if (!key || !"wasd".includes(key)) return;
panState.keys.delete(key);
event.preventDefault();
}
function parseSeed(seedText) { function parseSeed(seedText) {
const numeric = Number.parseInt(seedText, 10); const numeric = Number.parseInt(seedText, 10);
if (Number.isFinite(numeric)) return numeric >>> 0; if (Number.isFinite(numeric)) return numeric >>> 0;
@ -121,6 +185,7 @@ function getStats(map) {
["Map Type", map.terrainTemplate?.terrainTypeLabel || map.terrainDebug?.terrainTypeLabel || "-"], ["Map Type", map.terrainTemplate?.terrainTypeLabel || map.terrainDebug?.terrainTypeLabel || "-"],
["Terrain ID", map.terrainTemplate?.terrainType || map.terrainDebug?.terrainType || "-"], ["Terrain ID", map.terrainTemplate?.terrainType || map.terrainDebug?.terrainType || "-"],
["Generation Total", map.generationTotalMs ? formatMs(map.generationTotalMs) : "-"], ["Generation Total", map.generationTotalMs ? formatMs(map.generationTotalMs) : "-"],
["Geography Basis", map.geographyDebug?.version ? `${map.geographyDebug.version} / ${map.geographyDebug.stage || "-"}` : "-"],
...(map.generationTimings || []).map((row) => [`Time: ${row.label}`, formatMs(row.ms)]), ...(map.generationTimings || []).map((row) => [`Time: ${row.label}`, formatMs(row.ms)]),
["Villages", countText(map.villages)], ["Villages", countText(map.villages)],
["Market Towns", countText(map.markets)], ["Market Towns", countText(map.markets)],
@ -218,8 +283,9 @@ function prefectureNameForCell(map, i) {
function updateTooltip(event) { function updateTooltip(event) {
if (!state.map || !tooltipEl) return; if (!state.map || !tooltipEl) return;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const x = Math.floor((event.clientX - rect.left) / rect.width * state.map.width); const cell = mapClientToCell(event);
const y = Math.floor((event.clientY - rect.top) / rect.height * state.map.height); if (!cell) return;
const { x, y } = cell;
if (x < 0 || y < 0 || x >= state.map.width || y >= state.map.height) { if (x < 0 || y < 0 || x >= state.map.width || y >= state.map.height) {
tooltipEl.classList.remove("visible"); tooltipEl.classList.remove("visible");
return; return;
@ -318,8 +384,13 @@ function init() {
redraw(); redraw();
}); });
canvasShell?.setAttribute("tabindex", "0");
window.addEventListener("keydown", handlePanKeyDown);
window.addEventListener("keyup", handlePanKeyUp);
canvas.addEventListener("mousemove", updateTooltip); canvas.addEventListener("mousemove", updateTooltip);
canvas.addEventListener("mouseleave", () => tooltipEl?.classList.remove("visible")); canvas.addEventListener("mouseleave", () => {
tooltipEl?.classList.remove("visible");
});
regenerate(); regenerate();
} }

View file

@ -0,0 +1,744 @@
import { INF, MAP_H, MAP_W, SIZE, clamp, hash2, indexOf, inside, xyOf } from "./mapUtils.js";
import { applyCompartmentOwners, dominantCompartmentOwners } from "./mapAdminShared.js";
export function compactWholeCompartmentMunicipalities(adminId, centers, prefectureMask, sea, fields = {}) {
const activeIds = [...new Set([...adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i]))].sort((a, b) => a - b);
const idMap = new Map(activeIds.map((id, n) => [id, n]));
const compactId = new Int16Array(SIZE);
compactId.fill(-1);
const stats = activeIds.map(() => ({ sx: 0, sy: 0, count: 0, bestI: -1, bestScore: -INF }));
for (let i = 0; i < SIZE; i++) {
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
const nextId = idMap.get(adminId[i]);
if (nextId === undefined) continue;
compactId[i] = nextId;
const [x, y] = xyOf(i);
const row = stats[nextId];
row.sx += x;
row.sy += y;
row.count++;
const score = (fields.populationDensity?.[i] || 0) * 2.2 + (fields.plain?.[i] || 0) * 0.25 - (fields.slope?.[i] || 0) * 0.2;
if (score > row.bestScore) { row.bestScore = score; row.bestI = i; }
}
const compactCenters = activeIds.map((oldId, newId) => {
const current = centers[oldId];
if (current && inside(current.x, current.y) && compactId[indexOf(current.x, current.y)] === newId) {
return { ...current, originalAdminId: oldId };
}
const row = stats[newId];
const fallback = row.bestI >= 0 ? xyOf(row.bestI) : [Math.round(row.sx / Math.max(1, row.count)), Math.round(row.sy / Math.max(1, row.count))];
return {
...(current || {}),
x: fallback[0],
y: fallback[1],
originalAdminId: oldId,
generatedOfficePoint: true,
invisibleLowlandAdminSeed: current?.invisibleLowlandAdminSeed ?? true,
seedKind: current?.seedKind || "compactedMunicipalityOffice",
};
});
return { adminId: compactId, adminCentersRaw: compactCenters, activeMunicipalityCount: compactCenters.length };
}
export function enforceCompartmentMunicipalityOwnership(adminId, compartmentId, compartments, prefectureMask, sea) {
if (!compartmentId || !compartments) return 0;
let changed = 0;
for (const comp of compartments) {
if (!comp || !comp.cells?.length) continue;
const counts = new Map();
for (const i of comp.cells) {
if (!prefectureMask[i] || sea[i]) continue;
const id = adminId[i];
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
}
let bestId = -1, bestCount = -1;
for (const [id, count] of counts) {
if (count > bestCount || (count === bestCount && id < bestId)) {
bestId = id;
bestCount = count;
}
}
if (bestId < 0) continue;
for (const i of comp.cells) {
if (!prefectureMask[i] || sea[i] || adminId[i] === bestId) continue;
adminId[i] = bestId;
changed++;
}
}
return changed;
}
export function mergeSingleCompartmentMunicipalities(adminId, compartments, prefectureMask, sea, minCompartments = 2, maxPasses = 8) {
if (!compartments?.length) return { changedCells: 0, mergedMunicipalities: 0, remainingSingleCompartmentMunicipalities: 0 };
let totalChangedCells = 0;
let mergedMunicipalities = 0;
let remainingSingles = 0;
for (let pass = 0; pass < maxPasses; pass++) {
const owner = dominantCompartmentOwners(compartments, adminId);
const byOwner = new Map();
const areaByOwner = new Map();
for (const unit of compartments) {
if (!unit || unit.area === 0) continue;
const id = owner[unit.id];
if (id < 0) continue;
if (!byOwner.has(id)) byOwner.set(id, []);
byOwner.get(id).push(unit);
areaByOwner.set(id, (areaByOwner.get(id) || 0) + unit.area);
}
const singles = [...byOwner.entries()]
.filter(([, units]) => units.length > 0 && units.length < minCompartments)
.sort((a, b) => (areaByOwner.get(a[0]) || 0) - (areaByOwner.get(b[0]) || 0) || a[0] - b[0]);
remainingSingles = singles.length;
if (!singles.length) break;
let passChanged = 0;
for (const [id, units] of singles) {
// The old rule allowed one natural compartment to become one municipality.
// That produces many tiny office-only municipalities and makes the hierarchy
// hard to read. Merge such municipalities into the strongest adjacent owner.
const neighborScores = new Map();
for (const unit of units) {
for (const [neighborId, edge] of unit.adjacent || []) {
const candidate = owner[neighborId];
if (candidate < 0 || candidate === id) continue;
const neighborUnit = compartments[neighborId];
const shared = edge.count || 1;
const barrier = edge.target ? edge.target / Math.max(1, shared) : 0;
const sameLandscape = neighborUnit?.classId === unit.classId ? 0.7 : 0;
const score = shared * (2.2 - Math.min(1.6, barrier) + sameLandscape) + Math.sqrt(areaByOwner.get(candidate) || 1) * 0.05;
neighborScores.set(candidate, (neighborScores.get(candidate) || 0) + score);
}
}
let best = -1, bestScore = -INF;
for (const [candidate, score] of neighborScores) {
if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; }
}
if (best < 0) {
// One-cell islets have no land adjacency. Attach them to the nearest
// existing municipality instead of leaving a one-compartment municipality.
const ux = units.reduce((sum, unit) => sum + (unit.x || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0));
const uy = units.reduce((sum, unit) => sum + (unit.y || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0));
let bestDist = INF;
for (const [candidate, candidateUnits] of byOwner) {
if (candidate === id || candidateUnits.length < minCompartments) continue;
for (const unit of candidateUnits) {
const d = Math.hypot((unit.x || 0) - ux, (unit.y || 0) - uy);
if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; }
}
}
if (bestDist > 28) best = -1;
}
if (best < 0) continue;
for (const unit of units) {
for (const i of unit.cells || []) {
if (!prefectureMask[i] || sea[i]) continue;
if (adminId[i] !== best) {
adminId[i] = best;
passChanged++;
}
}
}
mergedMunicipalities++;
}
totalChangedCells += passChanged;
if (!passChanged) break;
}
const finalOwner = dominantCompartmentOwners(compartments, adminId);
const finalCounts = new Map();
for (const unit of compartments) {
if (!unit || unit.area === 0) continue;
const id = finalOwner[unit.id];
if (id >= 0) finalCounts.set(id, (finalCounts.get(id) || 0) + 1);
}
remainingSingles = [...finalCounts.values()].filter((count) => count > 0 && count < minCompartments).length;
return { changedCells: totalChangedCells, mergedMunicipalities, remainingSingleCompartmentMunicipalities: remainingSingles };
}
export function ownerAreaByCompartment(owner, compartments) {
const area = new Map();
const count = new Map();
for (const unit of compartments || []) {
if (!unit || unit.area === 0) continue;
const id = owner[unit.id];
if (id < 0) continue;
area.set(id, (area.get(id) || 0) + (unit.area || 0));
count.set(id, (count.get(id) || 0) + 1);
}
return { area, count };
}
export function compartmentTouchesOutside(unit, prefectureMask, sea) {
if (!unit?.cells?.length) return true;
for (const i of unit.cells) {
const [x, y] = xyOf(i);
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) return true;
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) return true;
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni]) return true;
}
}
return false;
}
export function bestNeighborOwnerForUnits(units, owner, compartments, allowNearestFallback = false) {
const { area } = ownerAreaByCompartment(owner, compartments);
const scores = new Map();
const blocked = new Set(units.map((unit) => owner[unit.id]));
for (const unit of units) {
for (const [neighborId, edge] of unit.adjacent || []) {
const candidate = owner[neighborId];
if (candidate < 0 || blocked.has(candidate)) continue;
const neighbor = compartments[neighborId];
const shared = edge.count || 1;
const barrier = (edge.target || 0) / Math.max(1, shared);
const landscape = neighbor?.classId === unit.classId ? 0.75 : 0;
const lowland = Math.min(unit.lowlandFitness || 0, neighbor?.lowlandFitness || 0) * 0.5;
const score = shared * (2.4 + landscape + lowland - Math.min(1.8, barrier)) + Math.sqrt(area.get(candidate) || 1) * 0.04;
scores.set(candidate, (scores.get(candidate) || 0) + score);
}
}
let best = -1, bestScore = -INF;
for (const [candidate, score] of scores) {
if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; }
}
if (best >= 0 || !allowNearestFallback) return best;
const ux = units.reduce((sum, unit) => sum + (unit.x || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0));
const uy = units.reduce((sum, unit) => sum + (unit.y || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0));
let bestDist = INF;
for (const unit of compartments || []) {
if (!unit || unit.area === 0) continue;
const candidate = owner[unit.id];
if (candidate < 0 || blocked.has(candidate)) continue;
const d = Math.hypot((unit.x || 0) - ux, (unit.y || 0) - uy);
if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; }
}
return bestDist <= 32 ? best : -1;
}
export function mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, minCompartments = 2, maxPasses = 8) {
let changedCells = 0;
let mergedMunicipalities = 0;
let remainingSingleCompartmentMunicipalities = 0;
for (let pass = 0; pass < maxPasses; pass++) {
const byOwner = new Map();
for (const unit of compartments || []) {
if (!unit || unit.area === 0) continue;
const id = owner[unit.id];
if (id < 0) continue;
if (!byOwner.has(id)) byOwner.set(id, []);
byOwner.get(id).push(unit);
}
const small = [...byOwner.entries()]
.filter(([, units]) => units.length > 0 && units.length < minCompartments)
.sort((a, b) => a[1].length - b[1].length || a[0] - b[0]);
remainingSingleCompartmentMunicipalities = small.length;
if (!small.length) break;
let passChanged = 0;
for (const [id, units] of small) {
const target = bestNeighborOwnerForUnits(units, owner, compartments, true);
if (target < 0 || target === id) continue;
for (const unit of units) {
if (owner[unit.id] === target) continue;
owner[unit.id] = target;
changedCells += unit.area || 0;
passChanged += unit.area || 0;
}
mergedMunicipalities++;
}
if (!passChanged) break;
}
const counts = ownerAreaByCompartment(owner, compartments).count;
remainingSingleCompartmentMunicipalities = [...counts.values()].filter((count) => count > 0 && count < minCompartments).length;
return { changedCells, mergedMunicipalities, remainingSingleCompartmentMunicipalities };
}
export function repairCompartmentOwnerConnectivity(owner, compartments, maxPasses = 8) {
let changedCells = 0;
let changedComponents = 0;
for (let pass = 0; pass < maxPasses; pass++) {
const ownerIds = [...new Set([...owner].filter((id) => id >= 0))].sort((a, b) => a - b);
let passChanged = 0;
for (const id of ownerIds) {
const members = (compartments || []).filter((unit) => unit && unit.area > 0 && owner[unit.id] === id);
if (members.length <= 1) continue;
const memberSet = new Set(members.map((unit) => unit.id));
const seen = new Set();
const components = [];
for (const unit of members) {
if (seen.has(unit.id)) continue;
const queue = [unit.id];
const comp = [];
seen.add(unit.id);
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
comp.push(compartments[cur]);
for (const next of compartments[cur]?.adjacent?.keys?.() || []) {
if (!memberSet.has(next) || seen.has(next)) continue;
seen.add(next);
queue.push(next);
}
}
components.push(comp);
}
if (components.length <= 1) continue;
components.sort((a, b) => b.reduce((sum, unit) => sum + (unit.area || 0), 0) - a.reduce((sum, unit) => sum + (unit.area || 0), 0));
for (const comp of components.slice(1)) {
const target = bestNeighborOwnerForUnits(comp, owner, compartments, true);
if (target < 0 || target === id) continue;
for (const unit of comp) {
owner[unit.id] = target;
changedCells += unit.area || 0;
passChanged += unit.area || 0;
}
changedComponents++;
}
}
if (!passChanged) break;
}
return { changedCells, changedComponents };
}
export function repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, maxPasses = 6) {
let changedCells = 0;
let changedComponents = 0;
const outsideCache = new Map();
const touchesOutside = (unit) => {
if (!outsideCache.has(unit.id)) outsideCache.set(unit.id, compartmentTouchesOutside(unit, prefectureMask, sea));
return outsideCache.get(unit.id);
};
for (let pass = 0; pass < maxPasses; pass++) {
const ownerIds = [...new Set([...owner].filter((id) => id >= 0))].sort((a, b) => a - b);
let passChanged = 0;
for (const id of ownerIds) {
const members = (compartments || []).filter((unit) => unit && unit.area > 0 && owner[unit.id] === id);
if (!members.length) continue;
const memberSet = new Set(members.map((unit) => unit.id));
const seen = new Set();
for (const unit of members) {
if (seen.has(unit.id)) continue;
const queue = [unit.id];
const comp = [];
const boundaryOwners = new Map();
let outside = false;
seen.add(unit.id);
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const curUnit = compartments[cur];
if (!curUnit) continue;
comp.push(curUnit);
if (touchesOutside(curUnit)) outside = true;
for (const [next, edge] of curUnit.adjacent || []) {
const nextOwner = owner[next];
if (nextOwner === id) {
if (!seen.has(next) && memberSet.has(next)) { seen.add(next); queue.push(next); }
} else if (nextOwner >= 0) {
boundaryOwners.set(nextOwner, (boundaryOwners.get(nextOwner) || 0) + (edge.count || 1));
}
}
}
if (outside || boundaryOwners.size !== 1) continue;
const [target] = boundaryOwners.keys();
if (target < 0 || target === id) continue;
for (const compUnit of comp) {
owner[compUnit.id] = target;
changedCells += compUnit.area || 0;
passChanged += compUnit.area || 0;
}
changedComponents++;
}
}
if (!passChanged) break;
}
return { changedCells, changedComponents };
}
export function lockCompactUrbanCompartmentsToDominantOwner(owner, compartments, maxCells = 1100) {
const isUrbanUnit = (unit) => unit && unit.area > 0 && (
unit.classId <= 3 ||
(unit.urbanWeight || 0) >= 0.34 ||
((unit.urbanWeight || 0) >= 0.22 && (unit.lowlandFitness || 0) >= 0.34)
);
const seen = new Set();
let changedCells = 0;
let unifiedComponents = 0;
for (const start of compartments || []) {
if (!isUrbanUnit(start) || seen.has(start.id)) continue;
const queue = [start.id];
const comp = [];
seen.add(start.id);
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const unit = compartments[cur];
if (!isUrbanUnit(unit)) continue;
comp.push(unit);
for (const next of unit.adjacent?.keys?.() || []) {
if (seen.has(next) || !isUrbanUnit(compartments[next])) continue;
seen.add(next);
queue.push(next);
}
}
const totalArea = comp.reduce((sum, unit) => sum + (unit.area || 0), 0);
if (comp.length <= 1 || totalArea <= 0 || totalArea > maxCells) continue;
const ownerScore = new Map();
for (const unit of comp) {
const id = owner[unit.id];
if (id < 0) continue;
const score = (unit.area || 0) * (1 + (unit.urbanWeight || 0) * 1.8);
ownerScore.set(id, (ownerScore.get(id) || 0) + score);
}
let best = -1, bestScore = -INF;
for (const [id, score] of ownerScore) if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; }
if (best < 0) continue;
let localChanged = 0;
for (const unit of comp) {
if (owner[unit.id] === best) continue;
owner[unit.id] = best;
localChanged += unit.area || 0;
}
if (localChanged > 0) {
changedCells += localChanged;
unifiedComponents++;
}
}
return { changedCells, unifiedComponents };
}
export function lockCityMetroCompartmentsToSingleMunicipality(owner, compartments, fields = {}) {
const { modernCities = [] } = fields;
if (!owner || !compartments?.length || !modernCities?.length) return { changedCells: 0, unifiedCities: 0 };
let changedCells = 0;
let unifiedCities = 0;
const urbanUnit = (unit) => unit && unit.area > 0 && (
unit.classId <= 3 ||
(unit.urbanWeight || 0) >= 0.24 ||
((unit.urbanWeight || 0) >= 0.16 && (unit.lowlandFitness || 0) >= 0.40)
);
for (const city of modernCities) {
if (!city || !Number.isFinite(city.x) || !Number.isFinite(city.y) || (city.population || 0) < 18000) continue;
const radius = clamp(
(city.urbanRadius || 8) * ((city.population || 0) >= 200000 ? 1.95 : (city.population || 0) >= 80000 ? 1.65 : 1.35),
8,
(city.population || 0) >= 200000 ? 34 : 24
);
const units = [];
for (const unit of compartments) {
if (!urbanUnit(unit)) continue;
const d = Math.hypot((unit.x || 0) - city.x, (unit.y || 0) - city.y);
if (d > radius) continue;
const weight = (unit.area || 1) *
(1 + (unit.urbanWeight || 0) * 2.4 + (unit.lowlandFitness || 0) * 0.55) *
Math.max(0.20, 1 - d / Math.max(1, radius) * 0.58);
units.push({ unit, weight, d });
}
if (units.length <= 1) continue;
const totalArea = units.reduce((sum, row) => sum + (row.unit.area || 0), 0);
// Large multi-core conurbations may legitimately contain multiple municipalities.
// This pass targets compact urban areas that visually read as one city.
const maxArea = (city.population || 0) >= 200000 ? 1800 : 900;
if (totalArea > maxArea) continue;
const ownerScore = new Map();
for (const row of units) {
const id = owner[row.unit.id];
if (id < 0) continue;
ownerScore.set(id, (ownerScore.get(id) || 0) + row.weight);
}
if (ownerScore.size <= 1) continue;
let best = -1, bestScore = -INF, totalScore = 0;
for (const [id, score] of ownerScore) {
totalScore += score;
if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; }
}
if (best < 0 || bestScore / Math.max(1, totalScore) < 0.28) continue;
let localChanged = 0;
for (const row of units) {
if (owner[row.unit.id] === best) continue;
owner[row.unit.id] = best;
localChanged += row.unit.area || 0;
}
if (localChanged > 0) {
changedCells += localChanged;
unifiedCities++;
}
}
return { changedCells, unifiedCities };
}
export function enforceSimpleAdministrativeHierarchy(adminId, compartments, prefectureMask, sea, fields = {}) {
const owner = dominantCompartmentOwners(compartments, adminId);
const urban = lockCompactUrbanCompartmentsToDominantOwner(owner, compartments, fields.maxUrbanClusterCells || 1100);
const metro = lockCityMetroCompartmentsToSingleMunicipality(owner, compartments, fields);
const connectivity1 = repairCompartmentOwnerConnectivity(owner, compartments, 8);
const enclave1 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 6);
const singleMerge = mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, fields.minCompartmentsPerMunicipality || 2, 8);
const connectivity2 = repairCompartmentOwnerConnectivity(owner, compartments, 8);
const enclave2 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 6);
const singleMerge2 = mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, fields.minCompartmentsPerMunicipality || 2, 4);
const connectivity3 = repairCompartmentOwnerConnectivity(owner, compartments, 4);
const enclave3 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 4);
applyCompartmentOwners(adminId, compartments, owner);
const counts = ownerAreaByCompartment(owner, compartments).count;
return {
changedAfterUrbanUnification: urban.changedCells + metro.changedCells,
urbanComponentsUnified: urban.unifiedComponents,
changedAfterCityMetroMunicipalityUnification: metro.changedCells,
cityMetroMunicipalitiesUnified: metro.unifiedCities,
changedAfterCompartmentConnectivity: connectivity1.changedCells + connectivity2.changedCells + connectivity3.changedCells,
disconnectedCompartmentComponentsMerged: connectivity1.changedComponents + connectivity2.changedComponents + connectivity3.changedComponents,
changedAfterCompartmentEnclaveRepair: enclave1.changedCells + enclave2.changedCells + enclave3.changedCells,
compartmentEnclaveComponentsMerged: enclave1.changedComponents + enclave2.changedComponents + enclave3.changedComponents,
changedAfterSingleCompartmentMunicipalityMerge: singleMerge.changedCells + singleMerge2.changedCells,
singleCompartmentMunicipalitiesMerged: singleMerge.mergedMunicipalities + singleMerge2.mergedMunicipalities,
remainingSingleCompartmentMunicipalities: [...counts.values()].filter((count) => count > 0 && count < (fields.minCompartmentsPerMunicipality || 2)).length,
};
}
export function repairPrefectureCellEnclavesFromGrid(adminId, owner, prefectureMask, sea, maxPasses = 5) {
let changed = 0;
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
for (let pass = 0; pass < maxPasses; pass++) {
const prefId = new Int16Array(SIZE);
prefId.fill(-1);
for (let i = 0; i < SIZE; i++) {
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
prefId[i] = owner.get(adminId[i]) ?? -1;
}
const seen = new Uint8Array(SIZE);
let passChanged = 0;
for (let i = 0; i < SIZE; i++) {
if (seen[i] || prefId[i] < 0) continue;
const id = prefId[i];
const queue = [i];
const comp = [];
seen[i] = 1;
let touchesOutside = false;
const boundaryCounts = new Map();
const adminCounts = new Map();
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
comp.push(cur);
const aid = adminId[cur];
if (aid >= 0) adminCounts.set(aid, (adminCounts.get(aid) || 0) + 1);
const [x, y] = xyOf(cur);
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesOutside = true;
for (const [dx, dy] of dirs) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) { touchesOutside = true; continue; }
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni]) { touchesOutside = true; continue; }
const nid = prefId[ni];
if (nid === id) {
if (!seen[ni]) { seen[ni] = 1; queue.push(ni); }
} else if (nid >= 0) {
boundaryCounts.set(nid, (boundaryCounts.get(nid) || 0) + 1);
}
}
}
if (touchesOutside || boundaryCounts.size !== 1) continue;
const [targetPref] = boundaryCounts.keys();
if (targetPref < 0 || targetPref === id) continue;
for (const aid of adminCounts.keys()) {
if (owner.get(aid) !== targetPref) { owner.set(aid, targetPref); passChanged++; }
}
}
changed += passChanged;
if (!passChanged) break;
}
return changed;
}
export function repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, maxPasses = 5) {
let changed = 0;
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
for (let pass = 0; pass < maxPasses; pass++) {
const seen = new Uint8Array(SIZE);
let passChanged = 0;
for (let i = 0; i < SIZE; i++) {
if (seen[i] || !prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
const id = adminId[i];
const queue = [i];
const comp = [];
seen[i] = 1;
let touchesOutside = false;
const boundaryCounts = new Map();
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
comp.push(cur);
const [x, y] = xyOf(cur);
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesOutside = true;
for (const [dx, dy] of dirs) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) { touchesOutside = true; continue; }
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni]) { touchesOutside = true; continue; }
const nid = adminId[ni];
if (nid === id) {
if (!seen[ni]) { seen[ni] = 1; queue.push(ni); }
} else if (nid >= 0) {
boundaryCounts.set(nid, (boundaryCounts.get(nid) || 0) + 1);
}
}
}
if (touchesOutside || boundaryCounts.size !== 1) continue;
const [targetId] = boundaryCounts.keys();
if (targetId < 0 || targetId === id) continue;
for (const ci of comp) adminId[ci] = targetId;
passChanged += comp.length;
}
changed += passChanged;
if (!passChanged) break;
}
return changed;
}
export function lockCompactUrbanAreasToDominantAdmin(adminId, prefectureMask, sea, landuse, populationDensity, maxCells = 950) {
if (!landuse || !populationDensity) return 0;
const seen = new Uint8Array(SIZE);
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
let changed = 0;
const isUrban = (i) => !sea[i] && prefectureMask[i] && (populationDensity[i] > 0.34 || [2, 3, 4, 7, 8].includes(landuse[i]));
for (let i = 0; i < SIZE; i++) {
if (seen[i] || !isUrban(i) || adminId[i] < 0) continue;
const queue = [i];
const comp = [];
seen[i] = 1;
const counts = new Map();
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
comp.push(cur);
const id = adminId[cur];
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1 + (populationDensity[cur] || 0) * 2.0);
const [x, y] = xyOf(cur);
for (const [dx, dy] of dirs) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (seen[ni] || !isUrban(ni)) continue;
seen[ni] = 1;
queue.push(ni);
}
}
if (comp.length < 8 || comp.length > maxCells || counts.size <= 1 || counts.size > 7) continue;
let best = -1, bestScore = -INF;
let total = 0;
for (const [id, score] of counts) {
total += score;
if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; }
}
if (best < 0 || bestScore / Math.max(1, total) < 0.38) continue;
for (const ci of comp) {
if (adminId[ci] !== best) { adminId[ci] = best; changed++; }
}
}
return changed;
}
export function splitOversizedCompartmentMunicipalities(adminId, centers, compartments, prefectureMask, sea, fields = {}, seed = 0) {
if (!compartments?.length) return { changedCells: 0, splitMunicipalities: 0, addedCenters: 0 };
const compOwner = new Int16Array(compartments.length);
compOwner.fill(-1);
for (const comp of compartments) {
if (!comp || !comp.cells?.length) continue;
const counts = new Map();
for (const i of comp.cells) {
if (!prefectureMask[i] || sea[i]) continue;
const id = adminId[i];
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
}
let best = -1, bestCount = -1;
for (const [id, count] of counts) if (count > bestCount || (count === bestCount && id < best)) { best = id; bestCount = count; }
compOwner[comp.id] = best;
}
const byOwner = new Map();
for (const comp of compartments) {
if (!comp || !comp.cells?.length) continue;
const owner = compOwner[comp.id];
if (owner < 0) continue;
if (!byOwner.has(owner)) byOwner.set(owner, []);
byOwner.get(owner).push(comp);
}
const areas = [...byOwner.values()].map((list) => list.reduce((sum, comp) => sum + comp.area, 0)).sort((a, b) => a - b);
if (!areas.length) return { changedCells: 0, splitMunicipalities: 0, addedCenters: 0 };
const median = areas[Math.floor(areas.length / 2)] || 1;
const total = areas.reduce((sum, value) => sum + value, 0);
const maxArea = Math.max(360, Math.min(total * 0.11, Math.max(median * 2.65, total / Math.max(12, Math.round(total / 520)))));
let changedCells = 0;
let splitMunicipalities = 0;
let addedCenters = 0;
const elevation = fields.elevation;
const slope = fields.slope;
const ridgeField = fields.ridgeField;
const plain = fields.plain;
const agriculture = fields.agriculture;
const basinField = fields.basinField;
const coastalLowland = fields.coastalLowland;
const populationDensity = fields.populationDensity;
for (const [owner, list] of [...byOwner.entries()].sort((a, b) => a[0] - b[0])) {
const area = list.reduce((sum, comp) => sum + comp.area, 0);
if (area <= maxArea || list.length < 4) continue;
const desiredParts = clamp(Math.ceil(area / Math.max(1, maxArea)), 2, 9);
const splitCount = desiredParts - 1;
if (splitCount <= 0) continue;
const candidates = list.map((comp) => {
let sx = 0, sy = 0, n = 0, score = 0, bestI = -1, bestScore = -INF;
for (const i of comp.cells) {
if (!prefectureMask[i] || sea[i]) continue;
const [x, y] = xyOf(i);
sx += x; sy += y; n++;
const cellScore =
(plain?.[i] || 0) * 0.22 +
(agriculture?.[i] || 0) * 0.24 +
(basinField?.[i] || 0) * 0.14 +
(coastalLowland?.[i] || 0) * 0.10 +
(populationDensity?.[i] || 0) * 0.24 -
(slope?.[i] || 0) * 0.20 -
(ridgeField?.[i] || 0) * 0.18 -
Math.max(0, (elevation?.[i] || 0) - 0.62) * 0.38;
score += cellScore;
if (cellScore > bestScore) { bestScore = cellScore; bestI = i; }
}
const [x, y] = bestI >= 0 ? xyOf(bestI) : [Math.round(sx / Math.max(1, n)), Math.round(sy / Math.max(1, n))];
return { comp, x, y, score: score / Math.max(1, n) + Math.sqrt(comp.area) * 0.025 + hash2(seed + owner, comp.id) * 0.03 };
}).sort((a, b) => b.score - a.score || a.comp.id - b.comp.id);
const newSeeds = [];
for (const cand of candidates) {
if (newSeeds.length >= splitCount) break;
if (newSeeds.every((s) => Math.hypot(s.x - cand.x, s.y - cand.y) >= 9)) newSeeds.push(cand);
}
if (!newSeeds.length) continue;
const seedIds = newSeeds.map((cand) => {
const id = centers.length;
centers.push({ x: cand.x, y: cand.y, score: cand.score, invisibleLowlandAdminSeed: true, seedKind: "oversizedMunicipalitySplit", splitFromAdminId: owner });
addedCenters++;
return id;
});
const oldCenter = centers[owner] || candidates[0] || { x: list[0].x || 0, y: list[0].y || 0, score: 0 };
const owners = [{ id: owner, x: oldCenter.x, y: oldCenter.y, score: oldCenter.score || 0 }, ...newSeeds.map((cand, k) => ({ id: seedIds[k], x: cand.x, y: cand.y, score: cand.score }))];
const targetArea = area / Math.max(1, owners.length);
const claimedArea = new Map(owners.map((entry) => [entry.id, 0]));
for (const cand of candidates) {
let bestSeed = owner;
let bestCost = INF;
for (const entry of owners) {
const d = Math.hypot(cand.x - entry.x, cand.y - entry.y);
const pressure = Math.max(0, ((claimedArea.get(entry.id) || 0) + cand.comp.area - targetArea * 1.25) / Math.max(1, targetArea));
const cost = d + pressure * 18 - cand.score * 2.5 + hash2(entry.id, cand.comp.id) * 0.05;
if (cost < bestCost) { bestCost = cost; bestSeed = entry.id; }
}
claimedArea.set(bestSeed, (claimedArea.get(bestSeed) || 0) + cand.comp.area);
if (bestSeed === owner) continue;
for (const i of cand.comp.cells) {
if (!prefectureMask[i] || sea[i]) continue;
if (adminId[i] !== bestSeed) { adminId[i] = bestSeed; changedCells++; }
}
}
splitMunicipalities++;
}
return { changedCells, splitMunicipalities, addedCenters, maxArea };
}

200
mapAdminSeedLifecycle.js Normal file
View file

@ -0,0 +1,200 @@
import { INF, clamp, indexOf, inside } from "./mapUtils.js";
import { applyCompartmentOwners, dominantCompartmentOwners, municipalityAreaById } from "./mapAdminShared.js";
export function absorbSeedCompartments(adminId, compartments, seedLifecycle) {
const owner = dominantCompartmentOwners(compartments, adminId);
const absorbed = new Set(seedLifecycle.filter((seed) => seed.state === "absorbed").map((seed) => seed.id));
let changed = 0;
for (const unit of compartments) {
if (!unit || unit.area === 0 || !absorbed.has(owner[unit.id])) continue;
let bestId = -1, bestScore = -INF;
for (const [neighborId, edge] of unit.adjacent) {
const candidate = owner[neighborId];
if (candidate < 0 || absorbed.has(candidate)) continue;
const neighbor = compartments[neighborId];
const score = edge.count * 2 - (edge.target / Math.max(1, edge.count)) * 2 + (neighbor?.area || 0) * 0.002;
if (score > bestScore) { bestScore = score; bestId = candidate; }
}
if (bestId < 0) continue;
owner[unit.id] = bestId;
changed += unit.area;
}
applyCompartmentOwners(adminId, compartments, owner);
return changed;
}
export function splitOversizedLowlandsWithPendingSeeds(adminId, prefectureMask, sea, fields, compartments, adminCenters, seedLifecycle, settlements = []) {
const { plain, agriculture, basinField, coastalLowland, valleyField, ridgeField, slope, elevation } = fields;
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
const areas = [...areaById.values()].sort((a, b) => a - b);
const median = areas.length ? areas[Math.floor(areas.length / 2)] : 0;
if (!median) return { changedCells: 0, splitMunicipalities: 0, pendingSeedsUsed: 0 };
const owner = dominantCompartmentOwners(compartments, adminId);
const unitsByOwner = new Map();
for (const unit of compartments) {
if (!unit || unit.area === 0 || owner[unit.id] < 0) continue;
if (!unitsByOwner.has(owner[unit.id])) unitsByOwner.set(owner[unit.id], []);
unitsByOwner.get(owner[unit.id]).push(unit);
}
const pending = seedLifecycle.filter((seed) => seed.state === "pending" && !seed.protected);
let changedCells = 0;
let splitMunicipalities = 0;
let pendingSeedsUsed = 0;
for (const [id, units] of unitsByOwner) {
const area = areaById.get(id) || 0;
if (area < Math.max(260, median * 1.45) || units.length < 6) continue;
let lowland = 0, rough = 0;
for (const unit of units) {
for (const i of unit.cells) {
lowland += (plain[i] || 0) * 0.38 + (agriculture[i] || 0) * 0.20 + basinField[i] * 0.22 + coastalLowland[i] * 0.22 + valleyField[i] * 0.10;
rough += ridgeField[i] * 0.48 + slope[i] * 0.34 + Math.max(0, elevation[i] - 0.58) * 0.30;
}
}
if (lowland / area < 0.26 || rough / area > 0.48) continue;
const localPending = pending.filter((seed) => {
const center = adminCenters[seed.id];
if (!center || !inside(center.x, center.y)) return false;
const centerOwner = adminId[indexOf(center.x, center.y)];
return centerOwner === id || Math.hypot(center.x - (adminCenters[id]?.x || center.x), center.y - (adminCenters[id]?.y || center.y)) < 28;
});
const localSettlements = settlements.filter((p) => p && inside(p.x, p.y) && adminId[indexOf(p.x, p.y)] === id);
if (localPending.length === 0 || localPending.length + localSettlements.length < 2) continue;
let municipalitySplit = false;
for (const seed of localPending.slice(0, 3)) {
const center = adminCenters[seed.id];
if (!center) continue;
const targetArea = clamp(95 + (center.score || 0.5) * 60, 90, 180);
let claimed = 0;
const candidates = units
.filter((unit) => owner[unit.id] === id && unit.classId !== 8 && unit.classId !== 9)
.map((unit) => ({
unit,
score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 8 - (unit.urbanWeight || 0) * 3,
}))
.sort((a, b) => a.score - b.score);
if (candidates.length < 2) continue;
for (const { unit } of candidates) {
if (claimed >= targetArea && claimed >= 2) break;
owner[unit.id] = seed.id;
claimed += unit.area;
changedCells += unit.area;
}
if (claimed >= 45) {
seed.state = "survived";
seed.area = claimed;
pendingSeedsUsed++;
municipalitySplit = true;
}
}
if (municipalitySplit) splitMunicipalities++;
}
applyCompartmentOwners(adminId, compartments, owner);
return { changedCells, splitMunicipalities, pendingSeedsUsed };
}
export function promotePendingSeedsForMunicipalityCount(adminId, prefectureMask, sea, fields, compartments, adminCenters, seedLifecycle, targetMinCount = 20) {
const { plain, agriculture, basinField, coastalLowland, valleyField, ridgeField, slope, elevation } = fields;
let areaById = municipalityAreaById(adminId, prefectureMask, sea);
let currentCount = areaById.size;
if (currentCount >= targetMinCount) return { changedCells: 0, promotedSeeds: 0 };
const owner = dominantCompartmentOwners(compartments, adminId);
let changedCells = 0;
let promotedSeeds = 0;
const pending = seedLifecycle
.filter((seed) => seed.state === "pending" && !seed.protected)
.sort((a, b) => (adminCenters[b.id]?.score || 0) - (adminCenters[a.id]?.score || 0));
for (const seed of pending) {
if (currentCount >= targetMinCount) break;
const center = adminCenters[seed.id];
if (!center || !inside(center.x, center.y)) continue;
const existingArea = areaById.get(seed.id) || 0;
if (existingArea >= 12) {
seed.state = "survived";
seed.area = existingArea;
promotedSeeds++;
continue;
}
const candidates = compartments
.filter((unit) => {
if (!unit || unit.area === 0) return false;
const currentOwner = owner[unit.id];
if (currentOwner < 0 || currentOwner === seed.id) return false;
const ownerArea = areaById.get(currentOwner) || 0;
if (ownerArea < 90) return false;
const lowlandFit = (unit.lowlandFitness || 0) + (unit.basinIdentity || 0) * 0.15 + (unit.coastalExposure || 0) * 0.15;
if (lowlandFit < 0.26) return false;
return Math.hypot(unit.x - center.x, unit.y - center.y) < 36;
})
.map((unit) => ({
unit,
score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 6 - (unit.urbanWeight || 0) * 2,
}))
.sort((a, b) => a.score - b.score);
if (candidates.length === 0) continue;
let claimed = 0;
for (const { unit } of candidates) {
const currentOwner = owner[unit.id];
if ((areaById.get(currentOwner) || 0) - unit.area < 70) continue;
owner[unit.id] = seed.id;
areaById.set(currentOwner, (areaById.get(currentOwner) || 0) - unit.area);
areaById.set(seed.id, (areaById.get(seed.id) || 0) + unit.area);
claimed += unit.area;
changedCells += unit.area;
if (claimed >= 55) break;
}
if (claimed >= 25) {
seed.state = "survived";
seed.area = areaById.get(seed.id) || claimed;
promotedSeeds++;
currentCount++;
}
}
applyCompartmentOwners(adminId, compartments, owner);
return { changedCells, promotedSeeds };
}
export function restoreSurvivedSeedsByCompartment(adminId, prefectureMask, sea, compartments, adminCenters, seedLifecycle, targetMinCount = 20) {
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
let currentCount = areaById.size;
if (currentCount >= targetMinCount) return { changedCells: 0, restoredSeeds: 0 };
const owner = dominantCompartmentOwners(compartments, adminId);
let changedCells = 0;
let restoredSeeds = 0;
const missing = seedLifecycle
.filter((seed) => (seed.state === "survived" || seed.protected) && (areaById.get(seed.id) || 0) === 0)
.sort((a, b) => (b.protected ? 1 : 0) - (a.protected ? 1 : 0));
for (const seed of missing) {
if (currentCount >= targetMinCount) break;
const center = adminCenters[seed.id];
if (!center || !inside(center.x, center.y)) continue;
const candidates = compartments
.filter((unit) => {
if (!unit || unit.area === 0 || unit.classId === 8 || unit.classId === 9) return false;
const currentOwner = owner[unit.id];
if (currentOwner < 0 || currentOwner === seed.id) return false;
if ((areaById.get(currentOwner) || 0) - unit.area < 25) return false;
return Math.hypot(unit.x - center.x, unit.y - center.y) < 90 && ((unit.lowlandFitness || 0) > 0.08 || seed.protected);
})
.map((unit) => ({
unit,
score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 6 - (unit.urbanWeight || 0) * 2 + (unit.classId === 8 || unit.classId === 9 ? 20 : 0),
}))
.sort((a, b) => a.score - b.score);
if (candidates.length === 0) continue;
const unit = candidates[0].unit;
const oldOwner = owner[unit.id];
if ((areaById.get(oldOwner) || 0) - unit.area < 25) continue;
owner[unit.id] = seed.id;
const claimed = unit.area;
areaById.set(oldOwner, (areaById.get(oldOwner) || 0) - claimed);
changedCells += claimed;
areaById.set(seed.id, claimed);
seed.area = claimed;
restoredSeeds++;
currentCount++;
}
applyCompartmentOwners(adminId, compartments, owner);
return { changedCells, restoredSeeds };
}

80
mapAdminShared.js Normal file
View file

@ -0,0 +1,80 @@
import { SIZE } from "./mapUtils.js";
export function changedCellsSince(before, after, prefectureMask, sea) {
let changed = 0;
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== after[i]) changed++;
return changed;
}
export function municipalityAreaById(adminId, prefectureMask, sea) {
const area = new Map();
for (let i = 0; i < SIZE; i++) {
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
area.set(adminId[i], (area.get(adminId[i]) || 0) + 1);
}
return area;
}
export function maskLandArea(mask, sea) {
let area = 0;
for (let i = 0; i < SIZE; i++) if (mask[i] && !sea[i]) area++;
return area;
}
export function isProtectedAdminSeed(seed) {
if (!seed) return false;
if (seed.seedKind === "capital" || seed.protectedCity?.isPrefecturalCapital) return true;
if (seed.seedKind === "modernCity" && (seed.protectedCity?.population || seed.population || 0) >= 180000) return true;
if (seed.seedKind === "port" && seed.portClass === "major") return true;
if (seed.seedKind === "satelliteCity" && (seed.protectedSatellite?.population || seed.population || 0) >= 60000) return true;
return false;
}
export function buildSeedLifecycle(adminCenters, adminId, prefectureMask, sea, minArea = 80) {
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
const lifecycle = adminCenters.map((center, id) => {
const protectedSeed = isProtectedAdminSeed(center);
const area = areaById.get(id) || 0;
const enoughArea = area >= (protectedSeed ? 28 : minArea);
return {
id,
protected: protectedSeed,
area,
state: enoughArea || protectedSeed ? "survived" : "pending",
};
});
return lifecycle;
}
export function activeSeedIds(seedLifecycle) {
return new Set(seedLifecycle.filter((seed) => seed.state === "survived" || seed.protected).map((seed) => seed.id));
}
export function dominantCompartmentOwners(compartments, adminId) {
const owner = new Int16Array(compartments.length);
owner.fill(-1);
for (const unit of compartments) {
if (!unit || unit.area === 0) continue;
const counts = new Map();
for (const i of unit.cells) {
const id = adminId[i];
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
}
let bestId = -1, best = -1;
for (const [id, count] of counts) if (count > best) { best = count; bestId = id; }
owner[unit.id] = bestId;
}
return owner;
}
export function applyCompartmentOwners(adminId, compartments, owner) {
for (const unit of compartments) {
if (!unit || unit.area === 0) continue;
const id = owner[unit.id];
if (id < 0) continue;
for (const i of unit.cells) adminId[i] = id;
}
}

File diff suppressed because it is too large Load diff

190
mapAdminTargets.js Normal file
View file

@ -0,0 +1,190 @@
import { MAP_H, MAP_W, clamp, hash2, indexOf, inside, pickEntities, rand } from "./mapUtils.js";
export function municipalityCountBoundsForRegion(landCells, meta = {}) {
// Use the same administrative density curve for the highlighted prefecture
// and neighboring prefectures. Only clipped slivers get a low floor.
let min = 1;
if (landCells >= 360) min = 2;
if (landCells >= 750) min = 4;
if (landCells >= 1400) min = 7;
if (landCells >= 2400) min = 11;
if (landCells >= 3800) min = 16;
if (landCells >= 5600) min = 22;
const max = clamp(Math.round(landCells / 160 + 6), Math.max(min, 4), 72);
return { min, max };
}
export function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField, coastalLowland, basinField, habitability, centrality, accessibility, geographicBarrier, modernCities, markets, ports, satelliteCities, villages, adminRegionMeta = {} }) {
let landCells = 0;
let habitableCells = 0;
let lowlandCells = 0;
let coastlineComplexity = 0;
let mountainCells = 0;
let habitabilitySum = 0;
let centralitySum = 0;
let accessibleCells = 0;
let barrierCells = 0;
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i]) continue;
landCells++;
if (slope[i] < 0.42 && ridgeField[i] < 0.55 && (!elevation || elevation[i] < 0.72)) habitableCells++;
if ((coastalLowland[i] > 0.20 || basinField[i] > 0.24) && slope[i] < 0.36 && ridgeField[i] < 0.52) lowlandCells++;
if (ridgeField[i] > 0.52 || slope[i] > 0.48) mountainCells++;
habitabilitySum += habitability?.[i] || 0;
centralitySum += centrality?.[i] || 0;
if ((accessibility?.[i] || 0) > 0.36 || (centrality?.[i] || 0) > 0.38) accessibleCells++;
if ((geographicBarrier?.[i] || ridgeField[i]) > 0.58) barrierCells++;
for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) {
const ni = indexOf(nx, ny);
if (sea[ni]) {
coastlineComplexity += 1 + coastalLowland[i] * 0.8;
break;
}
}
}
}
const independentSatellites = (satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality").length;
const ruralVillageWeight = Math.sqrt(Math.max(0, villages.length || 0)) * 0.55;
const settlementWeight = modernCities.length * 1.7 + markets.length * 1.15 + ports.length * 0.8 + independentSatellites * 0.7 + ruralVillageWeight;
const basinBonus = Math.min(8, [...basinField].filter((v, i) => prefectureMask[i] && !sea[i] && v > 0.34).length / 520);
const mountainRatio = landCells ? mountainCells / landCells : 0;
const barrierRatio = landCells ? barrierCells / landCells : 0;
const avgHabitability = landCells ? habitabilitySum / landCells : 0;
const avgCentrality = landCells ? centralitySum / landCells : 0;
const lowlandBonus = Math.min(7, lowlandCells / 430);
const livingSphereBonus = Math.min(5.5, accessibleCells / 560 + avgCentrality * 3.2 + avgHabitability * 1.6);
const rawTarget = Math.round(habitableCells / 158 + settlementWeight * 1.02 + coastlineComplexity * 0.032 + basinBonus * 0.70 + lowlandBonus * 1.08 + livingSphereBonus - mountainRatio * 1.70 - barrierRatio * 1.15);
const { min, max } = municipalityCountBoundsForRegion(landCells, adminRegionMeta);
return clamp(rawTarget, min, max);
}
export function lowlandAdminSeedScore(i, { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse, habitability, accessibility, centrality, boundaryAvoidance, adminBoundaryPreference, geographicBarrier }) {
const lowRelief = clamp((0.70 - elevation[i]) * 1.35) + clamp((0.34 - slope[i]) * 1.55) + clamp((0.48 - ridgeField[i]) * 1.10);
const landuseFit = [1, 2, 3, 4, 7, 8].includes(landuse[i]) ? 0.40 : landuse[i] === 5 || landuse[i] === 6 ? 0.12 : 0;
const unifiedNudge = clamp(
(habitability?.[i] || 0) * 0.05 +
(accessibility?.[i] || 0) * 0.04 +
(centrality?.[i] || 0) * 0.04 -
(adminBoundaryPreference?.[i] || 0) * 0.04 -
(geographicBarrier?.[i] || 0) * 0.03
) * 0.20;
return clamp(
lowRelief * 0.25 +
plain[i] * 0.28 +
basinField[i] * 0.24 +
coastalLowland[i] * 0.24 +
settlementScore[i] * 0.30 +
populationDensity[i] * 0.32 +
roadInfluence[i] * 0.16 +
railInfluence2[i] * 0.16 +
(stationInfluence?.[i] || 0) * 0.18 +
landuseFit +
unifiedNudge -
Math.max(0, elevation[i] - 0.62) * 1.2 -
Math.max(0, ridgeField[i] - 0.54) * 0.9
);
}
export function buildLowlandAdminSeeds({
seed,
targetMunicipalityCount,
prefectureMask,
sea,
elevation,
slope,
ridgeField,
plain,
basinField,
coastalLowland,
settlementScore,
populationDensity,
roadInfluence,
railInfluence2,
stationInfluence,
landuse,
habitability,
accessibility,
centrality,
boundaryAvoidance,
adminBoundaryPreference,
geographicBarrier,
modernCities,
satelliteCities,
markets,
ports,
newTowns,
stations,
}) {
const fields = { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse, habitability, accessibility, centrality, boundaryAvoidance, adminBoundaryPreference, geographicBarrier };
function validLowlandPoint(p, strict = true) {
if (!p || !inside(p.x, p.y)) return false;
const i = indexOf(p.x, p.y);
if (!prefectureMask[i] || sea[i]) return false;
const score = lowlandAdminSeedScore(i, fields);
const mountain = elevation[i] > 0.70 || slope[i] > 0.52 || ridgeField[i] > 0.62;
return score >= (strict ? 0.34 : 0.24) && (!mountain || p.isPrefecturalCapital || (p.population || 0) >= 220000 || p.portClass === "major");
}
const realSeeds = [];
for (const city of modernCities || []) {
if (!validLowlandPoint(city, !city.isPrefecturalCapital)) continue;
if ((city.population || 0) < 45000) continue;
const i = indexOf(city.x, city.y);
realSeeds.push({ x: city.x, y: city.y, score: 1.35 + (city.population || 0) / 650000 + lowlandAdminSeedScore(i, fields), population: city.population || 0, protectedCity: city, seedKind: city.isPrefecturalCapital ? "capital" : "modernCity" });
}
for (const city of satelliteCities || []) {
if (city.municipalityClass !== "independentSatelliteMunicipality" || !validLowlandPoint(city, false)) continue;
const i = indexOf(city.x, city.y);
realSeeds.push({ x: city.x, y: city.y, score: 0.92 + (city.population || 0) / 260000 + lowlandAdminSeedScore(i, fields), population: city.population || 0, protectedSatellite: city, seedKind: "satelliteCity" });
}
for (const p of [...(markets || []), ...(ports || []), ...(newTowns || [])]) {
if (!validLowlandPoint(p, true)) continue;
const i = indexOf(p.x, p.y);
const portBonus = p.portClass === "major" ? 0.45 : p.portClass === "regional" ? 0.26 : 0;
realSeeds.push({ x: p.x, y: p.y, score: 0.74 + lowlandAdminSeedScore(i, fields) + portBonus + (p.population || 0) / 420000, population: p.population || 0, portClass: p.portClass, source: p, seedKind: p.portClass ? "port" : "marketTown" });
}
const picked = pickEntities(realSeeds, {
max: targetMunicipalityCount,
minDistance: 5 + Math.floor(rand(seed, 1302) * 3),
threshold: 0.62,
seed: seed + 1300,
jitter: 0.025,
});
const invisibleCandidates = [];
for (let y = 2; y < MAP_H - 2; y++) {
for (let x = 2; x < MAP_W - 2; x++) {
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i]) continue;
const score = lowlandAdminSeedScore(i, fields) + hash2(x, y, seed + 1311) * 0.045;
if (score < 0.48) continue;
const insideDenseCore = modernCities.some((city) => (city.population || 0) >= 180000 && Math.hypot(city.x - x, city.y - y) < Math.max(5, (city.coreRadius || 4) * 1.7));
if (insideDenseCore) continue;
invisibleCandidates.push({ x, y, score, invisibleLowlandAdminSeed: true, seedKind: "invisibleLowland" });
}
}
if (picked.length < targetMunicipalityCount) {
const extra = pickEntities(invisibleCandidates, {
max: targetMunicipalityCount - picked.length,
minDistance: 5,
threshold: 0.48,
seed: seed + 1304,
jitter: 0.02,
});
for (const p of extra) if (picked.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 4)) picked.push(p);
}
if (picked.length < Math.min(targetMunicipalityCount, 20)) {
const relaxed = pickEntities(invisibleCandidates, {
max: Math.min(targetMunicipalityCount, 20) - picked.length,
minDistance: 4,
threshold: 0.38,
seed: seed + 1305,
jitter: 0.02,
});
for (const p of relaxed) if (picked.length < targetMunicipalityCount && picked.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 3.5)) picked.push(p);
}
return picked.slice(0, targetMunicipalityCount);
}

239
mapAdminUrbanCatchments.js Normal file
View file

@ -0,0 +1,239 @@
import { INF, SIZE, MinHeap, clamp, indexOf, inside, xyOf } from "./mapUtils.js";
import { municipalityAreaById } from "./mapAdminShared.js";
export function estimateUrbanComponentArea(city, prefectureMask, sea, landuse, populationDensity) {
if (!city || !inside(city.x, city.y)) return 0;
const start = indexOf(city.x, city.y);
if (!prefectureMask[start] || sea[start]) return 0;
const radius = Math.ceil(Math.max(7, (city.urbanRadius || 6) * 1.7));
const seen = new Uint8Array(SIZE);
const queue = [start];
seen[start] = 1;
let area = 0;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const [x, y] = xyOf(cur);
const d = Math.hypot(x - city.x, y - city.y);
if (d > radius) continue;
const urban = (landuse[cur] >= 2 && landuse[cur] <= 4) || landuse[cur] === 7 || landuse[cur] === 8 || populationDensity[cur] > 0.18;
if (!urban) continue;
area++;
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue;
seen[ni] = 1;
queue.push(ni);
}
}
return area;
}
export function terrainSeparationBetween(a, b, ridgeField, river, flowAccum, populationDensity, landuse) {
if (!a || !b) return { separatedByBarrier: false, ruralGap: false, averageDensity: 0, maxBarrier: 0 };
const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y)));
let maxBarrier = 0;
let lowUrbanRun = 0;
let bestLowUrbanRun = 0;
let densitySum = 0;
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const x = Math.round(a.x + (b.x - a.x) * t);
const y = Math.round(a.y + (b.y - a.y) * t);
if (!inside(x, y)) continue;
const i = indexOf(x, y);
const barrier = Math.max(ridgeField[i] * 0.95, river[i] * 0.85, flowAccum[i] * 0.42);
maxBarrier = Math.max(maxBarrier, barrier);
densitySum += populationDensity[i];
const urban = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.20;
if (urban) lowUrbanRun = 0;
else {
lowUrbanRun++;
bestLowUrbanRun = Math.max(bestLowUrbanRun, lowUrbanRun);
}
}
return {
separatedByBarrier: maxBarrier > 0.56,
ruralGap: bestLowUrbanRun >= 4,
averageDensity: densitySum / (steps + 1),
maxBarrier,
};
}
export function classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, ridgeField, river, flowAccum) {
let independent = 0;
let attached = 0;
for (const sat of satelliteCities || []) {
if (!sat || !inside(sat.x, sat.y) || !prefectureMask[indexOf(sat.x, sat.y)] || sea[indexOf(sat.x, sat.y)]) continue;
const parent = modernCities[sat.parentCityIndex] || modernCities.slice().sort((a, b) => Math.hypot(a.x - sat.x, a.y - sat.y) - Math.hypot(b.x - sat.x, b.y - sat.y))[0];
const parentDistance = parent ? Math.hypot(parent.x - sat.x, parent.y - sat.y) : 99;
const separation = terrainSeparationBetween(sat, parent, ridgeField, river, flowAccum, populationDensity, landuse);
const urbanArea = estimateUrbanComponentArea(sat, prefectureMask, sea, landuse, populationDensity);
const i = indexOf(sat.x, sat.y);
const continuousUrban = parent && parentDistance < Math.max(10, (parent.urbanRadius || 12) + (sat.urbanRadius || 5) + 5) && separation.averageDensity > 0.14 && !separation.ruralGap && !separation.separatedByBarrier;
const newTownLike = landuse[i] === 7 || (railInfluence2[i] > 0.22 && roadInfluence[i] > 0.12 && (sat.population || 0) < 70000);
let municipalityClass = "independentSatelliteMunicipality";
if (continuousUrban && (sat.population || 0) < 90000) municipalityClass = "suburbanDistrictMergedWithParent";
else if (newTownLike && (sat.population || 0) < 85000 && !separation.separatedByBarrier) municipalityClass = "newTownDistrict";
else if ((sat.population || 0) < 42000 && urbanArea < 55 && !separation.separatedByBarrier) municipalityClass = "smallTownAttachedToRuralMunicipality";
else if ((sat.population || 0) >= 60000 && urbanArea >= 42 && (separation.separatedByBarrier || separation.ruralGap || parentDistance > 15)) municipalityClass = "independentSatelliteMunicipality";
sat.municipalityClass = municipalityClass;
sat.parentX = parent?.x;
sat.parentY = parent?.y;
sat.parentAdminHint = -1;
sat.distinctUrbanComponentArea = urbanArea;
sat.separatedByBarrier = separation.separatedByBarrier || separation.ruralGap;
sat.satelliteMinArea = clamp(90 + Math.sqrt(sat.population || 24000) * 0.62 + (sat.urbanRadius || 5) * 12, 80, 360);
if (municipalityClass === "independentSatelliteMunicipality") independent++;
else attached++;
}
return { independent, attached };
}
export function expandSatelliteMunicipalityCatchment(adminId, satellite, targetAdmin, context) {
const { prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities } = context;
if (!satellite || targetAdmin < 0 || !inside(satellite.x, satellite.y)) return 0;
const start = indexOf(satellite.x, satellite.y);
if (!prefectureMask[start] || sea[start]) return 0;
const targetAreaBase = clamp(90 + Math.sqrt(satellite.population || 24000) * 0.8 + (satellite.urbanRadius || 5) * 18, 120, 520);
const targetArea = satellite.municipalityClass === "smallTownAttachedToRuralMunicipality"
? Math.min(130, targetAreaBase * 0.55)
: satellite.municipalityClass === "suburbanDistrictMergedWithParent" || satellite.municipalityClass === "newTownDistrict"
? Math.min(190, targetAreaBase * 0.62)
: targetAreaBase;
const maxCost = satellite.municipalityClass === "independentSatelliteMunicipality" ? 46 : 32;
const heap = new MinHeap();
const best = new Float32Array(SIZE);
best.fill(INF);
heap.push({ i: start, f: 0 });
best[start] = 0;
const claimed = [];
while (heap.length > 0 && claimed.length < targetArea) {
const cur = heap.pop();
if (!cur || cur.f > best[cur.i] + 1e-5 || cur.f > maxCost) continue;
const [x, y] = xyOf(cur.i);
const d = Math.hypot(x - satellite.x, y - satellite.y);
if (!prefectureMask[cur.i] || sea[cur.i]) continue;
let invadesOtherCore = false;
for (const city of modernCities || []) {
if (!city || (city.population || 0) < 140000) continue;
if (Math.hypot(city.x - satellite.x, city.y - satellite.y) < 4) continue;
if (Math.hypot(city.x - x, city.y - y) <= Math.max(3.5, (city.coreRadius || 4) * 1.25)) {
invadesOtherCore = true;
break;
}
}
if (invadesOtherCore) continue;
const compatible = d <= (satellite.urbanRadius || 5) * 1.25 ||
[2, 3, 4, 7, 8].includes(landuse[cur.i]) ||
populationDensity[cur.i] > 0.12 ||
roadInfluence[cur.i] > 0.12 ||
railInfluence2[cur.i] > 0.10 ||
stationInfluence?.[cur.i] > 0.10 ||
basinField[cur.i] > 0.22 ||
valleyField[cur.i] > 0.24 ||
coastalLowland[cur.i] > 0.20;
if (!compatible && claimed.length > targetArea * 0.55) continue;
claimed.push(cur.i);
for (const [dx, dy, step] of [[1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1], [1, 1, 1.41], [-1, 1, 1.41], [1, -1, 1.41], [-1, -1, 1.41]]) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni]) continue;
const barrier = ridgeField[ni] * 5.2 + Math.max(0, elevation[ni] - 0.58) * 4.0 + slope[ni] * 2.2 + (river[ni] > 0.55 || flowAccum[ni] > 0.70 ? 7.5 : river[ni] > 0.30 ? 2.8 : 0);
const living = ([2, 3, 4, 7, 8].includes(landuse[ni]) ? 2.2 : 0) + populationDensity[ni] * 2.0 + roadInfluence[ni] * 0.85 + railInfluence2[ni] * 0.95 + (stationInfluence?.[ni] || 0) * 1.2 + basinField[ni] * 0.42 + valleyField[ni] * 0.48 + coastalLowland[ni] * 0.32;
const distanceCost = Math.hypot(nx - satellite.x, ny - satellite.y) / Math.max(7, (satellite.urbanRadius || 5) * 1.9);
const nd = cur.f + Math.max(0.28, 1.05 + barrier - living + distanceCost) * step;
if (nd < best[ni]) {
best[ni] = nd;
heap.push({ i: ni, f: nd });
}
}
}
let changed = 0;
for (const i of claimed) {
if (adminId[i] !== targetAdmin) changed++;
adminId[i] = targetAdmin;
}
return changed;
}
export function cityMinimumMunicipalityArea(city) {
const populationArea = Math.sqrt(city.population || 0) * 0.72;
const footprintArea = (city.urbanFootprintCells || 0) * 0.42;
return clamp(95 + populationArea + footprintArea, 130, (city.population || 0) >= 450000 ? 780 : 520);
}
export function enforceCityMunicipalityCatchments(adminId, cities, context) {
const { prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence } = context;
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
let changed = 0;
let protectedCities = 0;
let tooSmall = 0;
for (const city of cities || []) {
if (!city || (city.population || 0) < 95000 || !inside(city.x, city.y)) continue;
const start = indexOf(city.x, city.y);
if (!prefectureMask[start] || sea[start]) continue;
const targetAdmin = adminId[start];
if (targetAdmin < 0) continue;
protectedCities++;
const minArea = cityMinimumMunicipalityArea(city);
if ((areaById.get(targetAdmin) || 0) >= minArea) continue;
tooSmall++;
const heap = new MinHeap();
const best = new Float32Array(SIZE);
best.fill(INF);
heap.push({ i: start, f: 0 });
best[start] = 0;
const claimed = [];
const maxCost = (city.population || 0) >= 450000 ? 78 : 56;
let projectedArea = areaById.get(targetAdmin) || 0;
while (heap.length > 0 && projectedArea < minArea) {
const cur = heap.pop();
if (!cur || cur.f > best[cur.i] + 1e-5 || cur.f > maxCost) continue;
const [x, y] = xyOf(cur.i);
if (!prefectureMask[cur.i] || sea[cur.i]) continue;
const d = Math.hypot(x - city.x, y - city.y);
const compatible = d <= Math.max(5, (city.coreRadius || 3) * 2.0) ||
[2, 3, 4, 7, 8].includes(landuse[cur.i]) ||
populationDensity[cur.i] > 0.10 ||
roadInfluence[cur.i] > 0.10 ||
railInfluence2[cur.i] > 0.10 ||
(stationInfluence?.[cur.i] || 0) > 0.10 ||
valleyField[cur.i] > 0.22 ||
basinField[cur.i] > 0.20 ||
coastalLowland[cur.i] > 0.18;
if (!compatible && claimed.length > minArea * 0.55) continue;
claimed.push(cur.i);
if (adminId[cur.i] !== targetAdmin) projectedArea++;
for (const [dx, dy, step] of [[1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1], [1, 1, 1.41], [-1, 1, 1.41], [1, -1, 1.41], [-1, -1, 1.41]]) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni]) continue;
const majorBarrier = river[ni] > 0.62 || flowAccum[ni] > 0.74 || ridgeField[ni] > 0.70;
const barrier = ridgeField[ni] * 5.4 + Math.max(0, elevation[ni] - 0.60) * 4.4 + slope[ni] * 2.6 + (majorBarrier ? 8.5 : river[ni] > 0.34 ? 2.6 : 0);
const fit = ([2, 3, 4, 7, 8].includes(landuse[ni]) ? 2.4 : 0) + populationDensity[ni] * 2.2 + roadInfluence[ni] * 0.88 + railInfluence2[ni] * 0.92 + (stationInfluence?.[ni] || 0) * 1.15 + valleyField[ni] * 0.56 + basinField[ni] * 0.42 + coastalLowland[ni] * 0.34;
const nd = cur.f + Math.max(0.30, 1.05 + barrier - fit + Math.hypot(nx - city.x, ny - city.y) / Math.max(8, (city.urbanRadius || 7) * 2.1)) * step;
if (nd < best[ni]) {
best[ni] = nd;
heap.push({ i: ni, f: nd });
}
}
}
for (const i of claimed) {
const old = adminId[i];
if (old === targetAdmin) continue;
if (old >= 0) areaById.set(old, Math.max(0, (areaById.get(old) || 0) - 1));
adminId[i] = targetAdmin;
areaById.set(targetAdmin, (areaById.get(targetAdmin) || 0) + 1);
changed++;
}
city.municipalityMinArea = minArea;
}
return { changed, protectedCities, tooSmall };
}

File diff suppressed because it is too large Load diff

387
mapGeography.js Normal file
View file

@ -0,0 +1,387 @@
import { INF, MAP_H, MAP_W, SIZE, clamp, indexOf, inside } from "./mapUtils.js";
import { influenceFromPoints } from "./mapGeneratorHelpers.js";
const GEOGRAPHY_VERSION = "unified-geography-v1";
function localConfluenceScore(x, y, river) {
let arms = 0;
let strong = 0;
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) {
const nx = x + dx;
const ny = y + dy;
if (!inside(nx, ny)) continue;
const rv = river?.[indexOf(nx, ny)] || 0;
if (rv > 0.18) arms++;
if (rv > 0.34) strong++;
}
return clamp((arms >= 3 ? 0.22 : arms === 2 ? 0.09 : 0) + strong * 0.04);
}
function summarizeField(field, sea = null) {
let min = INF;
let max = -INF;
let sum = 0;
let count = 0;
for (let i = 0; i < SIZE; i++) {
if (sea?.[i]) continue;
const v = field?.[i];
if (!Number.isFinite(v)) continue;
min = Math.min(min, v);
max = Math.max(max, v);
sum += v;
count++;
}
return {
min: count ? Math.round(min * 1000) / 1000 : 0,
max: count ? Math.round(max * 1000) / 1000 : 0,
mean: count ? Math.round((sum / count) * 1000) / 1000 : 0,
};
}
function buildProfiles(idField, terrain, fields) {
if (!idField) return [];
const { sea } = terrain;
const rows = new Map();
for (let i = 0; i < SIZE; i++) {
if (sea?.[i]) continue;
const id = idField[i];
if (id < 0) continue;
let row = rows.get(id);
if (!row) {
row = {
id,
area: 0,
habitableCells: 0,
lowlandCells: 0,
barrierCells: 0,
habitabilitySum: 0,
accessibilitySum: 0,
centralitySum: 0,
barrierSum: 0,
sx: 0,
sy: 0,
};
rows.set(id, row);
}
const x = i % MAP_W;
const y = Math.floor(i / MAP_W);
const h = fields.habitability?.[i] || 0;
const a = fields.accessibility?.[i] || 0;
const c = fields.centrality?.[i] || fields.naturalCentrality?.[i] || 0;
const b = fields.geographicBarrier?.[i] || 0;
row.area++;
row.habitabilitySum += h;
row.accessibilitySum += a;
row.centralitySum += c;
row.barrierSum += b;
row.sx += x;
row.sy += y;
if (h > 0.26) row.habitableCells++;
if ((terrain.plain?.[i] || 0) > 0.24 || (terrain.basinField?.[i] || 0) > 0.24 || (terrain.coastalLowland?.[i] || 0) > 0.22) row.lowlandCells++;
if (b > 0.52) row.barrierCells++;
}
return [...rows.values()]
.map((row) => ({
id: row.id,
area: row.area,
cx: Math.round((row.sx / Math.max(1, row.area)) * 10) / 10,
cy: Math.round((row.sy / Math.max(1, row.area)) * 10) / 10,
habitableRatio: Math.round((row.habitableCells / Math.max(1, row.area)) * 1000) / 1000,
lowlandRatio: Math.round((row.lowlandCells / Math.max(1, row.area)) * 1000) / 1000,
barrierRatio: Math.round((row.barrierCells / Math.max(1, row.area)) * 1000) / 1000,
avgHabitability: Math.round((row.habitabilitySum / Math.max(1, row.area)) * 1000) / 1000,
avgAccessibility: Math.round((row.accessibilitySum / Math.max(1, row.area)) * 1000) / 1000,
avgCentrality: Math.round((row.centralitySum / Math.max(1, row.area)) * 1000) / 1000,
avgBarrier: Math.round((row.barrierSum / Math.max(1, row.area)) * 1000) / 1000,
}))
.sort((a, b) => b.area - a.area || a.id - b.id);
}
export function buildGeographicBasis(seed, terrain) {
const {
elevation,
slope,
sea,
river,
floodplain,
plain,
agriculture,
ridgeField,
valleyField,
basinField,
coastalLowland,
flowAccum,
naturalBarrierScore,
portSuitability,
crossingSuitability,
passSuitability,
depositionalLowland,
alluvialFanField,
deltaField,
naturalCompartmentId,
watershedId,
} = terrain;
const habitability = new Float32Array(SIZE);
const lowlandCapacity = new Float32Array(SIZE);
const valleyAccess = new Float32Array(SIZE);
const coastalAccess = new Float32Array(SIZE);
const geographicBarrier = new Float32Array(SIZE);
const geographicBarrierCost = new Float32Array(SIZE);
const corridorSuitability = new Float32Array(SIZE);
const accessibility = new Float32Array(SIZE);
const naturalCentrality = new Float32Array(SIZE);
const centrality = new Float32Array(SIZE);
const adminBoundaryPreference = new Float32Array(SIZE);
const boundaryAvoidance = new Float32Array(SIZE);
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (sea?.[i]) {
geographicBarrier[i] = 1;
geographicBarrierCost[i] = INF;
continue;
}
const depositional =
(depositionalLowland?.[i] || 0) * 0.92 +
(alluvialFanField?.[i] || 0) * 0.56 +
(deltaField?.[i] || 0) * 0.86;
const highElevation = clamp(((elevation?.[i] || 0) - 0.54) / 0.34);
const lowSlope = clamp(1 - (slope?.[i] || 0) * 2.25);
const confluence = x > 0 && y > 0 && x < MAP_W - 1 && y < MAP_H - 1 ? localConfluenceScore(x, y, river) : 0;
const naturalBarrier = naturalBarrierScore?.[i] || 0;
const pass = passSuitability?.[i] || 0;
const crossing = crossingSuitability?.[i] || 0;
const port = portSuitability?.[i] || 0;
lowlandCapacity[i] = clamp(
(plain?.[i] || 0) * 0.38 +
(agriculture?.[i] || 0) * 0.28 +
(basinField?.[i] || 0) * 0.24 +
(coastalLowland?.[i] || 0) * 0.18 +
depositional * 0.20 +
lowSlope * 0.13 -
(slope?.[i] || 0) * 0.55 -
(ridgeField?.[i] || 0) * 0.38 -
highElevation * 0.62
);
valleyAccess[i] = clamp(
(valleyField?.[i] || 0) * 0.48 +
confluence * 0.40 +
crossing * 0.22 +
pass * 0.18 +
(flowAccum?.[i] || 0) * 0.08 +
(basinField?.[i] || 0) * 0.12 -
(slope?.[i] || 0) * 0.42 -
(ridgeField?.[i] || 0) * 0.22
);
coastalAccess[i] = clamp(
(coastalLowland?.[i] || 0) * 0.46 +
port * 0.34 +
(deltaField?.[i] || 0) * 0.22 +
(plain?.[i] || 0) * 0.10 -
(slope?.[i] || 0) * 0.42 -
(ridgeField?.[i] || 0) * 0.20
);
geographicBarrier[i] = clamp(
(slope?.[i] || 0) * 0.60 +
(ridgeField?.[i] || 0) * 0.52 +
naturalBarrier * 0.58 +
highElevation * 0.54 +
Math.max(0, (elevation?.[i] || 0) - 0.68) * 0.90 -
(valleyField?.[i] || 0) * 0.18 -
(basinField?.[i] || 0) * 0.12 -
pass * 0.30 -
(plain?.[i] || 0) * 0.10 -
(coastalLowland?.[i] || 0) * 0.06
);
geographicBarrierCost[i] = 1 + geographicBarrier[i] * 8.5 + (slope?.[i] || 0) * 3.0 + highElevation * 4.2;
habitability[i] = clamp(
lowlandCapacity[i] * 0.70 +
valleyAccess[i] * 0.22 +
coastalAccess[i] * 0.26 +
(agriculture?.[i] || 0) * 0.16 +
confluence * 0.07 -
geographicBarrier[i] * 0.38 -
(floodplain?.[i] || 0) * 0.04
);
corridorSuitability[i] = clamp(
(valleyField?.[i] || 0) * 0.30 +
(coastalLowland?.[i] || 0) * 0.23 +
(plain?.[i] || 0) * 0.18 +
(basinField?.[i] || 0) * 0.18 +
pass * 0.22 +
crossing * 0.12 +
habitability[i] * 0.20 -
geographicBarrier[i] * 0.32 -
(slope?.[i] || 0) * 0.20
);
accessibility[i] = clamp(
corridorSuitability[i] * 0.44 +
port * 0.16 +
crossing * 0.12 +
pass * 0.10 +
habitability[i] * 0.22 -
geographicBarrier[i] * 0.16
);
naturalCentrality[i] = clamp(
habitability[i] * 0.50 +
accessibility[i] * 0.30 +
(basinField?.[i] || 0) * 0.14 +
(plain?.[i] || 0) * 0.10 +
(coastalLowland?.[i] || 0) * 0.08 +
port * 0.08 +
confluence * 0.06 -
geographicBarrier[i] * 0.20
);
centrality[i] = naturalCentrality[i];
adminBoundaryPreference[i] = clamp(
naturalBarrier * 0.54 +
(ridgeField?.[i] || 0) * 0.32 +
(river?.[i] || 0) * 0.12 +
(flowAccum?.[i] || 0) * 0.08 -
naturalCentrality[i] * 0.20 -
habitability[i] * 0.08
);
boundaryAvoidance[i] = clamp(
naturalCentrality[i] * 0.54 +
habitability[i] * 0.24 +
(plain?.[i] || 0) * 0.12 +
(basinField?.[i] || 0) * 0.08 -
geographicBarrier[i] * 0.18
);
}
}
const fields = {
version: GEOGRAPHY_VERSION,
habitability,
lowlandCapacity,
valleyAccess,
coastalAccess,
geographicBarrier,
geographicBarrierCost,
barrierCost: geographicBarrierCost,
corridorSuitability,
accessibility,
naturalCentrality,
centrality,
adminBoundaryPreference,
boundaryAvoidance,
};
const geographyDebug = {
version: GEOGRAPHY_VERSION,
stage: "terrain-derived",
fields: {
habitability: summarizeField(habitability, sea),
accessibility: summarizeField(accessibility, sea),
centrality: summarizeField(centrality, sea),
geographicBarrier: summarizeField(geographicBarrier, sea),
corridorSuitability: summarizeField(corridorSuitability, sea),
},
naturalCompartmentCount: new Set([...naturalCompartmentId || []].filter((id, i) => id >= 0 && !sea?.[i])).size,
watershedCount: new Set([...watershedId || []].filter((id, i) => id >= 0 && !sea?.[i])).size,
};
return {
...fields,
compartmentProfiles: buildProfiles(naturalCompartmentId, terrain, fields),
watershedProfiles: buildProfiles(watershedId, terrain, fields),
geographyDebug,
};
}
export function finalizeGeographicBasis(seed, terrain, features, baseGeography) {
const base = baseGeography || buildGeographicBasis(seed, terrain);
const { sea } = terrain;
const {
roadInfluence,
railInfluence2,
stationInfluence,
populationDensity,
ports = [],
modernCities = [],
markets = [],
} = features || {};
const portInfluence = influenceFromPoints(ports, 16, (p) => p.portClass === "major" ? 1.1 : p.portClass === "regional" ? 0.78 : 0.34);
const cityInfluence = influenceFromPoints(modernCities, 24, (p) => clamp((p.population || 50000) / 240000, 0.35, 2.4));
const marketInfluence = influenceFromPoints(markets, 13, (p) => clamp((p.population || 10000) / 48000, 0.18, 1.0));
const accessibility = new Float32Array(SIZE);
const transportAccessibility = new Float32Array(SIZE);
const centrality = new Float32Array(SIZE);
const humanCentrality = new Float32Array(SIZE);
const boundaryAvoidance = new Float32Array(SIZE);
const adminBoundaryPreference = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea?.[i]) continue;
transportAccessibility[i] = clamp(
(roadInfluence?.[i] || 0) * 0.36 +
(railInfluence2?.[i] || 0) * 0.28 +
(stationInfluence?.[i] || 0) * 0.20 +
portInfluence[i] * 0.16
);
accessibility[i] = clamp(
(base.accessibility?.[i] || 0) * 0.46 +
transportAccessibility[i] * 0.48 +
(base.corridorSuitability?.[i] || 0) * 0.10 -
(base.geographicBarrier?.[i] || 0) * 0.10
);
humanCentrality[i] = clamp(
(populationDensity?.[i] || 0) * 0.38 +
cityInfluence[i] * 0.30 +
marketInfluence[i] * 0.14 +
transportAccessibility[i] * 0.18
);
centrality[i] = clamp(
(base.naturalCentrality?.[i] || 0) * 0.44 +
accessibility[i] * 0.26 +
humanCentrality[i] * 0.36 -
(base.geographicBarrier?.[i] || 0) * 0.08
);
boundaryAvoidance[i] = clamp(
(base.boundaryAvoidance?.[i] || 0) * 0.52 +
centrality[i] * 0.42 +
transportAccessibility[i] * 0.10
);
adminBoundaryPreference[i] = clamp(
(base.adminBoundaryPreference?.[i] || 0) * 0.84 -
centrality[i] * 0.12 -
transportAccessibility[i] * 0.08
);
}
const finalFields = {
...base,
accessibility,
transportAccessibility,
centrality,
humanCentrality,
boundaryAvoidance,
adminBoundaryPreference,
};
return {
...finalFields,
compartmentProfiles: buildProfiles(terrain.naturalCompartmentId, terrain, finalFields),
watershedProfiles: buildProfiles(terrain.watershedId, terrain, finalFields),
geographyDebug: {
...(base.geographyDebug || {}),
stage: "finalized-with-human-network",
fields: {
...(base.geographyDebug?.fields || {}),
accessibility: summarizeField(accessibility, sea),
transportAccessibility: summarizeField(transportAccessibility, sea),
centrality: summarizeField(centrality, sea),
humanCentrality: summarizeField(humanCentrality, sea),
adminBoundaryPreference: summarizeField(adminBoundaryPreference, sea),
boundaryAvoidance: summarizeField(boundaryAvoidance, sea),
},
},
};
}

View file

@ -25,10 +25,7 @@ function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) {
if (!value) value = `自治${ordinal + 1}`; if (!value) value = `自治${ordinal + 1}`;
value = value.replace(/[市町村区駅港城跡宿]$/gu, ""); value = value.replace(/[市町村区駅港城跡宿]$/gu, "");
const fallback = String(center?.generatedMunicipalityName || "里").replace(/[市町村区駅港城跡宿]$/gu, ""); const fallback = String(center?.generatedMunicipalityName || "里").replace(/[市町村区駅港城跡宿]$/gu, "");
if (Array.from(value).length < 2) value = `${value}${fallback || "里"}`; if (!value) value = fallback || "里";
// Municipality roots should be at most two toponymic elements. The admin
// suffix is separate; avoid direction+root+suffix three-element names.
value = Array.from(value).slice(0, 2).join("");
return `${value}${municipalitySuffixForCenter(center, fields, seed, ordinal)}`; return `${value}${municipalitySuffixForCenter(center, fields, seed, ordinal)}`;
} }
@ -37,15 +34,19 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields, settlement
if (!adminCenters?.length || !adminId) return; if (!adminCenters?.length || !adminId) return;
const totals = new Float64Array(adminCenters.length); const totals = new Float64Array(adminCenters.length);
const settlementTotals = new Float64Array(adminCenters.length); const settlementTotals = new Float64Array(adminCenters.length);
const landCells = new Uint32Array(adminCenters.length);
const inhabitedCells = new Uint32Array(adminCenters.length);
for (let i = 0; i < adminId.length; i++) { for (let i = 0; i < adminId.length; i++) {
const id = adminId[i]; const id = adminId[i];
if (id < 0 || id >= totals.length || fields.sea?.[i]) continue; if (id < 0 || id >= totals.length || fields.sea?.[i]) continue;
landCells[id]++;
const density = fields.populationDensity?.[i] || 0; const density = fields.populationDensity?.[i] || 0;
const lu = fields.landuse?.[i] ?? 0; const lu = fields.landuse?.[i] ?? 0;
const plain = fields.plain?.[i] || 0; const plain = fields.plain?.[i] || 0;
const agri = fields.agriculture?.[i] || 0; const agri = fields.agriculture?.[i] || 0;
const builtWeight = lu === 3 ? 520 : lu === 2 ? 360 : lu === 4 || lu === 7 || lu === 8 ? 260 : lu === 5 || lu === 6 ? 160 : lu === 1 ? 82 : 22; const builtWeight = lu === 3 ? 520 : lu === 2 ? 360 : lu === 4 || lu === 7 || lu === 8 ? 260 : lu === 5 || lu === 6 ? 160 : lu === 1 ? 82 : 22;
const ruralFloor = lu === 1 ? 10 + agri * 18 + plain * 10 : 0; const ruralFloor = lu === 1 ? 10 + agri * 18 + plain * 10 : 0;
if (density > 0.006 || ruralFloor > 0 || lu > 0) inhabitedCells[id]++;
totals[id] += density * builtWeight + ruralFloor; totals[id] += density * builtWeight + ruralFloor;
} }
// Population-bearing generated settlements are canonical entities, so add // Population-bearing generated settlements are canonical entities, so add
@ -78,8 +79,17 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields, settlement
} }
for (let id = 0; id < adminCenters.length; id++) { for (let id = 0; id < adminCenters.length; id++) {
const raw = (totals[id] || 0) + (settlementTotals[id] || 0); const raw = (totals[id] || 0) + (settlementTotals[id] || 0);
const rounded = raw >= 10000 ? Math.round(raw / 1000) * 1000 : Math.round(raw / 100) * 100; const minimumResidentPopulation = landCells[id] > 0
adminCenters[id].municipalityPopulation = Math.max(0, rounded); ? Math.round(Math.max(100, Math.min(3800, 80 + landCells[id] * 9 + inhabitedCells[id] * 16)) / 100) * 100
: 0;
const adjustedRaw = Math.max(raw, minimumResidentPopulation);
const rounded = adjustedRaw >= 10000 ? Math.round(adjustedRaw / 1000) * 1000 : Math.max(minimumResidentPopulation, Math.round(adjustedRaw / 100) * 100);
const safePopulation = Math.max(landCells[id] > 0 ? 100 : 0, rounded);
adminCenters[id].municipalityPopulation = safePopulation;
// Some consumers still read the generic `population` field from municipal
// centers. Mirror the municipality total there so no municipality is shown
// as 0人 merely because it is not a canonical city/market entity.
adminCenters[id].population = Math.max(adminCenters[id].population || 0, safePopulation);
adminCenters[id].municipalitySettlementPopulation = Math.max(0, Math.round((settlementTotals[id] || 0) / 100) * 100); adminCenters[id].municipalitySettlementPopulation = Math.max(0, Math.round((settlementTotals[id] || 0) / 100) * 100);
adminCenters[id].municipalitySkippedDuplicateSettlementPopulation = Math.max(0, Math.round(skippedDuplicateSettlementPopulation / 100) * 100); adminCenters[id].municipalitySkippedDuplicateSettlementPopulation = Math.max(0, Math.round(skippedDuplicateSettlementPopulation / 100) * 100);
} }
@ -93,6 +103,33 @@ function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCiti
const id = prefectureRegionId[i]; const id = prefectureRegionId[i];
if (!sea[i] && id >= 0) prefIds.add(id); if (!sea[i] && id >= 0) prefIds.add(id);
} }
const prefProfiles = new Map();
for (let i = 0; i < prefectureRegionId.length; i++) {
const prefId = prefectureRegionId[i];
if (sea[i] || prefId < 0) continue;
const profile = prefProfiles.get(prefId) || { landCells: 0, habitableCells: 0, lowlandCells: 0, densitySum: 0 };
profile.landCells++;
const slopeV = fields.slope?.[i] || 0;
const ridgeV = fields.ridgeField?.[i] || 0;
const elevV = fields.elevation?.[i] || 0;
const lowland = ((fields.plain?.[i] || 0) > 0.24 || (fields.basinField?.[i] || 0) > 0.26 || (fields.coastalLowland?.[i] || 0) > 0.22) && slopeV < 0.38 && ridgeV < 0.55;
if (slopeV < 0.42 && ridgeV < 0.58 && elevV < 0.74) profile.habitableCells++;
if (lowland) profile.lowlandCells++;
profile.densitySum += fields.populationDensity?.[i] || 0;
prefProfiles.set(prefId, profile);
}
function capitalFloorForPref(prefId) {
const p = prefProfiles.get(prefId) || { landCells: 0, habitableCells: 0, lowlandCells: 0, densitySum: 0 };
const lowlandRatio = p.landCells ? p.lowlandCells / p.landCells : 0;
const densityBoost = clamp((p.densitySum / Math.max(1, p.landCells) - 0.16) / 0.42);
let base = 45000;
if (p.lowlandCells > 900 || (p.lowlandCells > 650 && lowlandRatio > 0.28)) base = minPopulation * 0.90;
else if (p.lowlandCells > 520) base = 160000;
else if (p.lowlandCells > 260) base = 110000;
else if (p.lowlandCells > 120 || p.habitableCells > 420) base = 70000;
const adjusted = base + densityBoost * 50000;
return Math.round(clamp(adjusted, 42000, minPopulation + 45000) / 1000) * 1000;
}
let promoted = 0; let promoted = 0;
function prefAt(p) { function prefAt(p) {
if (!p || !inside(p.x, p.y)) return -1; if (!p || !inside(p.x, p.y)) return -1;
@ -130,7 +167,10 @@ function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCiti
} }
} }
if (!target) continue; if (!target) continue;
const promotedPopulation = Math.round((minPopulation + rand(seed + 52000, prefId * 37 + 11) * 120000) / 1000) * 1000; const regionalFloor = capitalFloorForPref(prefId);
const candidateCapacity = Number.isFinite(target.capacity) ? Math.max(42000, target.capacity * 1.18) : Infinity;
const randomizedFloor = Math.round((regionalFloor + rand(seed + 52000, prefId * 37 + 11) * Math.max(12000, regionalFloor * 0.28)) / 1000) * 1000;
const promotedPopulation = Math.max(42000, Math.round(Math.min(randomizedFloor, candidateCapacity) / 1000) * 1000);
if ((target.population || 0) < promotedPopulation) { if ((target.population || 0) < promotedPopulation) {
target.population = promotedPopulation; target.population = promotedPopulation;
promoted++; promoted++;
@ -251,6 +291,7 @@ export function finishMapOutput({
terrain, terrain,
features, features,
admin, admin,
geography = null,
}) { }) {
const { const {
terrainTemplate, terrainTemplate,
@ -272,6 +313,7 @@ export function finishMapOutput({
basinField, basinField,
coastalLowland, coastalLowland,
flowAccum, flowAccum,
watershedId,
erosionField, erosionField,
depositionField, depositionField,
arcSpineField, arcSpineField,
@ -296,6 +338,7 @@ export function finishMapOutput({
railInfluence2, railInfluence2,
settlementCluster, settlementCluster,
villages: inputVillages, villages: inputVillages,
geographicUrbanAnchors: inputGeographicUrbanAnchors = [],
ports: inputPorts, ports: inputPorts,
crossings: inputCrossings, crossings: inputCrossings,
passes: inputPasses, passes: inputPasses,
@ -342,6 +385,7 @@ export function finishMapOutput({
} = admin; } = admin;
let villages = inputVillages; let villages = inputVillages;
let geographicUrbanAnchors = inputGeographicUrbanAnchors;
let ports = inputPorts; let ports = inputPorts;
let crossings = inputCrossings; let crossings = inputCrossings;
let passes = inputPasses; let passes = inputPasses;
@ -386,6 +430,7 @@ export function finishMapOutput({
outputProgress("feature naming"); outputProgress("feature naming");
villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed, null, nameFields, usedNames, nameDebug); villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed, null, nameFields, usedNames, nameDebug);
geographicUrbanAnchors = attachIdsAndNames(tagInsidePrefecture(geographicUrbanAnchors, prefectureMask), "geoAnchor", seed, null, nameFields, usedNames, nameDebug);
ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed, null, nameFields, usedNames, nameDebug); ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed, null, nameFields, usedNames, nameDebug);
crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed, null, nameFields, usedNames, nameDebug); crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed, null, nameFields, usedNames, nameDebug);
passes = attachIdsAndNames(tagInsidePrefecture(passes, prefectureMask), "pass", seed, null, nameFields, usedNames, nameDebug); passes = attachIdsAndNames(tagInsidePrefecture(passes, prefectureMask), "pass", seed, null, nameFields, usedNames, nameDebug);
@ -458,7 +503,7 @@ export function finishMapOutput({
].filter((v) => Array.from(v).length >= 2); ].filter((v) => Array.from(v).length >= 2);
for (let attempt = 0; attempt < alternates.length + 12 && usedAdminNames.has(candidate); attempt++) { for (let attempt = 0; attempt < alternates.length + 12 && usedAdminNames.has(candidate); attempt++) {
const root = attempt < alternates.length ? alternates[attempt] : `${(index + attempt) % 10}`; const root = attempt < alternates.length ? alternates[attempt] : `${(index + attempt) % 10}`;
candidate = `${Array.from(root).slice(0, 2).join("")}${suffix}`; candidate = `${root}${suffix}`;
} }
} }
center.name = candidate; center.name = candidate;
@ -615,6 +660,161 @@ export function finishMapOutput({
} }
} }
addMunicipalCenterLocalAccess(); addMunicipalCenterLocalAccess();
function pruneIsolatedFinalRoadComponents() {
const debugLayers = transportDebug ? (transportDebug.layers || (transportDebug.layers = {})) : {};
const groups = [
["minor", minorRoads],
["national", nationalRoads],
["external", externalRoads],
["expressway", expressways],
["externalExpressway", externalExpressways],
];
function rasterize(path, fn) {
for (let k = 0; k < (path?.length || 0); k++) {
const [x0, y0] = path[k];
const [x1, y1] = path[Math.min(k + 1, path.length - 1)];
const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0)));
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const x = Math.round(x0 + (x1 - x0) * t);
const y = Math.round(y0 + (y1 - y0) * t);
fn(x, y);
}
}
}
function splitPathOnSea(path) {
const chunks = [];
let chunk = [];
function pushPoint(x, y) {
if (!inside(x, y) || sea[indexOf(x, y)]) {
if (chunk.length >= 2) chunks.push(chunk);
chunk = [];
return;
}
if (!chunk.length || chunk[chunk.length - 1][0] !== x || chunk[chunk.length - 1][1] !== y) chunk.push([x, y]);
}
for (let k = 0; k < (path?.length || 0); k++) {
const [x0, y0] = path[k];
const [x1, y1] = path[Math.min(k + 1, path.length - 1)];
const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0)));
for (let s = 0; s <= steps; s++) {
const t = s / steps;
pushPoint(Math.round(x0 + (x1 - x0) * t), Math.round(y0 + (y1 - y0) * t));
}
}
if (chunk.length >= 2) chunks.push(chunk);
return chunks;
}
// Keep sea-crossing cells in the stored path so the renderer can draw
// explicit bridge overlays. Connectivity analysis below ignores sea cells
// when rasterizing components, so preserving them here does not make islands
// falsely connected by ordinary land roads.
function pathNearAdminCenter(path, radius = 0.75) {
for (const center of adminCenters || []) {
if (!center || !inside(center.x, center.y)) continue;
for (const [x, y] of path || []) {
if (Math.hypot(center.x - x, center.y - y) <= radius) return true;
}
}
return false;
}
function components() {
const occ = new Uint8Array(MAP_W * MAP_H);
for (const [, paths] of groups) for (const path of paths || []) rasterize(path, (x, y) => {
if (inside(x, y) && !sea[indexOf(x, y)]) occ[indexOf(x, y)] = 1;
});
const seen = new Uint8Array(MAP_W * MAP_H);
const out = [];
for (let i = 0; i < occ.length; i++) {
if (!occ[i] || seen[i]) continue;
const queue = [i];
const cells = [];
seen[i] = 1;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
cells.push(cur);
const [x, y] = xyOf(cur);
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) {
if (!dx && !dy) continue;
if (dx * dx + dy * dy > 5) continue;
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (!occ[ni] || seen[ni]) continue;
seen[ni] = 1;
queue.push(ni);
}
}
out.push({ cells, size: cells.length });
}
return out.sort((a, b) => b.size - a.size);
}
let comps = components();
const before = comps.length;
const pruned = { minor: 0, national: 0, external: 0, expressway: 0, externalExpressway: 0 };
for (let pass = 0; pass < 4 && comps.length > 1; pass++) {
const mainMask = new Uint8Array(MAP_W * MAP_H);
for (const ci of comps[0].cells) {
const [cx, cy] = xyOf(ci);
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) {
if (dx * dx + dy * dy > 5) continue;
const nx = cx + dx, ny = cy + dy;
if (inside(nx, ny)) mainMask[indexOf(nx, ny)] = 1;
}
}
function touchesMain(path) {
let hit = 0, n = 0;
rasterize(path, (x, y) => {
if (!inside(x, y) || sea[indexOf(x, y)]) return;
n++;
if (mainMask[indexOf(x, y)]) hit++;
});
return n > 0 && hit / n >= (pass === 0 ? 0.10 : 0.01);
}
for (const [key, paths] of groups) {
const kept = [];
for (const path of paths || []) {
if (touchesMain(path) || pathNearAdminCenter(path)) kept.push(path);
else pruned[key]++;
}
paths.length = 0;
paths.push(...kept);
}
comps = components();
}
debugLayers.finalOutputRoadConnectivity = { beforeComponents: before, afterComponents: comps.length, pruned };
}
pruneIsolatedFinalRoadComponents();
function ensureAdminCenterCellsAfterOutputPrune() {
let added = 0;
function roadTouches(center) {
for (const path of [...minorRoads, ...nationalRoads, ...externalRoads]) {
for (const [x, y] of path || []) if (Math.hypot(center.x - x, center.y - y) <= 0.65) return true;
}
return false;
}
for (const center of adminCenters || []) {
if (!center || !inside(center.x, center.y) || sea[indexOf(center.x, center.y)]) continue;
if (roadTouches(center)) continue;
const x = Math.round(center.x);
const y = Math.round(center.y);
const horizontal = [[Math.max(0, x - 1), y], [x, y], [Math.min(MAP_W - 1, x + 1), y]]
.filter((pt, idx, arr) => idx === 0 || pt[0] !== arr[idx - 1][0] || pt[1] !== arr[idx - 1][1]);
const vertical = [[x, Math.max(0, y - 1)], [x, y], [x, Math.min(MAP_H - 1, y + 1)]]
.filter((pt, idx, arr) => idx === 0 || pt[0] !== arr[idx - 1][0] || pt[1] !== arr[idx - 1][1]);
minorRoads.push(horizontal.length >= 2 ? horizontal : vertical);
added++;
}
if (transportDebug) {
transportDebug.layers ||= {};
transportDebug.layers.adminCenterFinalStubs = added;
}
}
ensureAdminCenterCellsAfterOutputPrune();
nameDebug.maxDerivedPerBase = 0; nameDebug.maxDerivedPerBase = 0;
const prefectureRegions = buildPrefectureRegions(prefectureRegionId, sea, nameFields, seed, usedNames, nameDebug, { modernCities, adminCenters }); const prefectureRegions = buildPrefectureRegions(prefectureRegionId, sea, nameFields, seed, usedNames, nameDebug, { modernCities, adminCenters });
const regionalPrefectureBorders = adminRegionalPrefectureBorders || []; const regionalPrefectureBorders = adminRegionalPrefectureBorders || [];
@ -657,6 +857,25 @@ export function finishMapOutput({
regionalDebug, regionalDebug,
terrainDebug, terrainDebug,
regionalPrefectureBorders, regionalPrefectureBorders,
geography,
geographyDebug: geography?.geographyDebug || null,
habitability: geography?.habitability || null,
accessibility: geography?.accessibility || null,
centrality: geography?.centrality || null,
naturalCentrality: geography?.naturalCentrality || null,
humanCentrality: geography?.humanCentrality || null,
transportAccessibility: geography?.transportAccessibility || null,
geographicBarrier: geography?.geographicBarrier || null,
geographicBarrierCost: geography?.geographicBarrierCost || geography?.barrierCost || null,
barrierCost: geography?.barrierCost || geography?.geographicBarrierCost || null,
corridorSuitability: geography?.corridorSuitability || null,
adminBoundaryPreference: geography?.adminBoundaryPreference || null,
boundaryAvoidance: geography?.boundaryAvoidance || null,
lowlandCapacity: geography?.lowlandCapacity || null,
valleyAccess: geography?.valleyAccess || null,
coastalAccess: geography?.coastalAccess || null,
geographicCompartmentProfiles: geography?.compartmentProfiles || [],
watershedProfiles: geography?.watershedProfiles || [],
elevation, elevation,
moisture, moisture,
slope, slope,
@ -675,6 +894,7 @@ export function finishMapOutput({
basinField, basinField,
coastalLowland, coastalLowland,
flowAccum, flowAccum,
watershedId,
erosionField, erosionField,
depositionField, depositionField,
arcSpineField, arcSpineField,
@ -686,6 +906,7 @@ export function finishMapOutput({
naturalCompartmentId, naturalCompartmentId,
naturalCompartments, naturalCompartments,
villages, villages,
geographicUrbanAnchors,
ports, ports,
crossings, crossings,
passes, passes,

View file

@ -3,6 +3,8 @@ import { generateTerrainAndRivers } from "./mapTerrain.js";
import { generateMapFeatures } from "./mapFeatures.js"; import { generateMapFeatures } from "./mapFeatures.js";
import { finishMapOutput } from "./mapOutput.js"; import { finishMapOutput } from "./mapOutput.js";
import { generateAdminLayout } from "./mapAdminStage.js"; import { generateAdminLayout } from "./mapAdminStage.js";
import { buildGeographicBasis, finalizeGeographicBasis } from "./mapGeography.js";
import { finalizeAdminAwareTransport } from "./mapPostAdminTransport.js";
export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js"; export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
@ -69,7 +71,10 @@ export function generateMap(seedInput = 114514, options = {}) {
naturalCompartments, naturalCompartments,
} = terrain; } = terrain;
const features = stage("human", "Human geography and transport", () => generateMapFeatures(seed, terrain)); const geographyBasis = stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain));
const terrainWithGeography = { ...terrain, geography: geographyBasis };
const features = stage("settlements", "Settlements, towns, ports, and land-use demand", () => generateMapFeatures(seed, terrainWithGeography));
const { const {
settlementScore, settlementScore,
villages, villages,
@ -89,8 +94,11 @@ export function generateMap(seedInput = 114514, options = {}) {
villageInfluence, villageInfluence,
} = features; } = features;
const geography = stage("geographyFinal", "Unified accessibility and centrality fields", () => finalizeGeographicBasis(seed, terrain, features, geographyBasis));
const admin = stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({ const admin = stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({
seed, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, seed, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
geography, habitability: geography.habitability, accessibility: geography.accessibility, centrality: geography.centrality, geographicBarrier: geography.geographicBarrier, geographicBarrierCost: geography.geographicBarrierCost, adminBoundaryPreference: geography.adminBoundaryPreference, boundaryAvoidance: geography.boundaryAvoidance,
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks, settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
adminProgress: (event) => options?.onProgress?.({ adminProgress: (event) => options?.onProgress?.({
...event, ...event,
@ -104,12 +112,15 @@ export function generateMap(seedInput = 114514, options = {}) {
}), }),
})); }));
stage("transport", "Administrative-aware final transport network", () => finalizeAdminAwareTransport({ seed, terrain, features, admin, geography }));
const output = stage("output", "Names, population totals, and final packaging", () => finishMapOutput({ const output = stage("output", "Names, population totals, and final packaging", () => finishMapOutput({
seed, seed,
options, options,
terrain, terrain,
features, features,
admin, admin,
geography,
})); }));
output.generationTimings = generationTimings; output.generationTimings = generationTimings;
output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0); output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0);
@ -143,7 +154,10 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
naturalCompartments, naturalCompartments,
} = terrain; } = terrain;
const features = await stage("human", "Human geography and transport", () => generateMapFeatures(seed, terrain)); const geographyBasis = await stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain));
const terrainWithGeography = { ...terrain, geography: geographyBasis };
const features = await stage("settlements", "Settlements, towns, ports, and land-use demand", () => generateMapFeatures(seed, terrainWithGeography));
const { const {
settlementScore, settlementScore,
villages, villages,
@ -163,8 +177,11 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
villageInfluence, villageInfluence,
} = features; } = features;
const geography = await stage("geographyFinal", "Unified accessibility and centrality fields", () => finalizeGeographicBasis(seed, terrain, features, geographyBasis));
const admin = await stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({ const admin = await stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({
seed, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, seed, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
geography, habitability: geography.habitability, accessibility: geography.accessibility, centrality: geography.centrality, geographicBarrier: geography.geographicBarrier, geographicBarrierCost: geography.geographicBarrierCost, adminBoundaryPreference: geography.adminBoundaryPreference, boundaryAvoidance: geography.boundaryAvoidance,
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks, settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
adminProgress: (event) => options?.onProgress?.({ adminProgress: (event) => options?.onProgress?.({
...event, ...event,
@ -178,12 +195,15 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
}), }),
})); }));
await stage("transport", "Administrative-aware final transport network", () => finalizeAdminAwareTransport({ seed, terrain, features, admin, geography }));
const output = await stage("output", "Names, population totals, and final packaging", () => finishMapOutput({ const output = await stage("output", "Names, population totals, and final packaging", () => finishMapOutput({
seed, seed,
options, options,
terrain, terrain,
features, features,
admin, admin,
geography,
})); }));
output.generationTimings = generationTimings; output.generationTimings = generationTimings;
output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0); output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0);

312
mapPostAdminTransport.js Normal file
View file

@ -0,0 +1,312 @@
import { MAP_W, MAP_H, SIZE, indexOf, inside } from "./mapUtils.js";
import { pathLengthCells } from "./mapTransport.js";
function cellKey(x, y) { return `${Math.round(x)},${Math.round(y)}`; }
function pathTouchesCell(path, x, y, radius = 0.65) {
if (!path || path.length < 1) return false;
for (const [px, py] of path) if (Math.hypot(px - x, py - y) <= radius) return true;
return false;
}
function anyPathTouches(paths, p, radius = 0.65) {
return (paths || []).some((path) => pathTouchesCell(path, p.x, p.y, radius));
}
function pathTerrainRuns(path, terrain = null) {
const sea = terrain?.sea;
const elevation = terrain?.elevation;
const ridgeField = terrain?.ridgeField;
const naturalBarrierScore = terrain?.naturalBarrierScore;
let seaRun = 0, maxSeaRun = 0, tunnelRun = 0, maxTunnelRun = 0;
for (const [x, y] of path || []) {
if (!inside(x, y)) continue;
const i = indexOf(x, y);
const isSea = Boolean(sea?.[i]);
const isTunnel = !isSea && (((elevation?.[i] || 0) >= 0.74 && (ridgeField?.[i] || 0) >= 0.46) || (naturalBarrierScore?.[i] || 0) >= 0.82);
if (isSea) { seaRun++; maxSeaRun = Math.max(maxSeaRun, seaRun); } else seaRun = 0;
if (isTunnel) { tunnelRun++; maxTunnelRun = Math.max(maxTunnelRun, tunnelRun); } else tunnelRun = 0;
}
return { maxSeaRun, maxTunnelRun };
}
function directPath(a, b, options = {}) {
if (!a || !b) return [];
const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y)));
const out = [];
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const x = Math.round(a.x + (b.x - a.x) * t);
const y = Math.round(a.y + (b.y - a.y) * t);
if (!inside(x, y)) return [];
if (!out.length || out[out.length - 1][0] !== x || out[out.length - 1][1] !== y) out.push([x, y]);
}
if (options.maxLength && pathLengthCells(out) > options.maxLength) return [];
if (options.terrain) {
const runs = pathTerrainRuns(out, options.terrain);
if (Number.isFinite(options.maxSeaRun) && runs.maxSeaRun > options.maxSeaRun) return [];
if (Number.isFinite(options.maxTunnelRun) && runs.maxTunnelRun > options.maxTunnelRun) return [];
}
return out;
}
function nearestPointOnPaths(paths, p, maxDistance = Infinity) {
let best = null;
for (const path of paths || []) {
for (const [x, y] of path || []) {
const d = Math.hypot(p.x - x, p.y - y);
if (d <= maxDistance && (!best || d < best.d)) best = { x, y, d };
}
}
return best;
}
function nearestEntity(entities, p, maxDistance = Infinity) {
let best = null;
for (const q of entities || []) {
if (!q || !Number.isFinite(q.x) || !Number.isFinite(q.y) || (q.x === p.x && q.y === p.y)) continue;
const d = Math.hypot(q.x - p.x, q.y - p.y);
if (d <= maxDistance && (!best || d < best.d)) best = { ...q, d };
}
return best;
}
function dedupePaths(paths, sampleStep = 2) {
const seen = new Set();
const kept = [];
for (const path of paths || []) {
if (!path || path.length < 2) continue;
const cleaned = [];
for (const pt of path) {
const x = Math.round(pt[0]);
const y = Math.round(pt[1]);
if (!inside(x, y)) continue;
if (!cleaned.length || cleaned[cleaned.length - 1][0] !== x || cleaned[cleaned.length - 1][1] !== y) cleaned.push([x, y]);
}
if (cleaned.length < 2) continue;
const sigFor = (arr) => arr.map((p, i) => (i % sampleStep === 0 || i === arr.length - 1) ? `${p[0]},${p[1]}` : "").filter(Boolean).join("|");
const f = sigFor(cleaned);
const r = sigFor([...cleaned].reverse());
const sig = f < r ? f : r;
if (seen.has(sig)) continue;
seen.add(sig);
kept.push(cleaned);
}
return kept;
}
function addInterchange(interchanges, x, y, source = "post-admin-expressway-endpoint") {
x = Math.round(x); y = Math.round(y);
if (!inside(x, y)) return false;
if ((interchanges || []).some((p) => Math.hypot(p.x - x, p.y - y) <= 2.5)) return false;
interchanges.push({ x, y, kind: "Interchange", score: 1, source });
return true;
}
function smoothPath(path, passes = 1) {
let cur = (path || []).map(([x, y]) => [Math.round(x), Math.round(y)]);
for (let pass = 0; pass < passes; pass++) {
if (cur.length < 3) break;
const next = [cur[0]];
for (let i = 1; i < cur.length - 1; i++) {
const [ax, ay] = cur[i - 1];
const [bx, by] = cur[i];
const [cx, cy] = cur[i + 1];
const x = Math.round((ax + bx * 2 + cx) / 4);
const y = Math.round((ay + by * 2 + cy) / 4);
if (!next.length || next[next.length - 1][0] !== x || next[next.length - 1][1] !== y) next.push([x, y]);
}
next.push(cur[cur.length - 1]);
cur = next;
}
return cur;
}
function rebuildInfluence(paths, radius = 5) {
const field = new Float32Array(SIZE);
const r = Math.ceil(radius);
for (const path of paths || []) {
for (const [px, py] of path || []) {
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
const x = px + dx, y = py + dy;
if (!inside(x, y)) continue;
const d = Math.hypot(dx, dy);
if (d > radius) continue;
const i = indexOf(x, y);
field[i] = Math.max(field[i], Math.max(0, 1 - d / Math.max(0.001, radius)));
}
}
}
return field;
}
export function finalizeAdminAwareTransport({ seed, terrain, features, admin, geography = null }) {
if (!features || !admin) return features;
const minorRoads = features.minorRoads || [];
const nationalRoads = features.nationalRoads || [];
const externalRoads = features.externalRoads || [];
const expressways = features.expressways || [];
const externalExpressways = features.externalExpressways || [];
const interchanges = features.interchanges || [];
const adminCenters = admin.adminCentersRaw || features.adminCenters || [];
const townsForNational = [
...(features.modernCities || []).filter((p) => (p.population || 0) >= 5000),
...(features.markets || []).filter((p) => (p.population || 0) >= 5000),
...(features.villages || []).filter((p) => (p.population || 0) >= 5000),
...(features.ports || []).filter((p) => (p.population || 0) >= 5000 || p.portClass === "major" || p.portClass === "regional" || p.portClass === "fishing"),
];
const debug = { order: "settlements -> administration -> transport", adminCentersChecked: 0, adminLocalRoadsAdded: 0, nationalTownSpursAdded: 0, nationalTownChainsAdded: 0, nationalTownChainTownsCovered: 0, expresswayEndpointInterchangesAdded: 0, expresswaysSmoothed: 0 };
// Local roads after admin: every municipal office cell should lie on a road.
const settlementTargets = [...(features.modernCities || []), ...(features.markets || []), ...(features.villages || []), ...(features.ports || [])];
for (const center of adminCenters || []) {
if (!center || !inside(center.x, center.y)) continue;
debug.adminCentersChecked++;
const roadSet = [...minorRoads, ...nationalRoads, ...externalRoads];
if (anyPathTouches(roadSet, center, 0.65)) continue;
const nearRoad = nearestPointOnPaths(roadSet, center, 22);
const nearSettlement = nearestEntity(settlementTargets, center, 18);
const target = nearRoad || nearSettlement;
let path = target ? directPath(center, target, { maxLength: 34 }) : [];
if (!path.length) {
const x = center.x, y = center.y;
const a = { x: Math.max(0, x - 2), y };
const b = { x: Math.min(MAP_W - 1, x + 2), y };
path = directPath(a, b, { maxLength: 8 });
}
if (path.length >= 2 && pathTouchesCell(path, center.x, center.y, 0.65)) {
minorRoads.push(path);
debug.adminLocalRoadsAdded++;
}
}
// National roads after admin/settlements: try to cover red-dot towns by chain routes instead of one spur per town.
function concatPaths(parts) {
const out = [];
for (const part of parts || []) {
if (!part || part.length < 2) continue;
for (const pt of part) {
if (!out.length || out[out.length - 1][0] !== pt[0] || out[out.length - 1][1] !== pt[1]) out.push(pt);
}
}
return out;
}
function townWeight(p) {
return (p.population || 0) + (p.isPrefecturalCapital ? 800000 : 0) + (p.isRegionalCapital ? 350000 : 0) + (p.portClass === "major" ? 220000 : p.portClass === "regional" ? 120000 : p.portClass === "fishing" ? 45000 : 0);
}
function nearestTrunkOrHub(p, maxDistance = 85) {
const trunk = nearestPointOnPaths([...nationalRoads, ...externalRoads], p, maxDistance);
if (trunk) return trunk;
return nearestEntity([...(features.modernCities || []), ...(features.ports || []), ...(features.markets || []), ...(features.externalGateways || [])], p, maxDistance);
}
function buildTownChain(start, pool, maxHops = 7) {
const chain = [start];
let cur = start;
for (let hop = 1; hop < maxHops; hop++) {
let best = null;
for (const town of pool) {
if (chain.includes(town)) continue;
const d = Math.hypot(cur.x - town.x, cur.y - town.y);
if (d > 42) continue;
const score = d - Math.min(18, Math.sqrt(Math.max(0, townWeight(town))) / 70);
if (!best || score < best.score) best = { town, d, score };
}
if (!best) break;
chain.push(best.town);
cur = best.town;
}
return chain;
}
function addNationalTownChains() {
let uncovered = townsForNational
.filter((town) => town && inside(town.x, town.y) && !anyPathTouches([...nationalRoads, ...externalRoads], town, 0.65))
.sort((a, b) => townWeight(b) - townWeight(a));
let chainsAdded = 0;
let townsCovered = 0;
while (uncovered.length) {
const start = uncovered.shift();
const chain = buildTownChain(start, uncovered, 7);
uncovered = uncovered.filter((town) => !chain.includes(town));
const parts = [];
const before = nearestTrunkOrHub(chain[0], 80);
if (before) {
const p = directPath(before, chain[0], { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 0 });
if (p.length) parts.push(p);
}
for (let i = 1; i < chain.length; i++) {
const d = Math.hypot(chain[i - 1].x - chain[i].x, chain[i - 1].y - chain[i].y);
const p = directPath(chain[i - 1], chain[i], { maxLength: Math.max(28, d * 1.35 + 8), terrain, maxSeaRun: 10, maxTunnelRun: 0 });
if (p.length) parts.push(p);
}
const after = chain.length >= 2 ? nearestTrunkOrHub(chain[chain.length - 1], 80) : null;
if (after) {
const p = directPath(chain[chain.length - 1], after, { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 0 });
if (p.length) parts.push(p);
}
let path = concatPaths(parts);
if (path.length < 2) {
const target = nearestTrunkOrHub(chain[0], 90);
path = target ? directPath(chain[0], target, { maxLength: 100, terrain, maxSeaRun: 10, maxTunnelRun: 0 }) : [];
}
if (path.length >= 2 && chain.some((town) => pathTouchesCell(path, town.x, town.y, 0.65))) {
nationalRoads.push(path);
chainsAdded++;
townsCovered += chain.filter((town) => pathTouchesCell(path, town.x, town.y, 0.65)).length;
}
}
return { chainsAdded, townsCovered };
}
const chainDebug = addNationalTownChains();
debug.nationalTownChainsAdded = chainDebug.chainsAdded;
debug.nationalTownChainTownsCovered = chainDebug.townsCovered;
debug.nationalTownSpursAdded = chainDebug.chainsAdded;
// Expressway finalization after administration: smooth and ensure both endpoints are ICs.
for (let i = 0; i < expressways.length; i++) {
const smoothed = smoothPath(expressways[i], 2);
if (smoothed.length >= 2) {
expressways[i] = smoothed;
debug.expresswaysSmoothed++;
}
}
for (const path of [...expressways, ...externalExpressways]) {
if (!path || path.length < 2) continue;
const a = path[0];
const b = path[path.length - 1];
if (addInterchange(interchanges, a[0], a[1])) debug.expresswayEndpointInterchangesAdded++;
if (addInterchange(interchanges, b[0], b[1])) debug.expresswayEndpointInterchangesAdded++;
}
features.minorRoads = dedupePaths(minorRoads, 2);
features.nationalRoads = dedupePaths(nationalRoads, 1);
features.externalRoads = dedupePaths(externalRoads, 1);
features.expressways = dedupePaths(expressways, 2);
features.externalExpressways = dedupePaths(externalExpressways, 2);
features.interchanges = interchanges;
// Final invariant: after all dedupe passes, every municipal office cell has at least a local road cell on it.
let finalAdminStubsAdded = 0;
for (const center of adminCenters || []) {
if (!center || !inside(center.x, center.y)) continue;
if (anyPathTouches([...features.minorRoads, ...features.nationalRoads, ...features.externalRoads], center, 0.65)) continue;
const x = Math.round(center.x), y = Math.round(center.y);
const candidates = [
[{ x: Math.max(0, x - 1), y }, { x, y }, { x: Math.min(MAP_W - 1, x + 1), y }],
[{ x, y: Math.max(0, y - 1) }, { x, y }, { x, y: Math.min(MAP_H - 1, y + 1) }],
];
const stub = candidates
.map((cand) => cand.map((p) => [p.x, p.y]).filter(([px, py], idx, arr) => idx === 0 || px !== arr[idx - 1][0] || py !== arr[idx - 1][1]))
.find((p) => p.length >= 2) || [[x, y], [Math.min(MAP_W - 1, x + 1), y]];
features.minorRoads.push(stub);
finalAdminStubsAdded++;
}
debug.finalAdminStubsAdded = finalAdminStubsAdded;
features.roadInfluence = rebuildInfluence([...features.nationalRoads, ...(features.ringRoads || []), ...features.externalRoads, ...features.minorRoads], 5.0);
features.roadDensityInfluence = rebuildInfluence([...features.nationalRoads, ...(features.ringRoads || []), ...features.externalRoads, ...features.minorRoads], 9.0);
features.transportDebug = {
...(features.transportDebug || {}),
generationOrder: debug.order,
postAdminTransportFinalization: debug,
};
return features;
}

863
mapPrefectureStage.js Normal file
View file

@ -0,0 +1,863 @@
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, xyOf } from "./mapUtils.js";
export function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity, settlementFeatures = [], geography = {}) {
const nodes = new Map();
const edges = new Map();
for (let i = 0; i < SIZE; i++) {
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
const id = adminId[i];
if (!nodes.has(id)) nodes.set(id, { id, area: 0, population: 0, cityPopulation: 0, settlementPopulation: 0, majorCityCount: 0, sx: 0, sy: 0, touchesOutside: false, habitability: 0, accessibility: 0, centrality: 0, boundaryAvoidance: 0, adminBoundaryPreference: 0, geographicBarrier: 0 });
const node = nodes.get(id);
const [x, y] = xyOf(i);
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) node.touchesOutside = true;
for (const [ox, oy] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) {
if (!inside(ox, oy)) { node.touchesOutside = true; continue; }
const oi = indexOf(ox, oy);
if (!prefectureMask[oi] || sea[oi]) node.touchesOutside = true;
}
node.area++;
node.population += populationDensity?.[i] || 0;
node.habitability += geography.habitability?.[i] || 0;
node.accessibility += geography.accessibility?.[i] || 0;
node.centrality += geography.centrality?.[i] || 0;
node.boundaryAvoidance += geography.boundaryAvoidance?.[i] || 0;
node.adminBoundaryPreference += geography.adminBoundaryPreference?.[i] || 0;
node.geographicBarrier += geography.geographicBarrier?.[i] || 0;
node.sx += x;
node.sy += y;
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni] || adminId[ni] < 0 || adminId[ni] === id) continue;
const a = Math.min(id, adminId[ni]);
const b = Math.max(id, adminId[ni]);
const key = `${a}:${b}`;
const edge = edges.get(key) || { a, b, count: 0, barrier: 0, adminBoundaryPreference: 0, boundaryAvoidance: 0, centrality: 0, accessibility: 0 };
edge.count++;
edge.barrier += ((naturalBarrierScore?.[i] || 0) + (naturalBarrierScore?.[ni] || 0)) * 0.5;
edge.adminBoundaryPreference += ((geography.adminBoundaryPreference?.[i] || 0) + (geography.adminBoundaryPreference?.[ni] || 0)) * 0.5;
edge.boundaryAvoidance += ((geography.boundaryAvoidance?.[i] || 0) + (geography.boundaryAvoidance?.[ni] || 0)) * 0.5;
edge.centrality += ((geography.centrality?.[i] || 0) + (geography.centrality?.[ni] || 0)) * 0.5;
edge.accessibility += ((geography.accessibility?.[i] || 0) + (geography.accessibility?.[ni] || 0)) * 0.5;
edges.set(key, edge);
}
}
for (const feature of settlementFeatures || []) {
if (!feature || !inside(feature.x, feature.y)) continue;
const i = indexOf(feature.x, feature.y);
if (!prefectureMask[i] || sea[i]) continue;
const id = adminId[i];
const node = nodes.get(id);
if (!node) continue;
const pop = Math.max(0, feature.population || 0);
node.settlementPopulation += pop;
if (feature.kind === "Regional Capital" || feature.kind === "Prefectural Capital" || feature.kind === "Regional City" || feature.kind === "Local City" || feature.isRegionalCapital || feature.isPrefecturalCapital) {
node.cityPopulation += pop;
node.majorCityCount += pop >= 120000 ? 1 : 0;
}
node.population += pop / 16000;
}
for (const node of nodes.values()) {
node.x = node.sx / Math.max(1, node.area);
node.y = node.sy / Math.max(1, node.area);
node.habitability /= Math.max(1, node.area);
node.accessibility /= Math.max(1, node.area);
node.centrality /= Math.max(1, node.area);
node.boundaryAvoidance /= Math.max(1, node.area);
node.adminBoundaryPreference /= Math.max(1, node.area);
node.geographicBarrier /= Math.max(1, node.area);
node.adjacent = new Map();
}
for (const edge of edges.values()) {
edge.barrier /= Math.max(1, edge.count);
edge.adminBoundaryPreference /= Math.max(1, edge.count);
edge.boundaryAvoidance /= Math.max(1, edge.count);
edge.centrality /= Math.max(1, edge.count);
edge.accessibility /= Math.max(1, edge.count);
// Prefecture grouping should pay the same natural-compartment crossing
// cost that municipality generation uses: ridges, rivers, valley walls and
// other strong natural dividers should be expensive to cross. Short shared
// boundaries are also unstable, so they get a small extra penalty.
edge.crossingCost = Math.max(0.22,
1.0 +
edge.barrier * 7.2 +
edge.adminBoundaryPreference * 6.4 -
edge.boundaryAvoidance * 2.6 -
edge.centrality * 1.4 -
edge.accessibility * 0.9 +
2.6 / Math.sqrt(Math.max(1, edge.count))
);
nodes.get(edge.a)?.adjacent.set(edge.b, edge);
nodes.get(edge.b)?.adjacent.set(edge.a, edge);
}
return { nodes, edges };
}
export function choosePrefectureMunicipalitySeeds(nodes, seed) {
const active = [...nodes.values()].filter((node) => node.area > 0).sort((a, b) => b.area - a.area || a.id - b.id);
const totalArea = active.reduce((sum, node) => sum + node.area, 0);
// Real Japan's smallest prefecture by municipality count is roughly Toyama's 15.
// Keep generated prefectures near that scale by limiting prefecture count unless
// enough municipalities exist to give each prefecture a meaningful set.
const minMunicipalitiesPerPrefecture = 14;
const maxByMunicipalityCount = Math.max(3, Math.floor(active.length / minMunicipalitiesPerPrefecture));
const areaBased = clamp(Math.round(totalArea / 7800), 3, 6);
const targetCount = clamp(Math.min(areaBased, maxByMunicipalityCount || areaBased), 3, 6);
const seeds = [];
const minSpacing = Math.max(18, Math.sqrt(totalArea / Math.max(1, targetCount)) * 0.46);
function tryAdd(node, relaxed = false) {
if (!node || seeds.includes(node) || seeds.length >= targetCount) return false;
const nearest = seeds.length ? Math.min(...seeds.map((s) => Math.hypot(node.x - s.x, node.y - s.y))) : INF;
if (!relaxed && nearest < minSpacing) return false;
seeds.push(node);
return true;
}
const capitalLike = active
.filter((node) => (node.cityPopulation || 0) >= 90000 || node.majorCityCount > 0 || (node.settlementPopulation || 0) >= 140000)
.sort((a, b) => ((b.cityPopulation || 0) + (b.settlementPopulation || 0) * 0.35) - ((a.cityPopulation || 0) + (a.settlementPopulation || 0) * 0.35) || b.population - a.population || a.id - b.id);
// Prefectures should grow outward from municipalities that already look like
// future prefectural capitals. This keeps the generated prefecture shape from
// starting at arbitrary peripheral municipalities.
for (const node of capitalLike) tryAdd(node, false);
for (const node of capitalLike) tryAdd(node, true);
if (!seeds.length) {
const first = active.slice().sort((a, b) => (b.population * 1.8 + b.area * 0.08) - (a.population * 1.8 + a.area * 0.08) || a.id - b.id)[0];
if (first) seeds.push(first);
}
while (seeds.length < targetCount) {
let best = null, bestScore = -INF;
for (const node of active) {
if (seeds.includes(node)) continue;
const nearest = Math.min(...seeds.map((s) => Math.hypot(node.x - s.x, node.y - s.y)));
const capitalBonus = Math.sqrt(Math.max(0, node.cityPopulation || 0)) * 0.05 + (node.majorCityCount || 0) * 8;
const livingCoreBonus = (node.centrality || 0) * 9.5 + (node.accessibility || 0) * 4.0 + (node.habitability || 0) * 2.0 - (node.geographicBarrier || 0) * 3.8;
const score = nearest * (1.0 + hash2(seed, node.id) * 0.08) + Math.sqrt(node.area) * 0.12 + node.population * 0.24 + capitalBonus + livingCoreBonus;
if (score > bestScore) { bestScore = score; best = node; }
}
if (!best) break;
seeds.push(best);
}
seeds.minMunicipalitiesPerPrefecture = minMunicipalitiesPerPrefecture;
return seeds;
}
export function chooseSecondStagePrefectureMunicipalitySeeds(nodes, initialOwner, initialSeeds, context = {}) {
const { seed = 0, allowOffscreenCapitals = true } = context;
const prefIds = [...new Set(initialOwner.values())].sort((a, b) => a - b);
const seeds = [];
const usedNodeIds = new Set();
const offscreenPrefectureSeeds = [];
function scoreNode(node, prefId) {
if (!node) return -INF;
const populationCore = (node.cityPopulation || 0) * 1.55 + (node.settlementPopulation || 0) * 0.42 + (node.population || 0) * 900;
const civicCore = (node.majorCityCount || 0) * 420000 + (node.centrality || 0) * 76000 + (node.accessibility || 0) * 52000 + (node.habitability || 0) * 18000;
const terrainPenalty = (node.geographicBarrier || 0) * 62000;
const edgeBonus = allowOffscreenCapitals && node.touchesOutside ? 95000 : 0;
const jitter = hash2(seed + 331, node.id * 17 + prefId * 41) * 2500;
return populationCore + civicCore + edgeBonus - terrainPenalty + jitter;
}
for (const prefId of prefIds) {
const members = [...nodes.values()].filter((node) => initialOwner.get(node.id) === prefId);
if (!members.length) continue;
const insideCapitalCandidate = members
.filter((node) => (node.cityPopulation || 0) >= 45000 || (node.settlementPopulation || 0) >= 70000 || (node.majorCityCount || 0) > 0)
.sort((a, b) => scoreNode(b, prefId) - scoreNode(a, prefId))[0] || null;
const edgeCandidate = allowOffscreenCapitals
? members
.filter((node) => node.touchesOutside && node.area >= 8)
.sort((a, b) => scoreNode(b, prefId) - scoreNode(a, prefId))[0] || null
: null;
const useOffscreen = edgeCandidate && (!insideCapitalCandidate || scoreNode(edgeCandidate, prefId) > scoreNode(insideCapitalCandidate, prefId) + 35000);
const chosen = useOffscreen ? edgeCandidate : (insideCapitalCandidate || edgeCandidate || members.sort((a, b) => scoreNode(b, prefId) - scoreNode(a, prefId))[0]);
if (chosen && !usedNodeIds.has(chosen.id)) {
seeds.push(chosen);
usedNodeIds.add(chosen.id);
if (useOffscreen) offscreenPrefectureSeeds.push({ prefId, nodeId: chosen.id, x: Math.round(chosen.x), y: Math.round(chosen.y) });
}
}
if (!seeds.length) seeds.push(...(initialSeeds || []).filter(Boolean));
seeds.minMunicipalitiesPerPrefecture = initialSeeds?.minMunicipalitiesPerPrefecture || 14;
seeds.offscreenPrefectureSeeds = offscreenPrefectureSeeds;
seeds.secondStage = true;
return seeds;
}
export function assignMunicipalitiesToPrefectures(nodes, seeds) {
const owner = new Map();
const area = new Map();
const heap = new MinHeap();
seeds.forEach((node, id) => {
owner.set(node.id, id);
area.set(id, node.area);
heap.push({ i: node.id, id, f: 0 });
});
const totalArea = [...nodes.values()].reduce((sum, node) => sum + node.area, 0);
const maxArea = Math.max(900, totalArea * 0.30);
while (heap.length) {
const cur = heap.pop();
if (!cur || owner.get(cur.i) !== cur.id) continue;
const node = nodes.get(cur.i);
if (!node) continue;
for (const [nextId, edge] of node.adjacent) {
if (owner.has(nextId)) continue;
const next = nodes.get(nextId);
if (!next) continue;
const areaPressure = Math.max(0, ((area.get(cur.id) || 0) + next.area - maxArea) / Math.max(1, maxArea));
const cost = cur.f + (edge.crossingCost ?? (1.0 + edge.barrier * 8.5)) + areaPressure * 14 + hash2(cur.id, nextId) * 0.05;
owner.set(nextId, cur.id);
area.set(cur.id, (area.get(cur.id) || 0) + next.area);
heap.push({ i: nextId, id: cur.id, f: cost });
}
}
let fallback = 0;
for (const id of [...nodes.keys()].sort((a, b) => a - b)) {
if (!owner.has(id)) owner.set(id, fallback++ % Math.max(1, seeds.length));
}
return owner;
}
export function chooseSecondStagePrefectureSeeds(nodes, firstOwner, originalSeeds = [], seed = 0) {
const prefIds = [...new Set(firstOwner.values())].filter((id) => id >= 0).sort((a, b) => a - b);
const used = new Set();
const seeds = [];
const offscreenCapitalPrefectures = [];
for (const prefId of prefIds) {
const members = [...nodes.values()].filter((node) => firstOwner.get(node.id) === prefId && node.area > 0);
if (!members.length) continue;
const membersSortedByCapital = members.slice().sort((a, b) => {
const as = (a.cityPopulation || 0) * 1.45 + (a.settlementPopulation || 0) * 0.36 + (a.population || 0) * 900 + (a.centrality || 0) * 5200 + (a.accessibility || 0) * 2600 + Math.sqrt(a.area || 1) * 45;
const bs = (b.cityPopulation || 0) * 1.45 + (b.settlementPopulation || 0) * 0.36 + (b.population || 0) * 900 + (b.centrality || 0) * 5200 + (b.accessibility || 0) * 2600 + Math.sqrt(b.area || 1) * 45;
return bs - as || a.id - b.id;
});
const capitalCandidate = membersSortedByCapital.find((node) => !used.has(node.id) && ((node.cityPopulation || 0) >= 90000 || (node.settlementPopulation || 0) >= 130000 || (node.population || 0) >= 15));
let chosen = capitalCandidate;
if (!chosen) {
const edgeCandidate = members
.filter((node) => !used.has(node.id) && node.touchesOutside)
.sort((a, b) => {
const as = Math.sqrt(a.area || 1) * 0.7 + (a.habitability || 0) * 8 + (a.accessibility || 0) * 6 - (a.geographicBarrier || 0) * 4 + hash2(seed + 7100, a.id) * 0.2;
const bs = Math.sqrt(b.area || 1) * 0.7 + (b.habitability || 0) * 8 + (b.accessibility || 0) * 6 - (b.geographicBarrier || 0) * 4 + hash2(seed + 7100, b.id) * 0.2;
return bs - as || a.id - b.id;
})[0];
if (edgeCandidate) {
chosen = edgeCandidate;
offscreenCapitalPrefectures.push(prefId);
}
}
if (!chosen) chosen = membersSortedByCapital.find((node) => !used.has(node.id)) || membersSortedByCapital[0];
if (chosen) {
used.add(chosen.id);
seeds.push(chosen);
}
}
// If merges removed too many first-stage regions, preserve count by adding high-score unused capital-like nodes.
for (const node of [...nodes.values()].sort((a, b) => ((b.cityPopulation || 0) + (b.settlementPopulation || 0) * 0.25 + b.area * 5) - ((a.cityPopulation || 0) + (a.settlementPopulation || 0) * 0.25 + a.area * 5) || a.id - b.id)) {
if (seeds.length >= Math.max(1, originalSeeds.length || seeds.length)) break;
if (used.has(node.id)) continue;
used.add(node.id);
seeds.push(node);
}
seeds.minMunicipalitiesPerPrefecture = originalSeeds.minMunicipalitiesPerPrefecture || 14;
seeds.offscreenCapitalPrefectures = offscreenCapitalPrefectures;
return seeds;
}
export function repairPrefectureMunicipalityConnectivity(nodes, owner) {
let changed = 0;
for (let pass = 0; pass < 8; pass++) {
let passChanged = 0;
const prefIds = [...new Set(owner.values())].sort((a, b) => a - b);
for (const prefId of prefIds) {
const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId);
const memberSet = new Set(members);
const seen = new Set();
const components = [];
for (const start of members) {
if (seen.has(start)) continue;
const queue = [start];
const comp = [];
seen.add(start);
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
comp.push(cur);
for (const next of nodes.get(cur)?.adjacent.keys() || []) {
if (!memberSet.has(next) || seen.has(next)) continue;
seen.add(next);
queue.push(next);
}
}
components.push(comp);
}
if (components.length <= 1) continue;
components.sort((a, b) => b.length - a.length);
for (const comp of components.slice(1)) {
const neighborCounts = new Map();
for (const id of comp) {
for (const next of nodes.get(id)?.adjacent.keys() || []) {
const nOwner = owner.get(next);
if (nOwner !== prefId) neighborCounts.set(nOwner, (neighborCounts.get(nOwner) || 0) + 1);
}
}
let best = -1, bestCount = -1;
for (const [id, count] of neighborCounts) if (count > bestCount || (count === bestCount && id < best)) { best = id; bestCount = count; }
if (best < 0) continue;
for (const id of comp) owner.set(id, best);
passChanged += comp.length;
}
}
changed += passChanged;
if (!passChanged) break;
}
return changed;
}
export function repairPrefectureMunicipalityEnclaves(nodes, owner, maxPasses = 6) {
let changed = 0;
for (let pass = 0; pass < maxPasses; pass++) {
let passChanged = 0;
const prefIds = [...new Set(owner.values())].filter((id) => id >= 0).sort((a, b) => a - b);
for (const prefId of prefIds) {
const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId);
const memberSet = new Set(members);
const seen = new Set();
for (const start of members) {
if (seen.has(start)) continue;
const queue = [start];
const comp = [];
seen.add(start);
let touchesOutside = false;
const boundaryPrefs = new Map();
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
comp.push(cur);
const node = nodes.get(cur);
if (node?.touchesOutside) touchesOutside = true;
for (const next of node?.adjacent.keys() || []) {
const nextOwner = owner.get(next);
if (nextOwner === prefId) {
if (!seen.has(next)) { seen.add(next); queue.push(next); }
} else if (nextOwner >= 0) {
boundaryPrefs.set(nextOwner, (boundaryPrefs.get(nextOwner) || 0) + 1);
}
}
}
if (touchesOutside || boundaryPrefs.size !== 1) continue;
const [targetPref] = boundaryPrefs.keys();
if (targetPref < 0 || targetPref === prefId) continue;
for (const id of comp) owner.set(id, targetPref);
passChanged += comp.length;
}
}
changed += passChanged;
if (!passChanged) break;
}
return changed;
}
export function lockCompactUrbanMunicipalitiesToSinglePrefecture(owner, adminId, context, maxCells = 2600) {
const { prefectureMask, sea, landuse, populationDensity } = context;
if (!owner || !adminId || !landuse) return 0;
const seen = new Uint8Array(SIZE);
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
const isUrban = (i) => {
const lu = landuse[i];
return lu === 2 || lu === 3 || lu === 4 || lu === 7 || lu === 8 || (populationDensity?.[i] || 0) > 0.18;
};
let changedMunicipalities = 0;
for (let start = 0; start < SIZE; start++) {
if (seen[start] || !prefectureMask[start] || sea[start] || adminId[start] < 0 || !isUrban(start)) continue;
const queue = [start];
const comp = [];
seen[start] = 1;
const municipalityWeights = new Map();
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
comp.push(cur);
const id = adminId[cur];
const weight = 1 + Math.max(0, (populationDensity?.[cur] || 0) - 0.12) * 2.4 + (landuse[cur] === 3 ? 2.0 : landuse[cur] === 2 ? 1.2 : 0);
municipalityWeights.set(id, (municipalityWeights.get(id) || 0) + weight);
const [x, y] = xyOf(cur);
for (const [dx, dy] of dirs) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (seen[ni] || !prefectureMask[ni] || sea[ni] || adminId[ni] < 0 || !isUrban(ni)) continue;
seen[ni] = 1;
queue.push(ni);
}
}
if (comp.length < 14 || comp.length > maxCells || municipalityWeights.size <= 1) continue;
const prefectureWeights = new Map();
for (const [munId, weight] of municipalityWeights) {
const prefId = owner.get(munId);
if (prefId < 0) continue;
prefectureWeights.set(prefId, (prefectureWeights.get(prefId) || 0) + weight);
}
if (prefectureWeights.size <= 1) continue;
let bestPref = -1, bestWeight = -INF, totalWeight = 0;
for (const [prefId, weight] of prefectureWeights) {
totalWeight += weight;
if (weight > bestWeight || (weight === bestWeight && prefId < bestPref)) { bestPref = prefId; bestWeight = weight; }
}
if (bestPref < 0 || bestWeight / Math.max(1, totalWeight) < 0.34) continue;
for (const munId of municipalityWeights.keys()) {
if (owner.get(munId) === bestPref) continue;
owner.set(munId, bestPref);
changedMunicipalities++;
}
}
return changedMunicipalities;
}
export function lockCityMetroMunicipalitiesToSinglePrefecture(owner, adminId, context) {
const { prefectureMask, sea, landuse, populationDensity, modernCities = [] } = context;
if (!owner || !adminId || !modernCities?.length) return 0;
let changed = 0;
const isUrban = (i) => {
const lu = landuse?.[i];
return lu === 2 || lu === 3 || lu === 4 || lu === 7 || lu === 8 || (populationDensity?.[i] || 0) > 0.14;
};
for (const city of modernCities) {
if (!city || (city.population || 0) < 24000 || !inside(city.x, city.y)) continue;
const centerAdmin = adminId[indexOf(city.x, city.y)];
if (centerAdmin < 0) continue;
const centerPref = owner.get(centerAdmin);
if (centerPref < 0) continue;
const radius = clamp(Math.round((city.urbanRadius || 8) * ((city.population || 0) >= 160000 ? 1.55 : 1.25)), 7, 26);
const municipalityWeights = new Map();
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const x = city.x + dx;
const y = city.y + dy;
if (!inside(x, y) || Math.hypot(dx, dy) > radius) continue;
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i] || adminId[i] < 0 || !isUrban(i)) continue;
const dist = Math.hypot(dx, dy) / Math.max(1, radius);
const weight = (1 - dist * 0.55) * (1 + (populationDensity?.[i] || 0) * 2.6 + (landuse?.[i] === 3 ? 1.6 : 0));
municipalityWeights.set(adminId[i], (municipalityWeights.get(adminId[i]) || 0) + weight);
}
}
if (municipalityWeights.size <= 1) continue;
let total = 0, centerOwnedWeight = 0;
for (const [munId, weight] of municipalityWeights) {
total += weight;
if (owner.get(munId) === centerPref) centerOwnedWeight += weight;
}
// Only force compact city regions. If the center prefecture has almost no
// share, this is probably a genuine cross-prefecture conurbation or a city
// center on the edge; leave it to the graph repair.
if (centerOwnedWeight / Math.max(1, total) < 0.24) continue;
for (const munId of municipalityWeights.keys()) {
if (owner.get(munId) === centerPref) continue;
owner.set(munId, centerPref);
changed++;
}
}
return changed;
}
export function lockLivingSphereMunicipalitiesToSinglePrefecture(owner, nodes, adminId, context) {
const { prefectureMask, sea, modernCities = [], markets = [], ports = [] } = context || {};
if (!owner || !nodes || !adminId) return 0;
const hubs = [
...(modernCities || []).filter((p) => p && ((p.population || 0) >= 70000 || p.isPrefecturalCapital || p.isRegionalCapital)),
...(markets || []).filter((p) => p && (p.population || 0) >= 26000),
...(ports || []).filter((p) => p && (p.portClass === "major" || p.portClass === "regional")),
];
let changed = 0;
for (const hub of hubs) {
if (!hub || !inside(hub.x, hub.y)) continue;
const startCell = indexOf(hub.x, hub.y);
if (!prefectureMask[startCell] || sea[startCell]) continue;
const startAdmin = adminId[startCell];
if (startAdmin < 0 || !nodes.has(startAdmin)) continue;
const targetPref = owner.get(startAdmin);
if (targetPref < 0) continue;
const population = hub.population || (hub.portClass === "major" ? 140000 : 42000);
const maxCost = (population >= 260000 || hub.isPrefecturalCapital) ? 34 : population >= 120000 ? 25 : 17;
const maxDistance = clamp(10 + Math.sqrt(population) / 42, 14, 38);
const heap = new MinHeap();
const best = new Map([[startAdmin, 0]]);
heap.push({ i: startAdmin, f: 0 });
const candidates = new Set([startAdmin]);
while (heap.length) {
const cur = heap.pop();
if (!cur || cur.f > (best.get(cur.i) ?? INF) + 1e-5 || cur.f > maxCost) continue;
const node = nodes.get(cur.i);
if (!node) continue;
if (Math.hypot(node.x - hub.x, node.y - hub.y) <= maxDistance) candidates.add(cur.i);
for (const [nextId, edge] of node.adjacent || []) {
const next = nodes.get(nextId);
if (!next) continue;
if (Math.hypot(next.x - hub.x, next.y - hub.y) > maxDistance * 1.25) continue;
if ((edge.barrier || 0) > 0.68 && (edge.adminBoundaryPreference || 0) > 0.44) continue;
const lifeContinuity = (edge.boundaryAvoidance || 0) * 1.7 + (edge.accessibility || 0) * 0.9 + (edge.centrality || 0) * 0.9;
const stepCost = Math.max(0.35, (edge.crossingCost ?? 1.5) - lifeContinuity);
const nd = cur.f + stepCost;
if (nd < (best.get(nextId) ?? INF)) {
best.set(nextId, nd);
heap.push({ i: nextId, f: nd });
}
}
}
if (candidates.size <= 1) continue;
let totalWeight = 0;
for (const id of candidates) {
const node = nodes.get(id);
totalWeight += Math.max(1, (node?.centrality || 0) * 8 + (node?.accessibility || 0) * 5 + Math.sqrt(node?.area || 1) * 0.18);
}
if (totalWeight < 5.5) continue;
for (const id of candidates) {
if (id === startAdmin || owner.get(id) === targetPref) continue;
const node = nodes.get(id);
if (!node) continue;
if ((node.cityPopulation || 0) >= 160000 && Math.hypot(node.x - hub.x, node.y - hub.y) > 8) continue;
owner.set(id, targetPref);
changed++;
}
}
if (changed) repairPrefectureMunicipalityConnectivity(nodes, owner);
return changed;
}
export function mergeTinyMunicipalityPrefectures(nodes, owner) {
let changed = 0;
for (let pass = 0; pass < 6; pass++) {
const areaByPref = new Map();
for (const node of nodes.values()) {
const pref = owner.get(node.id);
areaByPref.set(pref, (areaByPref.get(pref) || 0) + node.area);
}
const totalArea = [...areaByPref.values()].reduce((sum, value) => sum + value, 0);
const minArea = Math.max(520, totalArea / Math.max(1, areaByPref.size) * 0.34);
const tiny = [...areaByPref.entries()]
.filter(([, area]) => area > 0 && area < minArea && areaByPref.size > 4)
.sort((a, b) => a[1] - b[1] || a[0] - b[0])[0];
if (!tiny) break;
const [tinyPref] = tiny;
const neighborScores = new Map();
for (const node of nodes.values()) {
if (owner.get(node.id) !== tinyPref) continue;
for (const [nextId, edge] of node.adjacent) {
const nextPref = owner.get(nextId);
if (nextPref === tinyPref || nextPref < 0) continue;
const score = (neighborScores.get(nextPref) || 0) + edge.count * 0.8 - (edge.crossingCost ?? (1.0 + edge.barrier * 8.5)) * 0.65;
neighborScores.set(nextPref, score);
}
}
let best = -1, bestScore = -INF;
for (const [pref, score] of neighborScores) {
if (score > bestScore || (score === bestScore && pref < best)) { best = pref; bestScore = score; }
}
if (best < 0) break;
for (const node of nodes.values()) if (owner.get(node.id) === tinyPref) { owner.set(node.id, best); changed++; }
}
return changed;
}
export function extractPrefectureBordersFromMunicipalities(adminId, municipalityToPrefectureId, prefectureMask, sea) {
const segments = [];
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
const aPref = municipalityToPrefectureId[adminId[i]] ?? -1;
if (x + 1 < MAP_W) {
const ni = indexOf(x + 1, y);
const bPref = !sea[ni] && adminId[ni] >= 0 ? municipalityToPrefectureId[adminId[ni]] ?? -1 : -1;
if (prefectureMask[ni] && bPref >= 0 && aPref >= 0 && bPref !== aPref && adminId[ni] !== adminId[i]) segments.push([[x + 1, y], [x + 1, y + 1]]);
}
if (y + 1 < MAP_H) {
const ni = indexOf(x, y + 1);
const bPref = !sea[ni] && adminId[ni] >= 0 ? municipalityToPrefectureId[adminId[ni]] ?? -1 : -1;
if (prefectureMask[ni] && bPref >= 0 && aPref >= 0 && bPref !== aPref && adminId[ni] !== adminId[i]) segments.push([[x, y + 1], [x + 1, y + 1]]);
}
}
}
return segments;
}
export function prefectureMunicipalityCounts(owner) {
const counts = new Map();
for (const pref of owner.values()) counts.set(pref, (counts.get(pref) || 0) + 1);
return counts;
}
export function ownerMembersByPref(owner) {
const by = new Map();
for (const [id, pref] of owner) {
if (!by.has(pref)) by.set(pref, []);
by.get(pref).push(id);
}
return by;
}
export function wouldRemainConnectedAfterRemoval(nodes, owner, adminId, prefId) {
const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId && id !== adminId);
if (members.length <= 1) return true;
const memberSet = new Set(members);
const seen = new Set([members[0]]);
const queue = [members[0]];
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
for (const next of nodes.get(cur)?.adjacent.keys() || []) {
if (!memberSet.has(next) || seen.has(next)) continue;
seen.add(next);
queue.push(next);
}
}
return seen.size === members.length;
}
export function rebalanceSmallPrefecturesByMunicipalityCount(nodes, owner, minCount = 14, maxPasses = 96) {
let changed = 0;
for (let pass = 0; pass < maxPasses; pass++) {
const counts = prefectureMunicipalityCounts(owner);
const small = [...counts.entries()].filter(([, count]) => count > 0 && count < minCount).sort((a, b) => a[1] - b[1] || a[0] - b[0])[0];
if (!small) break;
const [smallPref, smallCount] = small;
let best = null, bestScore = -INF;
for (const [id, pref] of owner) {
if (pref === smallPref) continue;
const donorCount = counts.get(pref) || 0;
if (donorCount <= minCount + 1) continue;
const node = nodes.get(id);
if (!node) continue;
let edgeToSmall = null;
for (const [nextId, edge] of node.adjacent || []) {
if (owner.get(nextId) === smallPref) {
edgeToSmall = edge;
break;
}
}
if (!edgeToSmall) continue;
if (!wouldRemainConnectedAfterRemoval(nodes, owner, id, pref)) continue;
const capitalPenalty = (node.cityPopulation || 0) >= 150000 ? 18 : 0;
const donorSurplus = donorCount - minCount;
const score = (edgeToSmall.count || 1) * 2.5 - (edgeToSmall.crossingCost ?? (1.0 + (edgeToSmall.barrier || 0) * 8.5)) * 1.15 + donorSurplus * 1.6 - Math.sqrt(node.area || 1) * 0.03 - capitalPenalty;
if (score > bestScore || (score === bestScore && id < best?.id)) best = { id, pref, score };
}
if (!best) break;
owner.set(best.id, smallPref);
changed++;
repairPrefectureMunicipalityConnectivity(nodes, owner);
}
return changed;
}
export function mergePersistentlyTinyPrefecturesByCount(nodes, owner, minCount = 12, minRemainingPrefectures = 2) {
let changed = 0;
for (let pass = 0; pass < 16; pass++) {
const counts = prefectureMunicipalityCounts(owner);
if (counts.size <= minRemainingPrefectures) break;
const tiny = [...counts.entries()].filter(([, count]) => count > 0 && count < minCount).sort((a, b) => a[1] - b[1] || a[0] - b[0])[0];
if (!tiny) break;
const [tinyPref] = tiny;
const neighborScores = new Map();
for (const [id, pref] of owner) {
if (pref !== tinyPref) continue;
const node = nodes.get(id);
for (const [nextId, edge] of node?.adjacent || []) {
const other = owner.get(nextId);
if (other === undefined || other === tinyPref) continue;
const score = (edge.count || 1) * 2.4 - (edge.crossingCost ?? (1.0 + (edge.barrier || 0) * 8.5)) * 1.0 + Math.min(18, counts.get(other) || 0) * 0.20;
neighborScores.set(other, (neighborScores.get(other) || 0) + score);
}
}
let best = -1, bestScore = -INF;
for (const [candidate, score] of neighborScores) {
if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; }
}
if (best < 0) {
const tinyNodes = [...owner.keys()].filter((id) => owner.get(id) === tinyPref).map((id) => nodes.get(id)).filter(Boolean);
const tx = tinyNodes.reduce((sum, node) => sum + node.x * node.area, 0) / Math.max(1, tinyNodes.reduce((sum, node) => sum + node.area, 0));
const ty = tinyNodes.reduce((sum, node) => sum + node.y * node.area, 0) / Math.max(1, tinyNodes.reduce((sum, node) => sum + node.area, 0));
let bestDist = INF;
for (const [candidate, count] of counts) {
if (candidate === tinyPref || count <= 0) continue;
const candidateNodes = [...owner.keys()].filter((id) => owner.get(id) === candidate).map((id) => nodes.get(id)).filter(Boolean);
for (const node of candidateNodes) {
const d = Math.hypot(node.x - tx, node.y - ty);
if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; }
}
}
}
if (best < 0) break;
for (const [id, pref] of owner) if (pref === tinyPref) { owner.set(id, best); changed++; }
repairPrefectureMunicipalityConnectivity(nodes, owner);
}
// Renumber compactly so labels/debug do not expose deleted prefecture IDs.
const active = [...new Set(owner.values())].sort((a, b) => a - b);
const remap = new Map(active.map((id, n) => [id, n]));
for (const [id, pref] of owner) owner.set(id, remap.get(pref));
return changed;
}
export function splitOversizedPrefecturesByMunicipalityCount(nodes, owner, maxCount = 88, maxPrefectures = 8) {
let changed = 0;
for (let pass = 0; pass < 10; pass++) {
const counts = prefectureMunicipalityCounts(owner);
const oversized = [...counts.entries()].filter(([, count]) => count > maxCount).sort((a, b) => b[1] - a[1] || a[0] - b[0])[0];
if (!oversized || counts.size >= maxPrefectures) break;
const [prefId, count] = oversized;
const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId);
if (members.length <= maxCount) break;
let sx = 0, sy = 0, area = 0;
for (const id of members) {
const node = nodes.get(id);
if (!node) continue;
sx += node.x * Math.max(1, node.area || 1);
sy += node.y * Math.max(1, node.area || 1);
area += Math.max(1, node.area || 1);
}
const cx = sx / Math.max(1, area);
const cy = sy / Math.max(1, area);
const seedNode = members
.map((id) => nodes.get(id))
.filter(Boolean)
.sort((a, b) => Math.hypot(b.x - cx, b.y - cy) - Math.hypot(a.x - cx, a.y - cy) || a.id - b.id)[0];
if (!seedNode) break;
const newPref = Math.max(-1, ...counts.keys()) + 1;
const target = Math.max(count - maxCount, Math.floor(count * 0.42));
const queue = [seedNode.id];
const picked = new Set([seedNode.id]);
for (let q = 0; q < queue.length && picked.size < target; q++) {
const cur = queue[q];
const nexts = [...(nodes.get(cur)?.adjacent.keys() || [])]
.filter((id) => owner.get(id) === prefId && !picked.has(id))
.map((id) => nodes.get(id))
.filter(Boolean)
.sort((a, b) => Math.hypot(b.x - cx, b.y - cy) - Math.hypot(a.x - cx, a.y - cy) || a.id - b.id);
for (const next of nexts) {
picked.add(next.id);
queue.push(next.id);
if (picked.size >= target) break;
}
}
if (picked.size < Math.max(8, target * 0.55)) break;
for (const id of picked) owner.set(id, newPref);
changed += picked.size;
repairPrefectureMunicipalityConnectivity(nodes, owner);
}
return changed;
}
function averageRegionalBorderField(adminId, municipalityToPrefectureId, prefectureMask, sea, field) {
if (!field) return 0;
let sum = 0;
let count = 0;
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
const a = municipalityToPrefectureId[adminId[i]] ?? -1;
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni] || adminId[ni] < 0) continue;
const b = municipalityToPrefectureId[adminId[ni]] ?? -1;
if (a < 0 || b < 0 || a === b) continue;
sum += ((field[i] || 0) + (field[ni] || 0)) * 0.5;
count++;
}
}
}
return count ? sum / count : 0;
}
export function generatePrefecturesFromMunicipalities(context, adminResult) {
const { adminId } = adminResult;
const { prefectureMask, sea, naturalBarrierScore, populationDensity, landuse, modernCities, markets, ports, seed, geography = null, habitability = null, accessibility = null, centrality = null, adminBoundaryPreference = null, boundaryAvoidance = null, geographicBarrier = null } = context;
const geographyFields = geography || { habitability, accessibility, centrality, adminBoundaryPreference, boundaryAvoidance, geographicBarrier };
const graph = buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity, [...(modernCities || []), ...(markets || []), ...(ports || [])], geographyFields);
const firstStageSeeds = choosePrefectureMunicipalitySeeds(graph.nodes, seed + 91001);
const firstStageOwner = assignMunicipalitiesToPrefectures(graph.nodes, firstStageSeeds);
repairPrefectureMunicipalityConnectivity(graph.nodes, firstStageOwner);
repairPrefectureMunicipalityEnclaves(graph.nodes, firstStageOwner, 4);
const secondStageSeeds = chooseSecondStagePrefectureSeeds(graph.nodes, firstStageOwner, firstStageSeeds, seed + 91077);
const seeds = secondStageSeeds.length ? secondStageSeeds : firstStageSeeds;
const owner = assignMunicipalitiesToPrefectures(graph.nodes, seeds);
const changedForTinyMerge = mergeTinyMunicipalityPrefectures(graph.nodes, owner);
let changedForMetroUnification = lockCompactUrbanMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity }, 2600);
changedForMetroUnification += lockCityMetroMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity, modernCities });
const changedForLivingSphereUnification = lockLivingSphereMunicipalitiesToSinglePrefecture(owner, graph.nodes, adminId, { prefectureMask, sea, modernCities, markets, ports });
let changedForConnectivity = repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
let changedForEnclaveRepair = repairPrefectureMunicipalityEnclaves(graph.nodes, owner);
changedForMetroUnification += lockCompactUrbanMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity }, 2600);
changedForMetroUnification += lockCityMetroMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity, modernCities });
const changedForPostRepairLivingSphereUnification = lockLivingSphereMunicipalitiesToSinglePrefecture(owner, graph.nodes, adminId, { prefectureMask, sea, modernCities, markets, ports });
const changedForMunicipalityCountRebalance = rebalanceSmallPrefecturesByMunicipalityCount(graph.nodes, owner, seeds.minMunicipalitiesPerPrefecture || 14);
const changedForTinyPrefectureCountMerge = mergePersistentlyTinyPrefecturesByCount(graph.nodes, owner, Math.max(13, (seeds.minMunicipalitiesPerPrefecture || 14) - 1));
const changedForPostMergeMunicipalityCountRebalance = rebalanceSmallPrefecturesByMunicipalityCount(graph.nodes, owner, Math.max(12, (seeds.minMunicipalitiesPerPrefecture || 14) - 1), 64);
const changedForOversizedPrefectureSplit = splitOversizedPrefecturesByMunicipalityCount(graph.nodes, owner, 88, 8);
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
// Rebalancing and oversized splitting can create small municipality-level
// exclaves. Run enclave/connectivity repair as the final owner operation so
// rendered prefectures are contiguous unions of municipalities.
changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 10);
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 10);
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
const maxAdminId = Math.max(-1, ...[...graph.nodes.keys()]);
const municipalityToPrefectureId = new Int16Array(maxAdminId + 1);
municipalityToPrefectureId.fill(-1);
for (const [admin, pref] of owner) municipalityToPrefectureId[admin] = pref;
const prefectureRegionId = new Int16Array(SIZE);
prefectureRegionId.fill(-1);
for (let i = 0; i < SIZE; i++) {
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
prefectureRegionId[i] = municipalityToPrefectureId[adminId[i]] ?? -1;
}
const regionalPrefectureBorders = extractPrefectureBordersFromMunicipalities(adminId, municipalityToPrefectureId, prefectureMask, sea);
const areaByPref = new Map();
const popByPref = new Map();
for (const node of graph.nodes.values()) {
const pref = owner.get(node.id);
areaByPref.set(pref, (areaByPref.get(pref) || 0) + node.area);
popByPref.set(pref, (popByPref.get(pref) || 0) + node.population);
}
const totalArea = [...areaByPref.values()].reduce((sum, value) => sum + value, 0);
return {
prefectureRegionId,
municipalityToPrefectureId,
regionalPrefectureBorders,
regionalDebug: {
prefecturesGeneratedAfterMunicipalities: true,
prefectureSource: "municipality-boundary-union",
municipalityGraphNodeCount: graph.nodes.size,
municipalityGraphEdgeCount: graph.edges.size,
prefectureMunicipalitySeedCount: seeds.length,
prefectureTwoStageReassignment: true,
prefectureFirstStageSeedCount: firstStageSeeds.length,
prefectureSecondStageSeedCount: secondStageSeeds.length,
prefectureSecondStageOffscreenCapitalSeedCount: secondStageSeeds.offscreenCapitalPrefectures?.length || 0,
prefectureSecondStageOffscreenCapitalPrefectures: secondStageSeeds.offscreenCapitalPrefectures || [],
prefectureTinyMergeChangedMunicipalities: changedForTinyMerge,
prefectureConnectivityRepairChangedMunicipalities: changedForConnectivity,
prefectureEnclaveRepairChangedMunicipalities: changedForEnclaveRepair,
prefectureUrbanMetroUnificationChangedMunicipalities: changedForMetroUnification,
prefectureLivingSphereUnificationChangedMunicipalities: (changedForLivingSphereUnification || 0) + (changedForPostRepairLivingSphereUnification || 0),
prefectureUnifiedGeographyBasis: true,
prefectureMunicipalityCountRebalanceChangedMunicipalities: (changedForMunicipalityCountRebalance || 0) + (changedForPostMergeMunicipalityCountRebalance || 0),
prefectureTinyMunicipalityCountMergeChangedMunicipalities: changedForTinyPrefectureCountMerge || 0,
prefectureOversizedCountSplitChangedMunicipalities: changedForOversizedPrefectureSplit || 0,
finalRegionalMaxMunicipalityCount: Math.max(...prefectureMunicipalityCounts(owner).values()),
finalRegionalMunicipalityCountCap: 88,
finalRegionalMinMunicipalityCount: Math.min(...prefectureMunicipalityCounts(owner).values()),
finalRegionalMaxAreaShare: totalArea ? Math.max(0, ...areaByPref.values()) / totalArea : 0,
finalRegionalTinyPrefectureCount: [...areaByPref.values()].filter((area) => area < 520).length,
finalRegionalPrefectureBorderCount: regionalPrefectureBorders.length,
regionalPrefectureBordersRebuiltFromFinalId: true,
finalRegionalBorderUnifiedBoundaryPreferenceAverage: averageRegionalBorderField(adminId, municipalityToPrefectureId, prefectureMask, sea, geographyFields.adminBoundaryPreference),
finalRegionalBorderUnifiedBoundaryAvoidanceAverage: averageRegionalBorderField(adminId, municipalityToPrefectureId, prefectureMask, sea, geographyFields.boundaryAvoidance),
finalRegionalBorderUnifiedCentralityAverage: averageRegionalBorderField(adminId, municipalityToPrefectureId, prefectureMask, sea, geographyFields.centrality),
},
};
}

View file

@ -213,7 +213,7 @@ const TERRAIN_TYPES = [
coastStyle: "inland_sea", coastStyle: "inland_sea",
mountainMode: "mixed", mountainMode: "mixed",
massifnessRange: [0.34, 0.62], massifnessRange: [0.34, 0.62],
seaRatioRange: [0.20, 0.33], seaRatioRange: [0.28, 0.43],
twoSidedChance: 0.92, twoSidedChance: 0.92,
mountainOffsetRange: [0.22, 0.34], mountainOffsetRange: [0.22, 0.34],
baseHeightRange: [0.46, 0.78], baseHeightRange: [0.46, 0.78],
@ -226,7 +226,7 @@ const TERRAIN_TYPES = [
lengthScale: 1.00, lengthScale: 1.00,
widthScale: 1.18, widthScale: 1.18,
heightScale: 0.82, heightScale: 0.82,
coastStrength: 1.10, coastStrength: 1.34,
plainBiasRange: [0.26, 0.50], plainBiasRange: [0.26, 0.50],
riverRichnessRange: [0.58, 0.96], riverRichnessRange: [0.58, 0.96],
bigRiverChanceRange: [0.18, 0.42], bigRiverChanceRange: [0.18, 0.42],
@ -580,10 +580,12 @@ function computeCoastLower(px, py, template, seed) {
let pressure = 0; let pressure = 0;
if (template.coastStyle === "inland_sea") { if (template.coastStyle === "inland_sea") {
// 瀬戸内型だけは中央を横切る浅い内海を許す。出現率は地形タイプ側で管理する。 // 瀬戸内型は旧来の大きな内海+両岸海岸線に戻す。
const sideA = smoothstep((-axis + 0.25 + wave + bay) / 0.26); // 海面比率はテンプレート側で高めに保ち、微細な島ノイズではなく
const sideB = smoothstep((axis + 0.23 - wave + bay * 0.7) / 0.27); // 連続した水道形状で海を増やす。
const channel = smoothstep((0.060 - Math.abs(cross + wave * 0.65 + islandNoise)) / 0.090) * 0.82; const sideA = smoothstep((-axis + 0.28 + wave + bay) / 0.28);
const sideB = smoothstep((axis + 0.26 - wave + bay * 0.7) / 0.29);
const channel = smoothstep((0.082 - Math.abs(cross + wave * 0.68 + islandNoise * 0.55)) / 0.112) * 0.98;
pressure = Math.max(sideA, sideB, channel); pressure = Math.max(sideA, sideB, channel);
} else if (template.coastStyle === "parallel_spine") { } else if (template.coastStyle === "parallel_spine") {
// 東北型: 左右端または上下端に海を置く。海岸線は脊梁山脈とおおよそ平行。 // 東北型: 左右端または上下端に海を置く。海岸線は脊梁山脈とおおよそ平行。
@ -738,6 +740,81 @@ function computeFlowAccumulation(sea, flowTo, filled, flowAccum) {
return area; return area;
} }
function buildWatershedId(sea, flowTo, flowAccum) {
const outletKey = new Int32Array(SIZE);
outletKey.fill(-1);
const ids = new Int32Array(SIZE);
ids.fill(-1);
const outletToId = new Map();
const quant = 12;
function keyForOutlet(i) {
if (i < 0) return -1;
const x = i % MAP_W;
const y = Math.floor(i / MAP_W);
const qx = Math.floor(x / quant);
const qy = Math.floor(y / quant);
return qy * 1000 + qx;
}
function resolve(start) {
if (start < 0 || sea[start]) return -1;
if (outletKey[start] >= 0) return outletKey[start];
const chain = [];
const seen = new Set();
let i = start;
let key = -1;
for (let guard = 0; guard < SIZE && i >= 0; guard++) {
if (sea[i]) { key = keyForOutlet(i); break; }
if (outletKey[i] >= 0) { key = outletKey[i]; break; }
if (seen.has(i)) { key = keyForOutlet(i); break; }
seen.add(i);
chain.push(i);
const to = flowTo[i];
if (to < 0 || to === i) { key = keyForOutlet(i); break; }
// Major channels should be a watershed's spine, not a sequence of tiny
// drainage labels. Continue to the coast/outlet even after hitting them.
i = to;
}
if (key < 0 && chain.length) key = keyForOutlet(chain[chain.length - 1]);
for (const ci of chain) outletKey[ci] = key;
return key;
}
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
const key = resolve(i);
if (key < 0) continue;
if (!outletToId.has(key)) outletToId.set(key, outletToId.size);
ids[i] = outletToId.get(key);
}
// Very small coastal outlet labels create noisy slivers. Merge them into the
// strongest neighbouring watershed so natural compartments remain basin-scale.
const counts = new Int32Array(outletToId.size || 1);
for (let i = 0; i < SIZE; i++) if (ids[i] >= 0) counts[ids[i]]++;
const minArea = 18;
for (let pass = 0; pass < 2; pass++) {
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
const id = ids[i];
if (id < 0 || counts[id] >= minArea) continue;
const choices = new Map();
for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) {
if (!inside(nx, ny)) continue;
const nid = ids[indexOf(nx, ny)];
if (nid >= 0 && nid !== id) choices.set(nid, (choices.get(nid) || 0) + 1 + (flowAccum[indexOf(nx, ny)] || 0));
}
let best = -1, bestScore = -1;
for (const [nid, score] of choices) if (score > bestScore) { bestScore = score; best = nid; }
if (best >= 0) { counts[id]--; counts[best]++; ids[i] = best; }
}
}
}
return ids;
}
function traceFlowPath(start, sea, flowTo, maxSteps = 900) { function traceFlowPath(start, sea, flowTo, maxSteps = 900) {
const path = []; const path = [];
const seen = new Set(); const seen = new Set();
@ -1129,6 +1206,9 @@ export function generateTerrainAndRivers(seed) {
const lowHillNoise = clamp((fbm(x * 0.95 + 17, y * 0.95 - 23, seed + 571) - 0.38) * 2.9); const lowHillNoise = clamp((fbm(x * 0.95 + 17, y * 0.95 - 23, seed + 571) - 0.38) * 2.9);
const lowHillMask = clamp(0.34 + mountainMaskMax * 0.72 + lowHillNoise * 0.50 - coastPressure * 0.12); const lowHillMask = clamp(0.34 + mountainMaskMax * 0.72 + lowHillNoise * 0.50 - coastPressure * 0.12);
e += lowHillMask * 0.145; e += lowHillMask * 0.145;
// Do not add the previous fine speckle uplift here: it created too many
// tiny islets. Sea amount is controlled by seaRatio/coast pressure.
e -= clamp((coastPressure - 0.42) * 1.35) * 0.026;
const high = Math.max(0, e - 0.62); const high = Math.max(0, e - 0.62);
e -= high * 0.42; e -= high * 0.42;
} }
@ -1150,6 +1230,7 @@ export function generateTerrainAndRivers(seed) {
const filled = new Float32Array(SIZE); const filled = new Float32Array(SIZE);
priorityFloodFlow(elevation, sea, flowTo, filled); priorityFloodFlow(elevation, sea, flowTo, filled);
computeFlowAccumulation(sea, flowTo, filled, flowAccum); computeFlowAccumulation(sea, flowTo, filled, flowAccum);
const watershedId = buildWatershedId(sea, flowTo, flowAccum);
const { riverPaths, mainRivers, tributaryRivers, smallStreams } = buildRiverNetwork(seed, terrainTemplate, sea, lake, elevation, slope, flowTo, flowAccum, river, erosionField); const { riverPaths, mainRivers, tributaryRivers, smallStreams } = buildRiverNetwork(seed, terrainTemplate, sea, lake, elevation, slope, flowTo, flowAccum, river, erosionField);
enforceLandGradient(elevation, sea, seaLevel); enforceLandGradient(elevation, sea, seaLevel);
deriveFields(seed, terrainTemplate, fields, seaLevel); deriveFields(seed, terrainTemplate, fields, seaLevel);
@ -1162,7 +1243,7 @@ export function generateTerrainAndRivers(seed) {
const natural = buildNaturalCompartments( const natural = buildNaturalCompartments(
landMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum,
null, plain, agriculture, zeroDensity, zeroLanduse, null, plain, agriculture, zeroDensity, zeroLanduse,
{ seed: seed + 17003, targetCompartmentCount: clamp(Math.round((SIZE - sea.reduce((sum, value) => sum + value, 0)) / 45), 70, 360) } { seed: seed + 17003, watershedId, targetCompartmentCount: clamp(Math.round((SIZE - sea.reduce((sum, value) => sum + value, 0)) / 45), 70, 360) }
); );
const sharedNaturalBarrierScore = natural.naturalBarrierScore || naturalBarrierScore; const sharedNaturalBarrierScore = natural.naturalBarrierScore || naturalBarrierScore;
const prefectureBorder = extractMaskBorder(prefectureMask, sea); const prefectureBorder = extractMaskBorder(prefectureMask, sea);
@ -1221,6 +1302,7 @@ export function generateTerrainAndRivers(seed) {
basinField, basinField,
coastalLowland, coastalLowland,
flowAccum, flowAccum,
watershedId,
erosionField, erosionField,
depositionField, depositionField,
arcSpineField, arcSpineField,

File diff suppressed because it is too large Load diff

415
mapTransportOD.js Normal file
View file

@ -0,0 +1,415 @@
import { INF, MAP_H, MAP_W, SIZE, clamp, hash2, indexOf, inside, pickEntities } from "./mapUtils.js";
import { makeUnionFind, pathAverageField, pathLengthCells } from "./mapTransportUtils.js";
// Phase 3: unified OD rail model
// --------------------------------
// Rail is no longer generated from isolated potential-field strokes. It is
// derived from a single transport-node model: major cities, prefectural seats,
// ports, large market towns, and external gateways create OD demand; MST-style
// connectivity gives the skeleton; high-demand pairs add loops; lower-tier
// settlements receive short branch connections only when the trunk is nearby.
export function buildUnifiedRailODNetwork(ctx) {
const {
seed,
sea,
elevation,
slope,
ridgeField,
valleyField,
basinField,
coastalLowland,
plain,
agriculture,
naturalBarrierScore,
passSuitability,
transportFields,
settlementDemand,
preliminaryUrbanInfluence,
preliminaryTownInfluence,
preliminaryVillageInfluence,
modernCities,
markets,
ports,
commercialPorts,
externalGateways,
geographicUrbanAnchors = [],
regionIdAt,
routeBetweenTrafficCandidates,
addCorridorInfluencePenalty,
transportRouteAcceptable,
pruneParallelSameMode,
cachedInfluenceFromPaths,
} = ctx;
const railways = [];
const branchRailways = [];
const debug = {
version: "phase3-unified-rail-od-v1",
strategy: "OD nodes -> MST trunk -> demand loops -> short branches",
nodeCounts: {},
trunkPairsConsidered: 0,
trunkPairsRouted: 0,
loopPairsRouted: 0,
branchPairsRouted: 0,
rejected: {},
nodes: [],
trunkCorridors: [],
loopCorridors: [],
branchCorridors: [],
parallelPruning: null,
};
const reject = (reason) => { debug.rejected[reason] = (debug.rejected[reason] || 0) + 1; };
function fieldValue(field, i, fallback = 0) {
const v = field?.[i];
return Number.isFinite(v) ? v : fallback;
}
function railCostAt(x, y) {
if (!inside(x, y)) return INF;
const i = indexOf(x, y);
return sea[i] ? INF : transportFields.rail[i];
}
function populationProxy(p, fallback = 12000) {
if (!p) return fallback;
if (Number.isFinite(p.population) && p.population > 0) return p.population;
if (p.portClass === "major") return 85000;
if (p.portClass === "regional") return 42000;
const i = inside(p.x, p.y) ? indexOf(p.x, p.y) : -1;
if (i < 0) return fallback;
return Math.max(fallback, Math.round(
fieldValue(preliminaryUrbanInfluence, i) * 160000 +
fieldValue(preliminaryTownInfluence, i) * 65000 +
fieldValue(preliminaryVillageInfluence, i) * 15000 +
fieldValue(settlementDemand, i) * 45000
));
}
function nearbyRailAnchor(point, role = "rail-node", options = {}) {
if (!point || !inside(point.x, point.y)) return null;
const inner = options.inner ?? 0;
const outer = options.outer ?? (role.includes("city") || role.includes("capital") ? 7 : role.includes("port") ? 8 : 5);
let best = null;
for (let dy = -outer; dy <= outer; dy++) {
for (let dx = -outer; dx <= outer; dx++) {
const x = Math.round(point.x + dx);
const y = Math.round(point.y + dy);
if (!inside(x, y)) continue;
const d = Math.hypot(dx, dy);
if (d < inner || d > outer) continue;
const i = indexOf(x, y);
if (sea[i] || transportFields.rail[i] >= INF) continue;
const density = fieldValue(settlementDemand, i);
const terrain =
fieldValue(transportFields.railPotential, i) * 1.35 +
fieldValue(preliminaryUrbanInfluence, i) * 0.42 +
fieldValue(preliminaryTownInfluence, i) * 0.46 +
valleyField[i] * 0.28 +
basinField[i] * 0.20 +
coastalLowland[i] * 0.20 +
plain[i] * 0.16 -
slope[i] * 1.10 -
ridgeField[i] * 0.72 -
Math.max(0, elevation[i] - 0.58) * 1.45 -
Math.max(0, density - 0.78) * 0.32;
const centerPenalty = d * (role.includes("city") || role.includes("capital") ? 0.025 : 0.060);
const score = terrain - centerPenalty + hash2(x, y, seed + 23101 + point.x * 3 + point.y * 7) * 0.035;
if (!best || score > best.score) best = {
x,
y,
score,
role,
source: point,
regionId: regionIdAt(x, y),
population: populationProxy(point),
name: point.name,
kind: point.kind,
portClass: point.portClass,
};
}
}
return best;
}
function dedupeNodes(nodes, minDistance = 5.5) {
const sorted = nodes
.filter((p) => p && inside(p.x, p.y) && !sea[indexOf(p.x, p.y)] && railCostAt(p.x, p.y) < INF)
.sort((a, b) => (b.score || 0) - (a.score || 0));
const out = [];
for (const p of sorted) {
if (out.every((q) => Math.hypot(q.x - p.x, q.y - p.y) >= minDistance)) out.push(p);
}
return out;
}
function lineStats(a, b, costField = transportFields.rail) {
const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y)));
let cost = 0;
let barrier = 0;
let high = 0;
let seaHits = 0;
let n = 0;
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const x = Math.round(a.x + (b.x - a.x) * t);
const y = Math.round(a.y + (b.y - a.y) * t);
if (!inside(x, y)) { seaHits++; n++; continue; }
const i = indexOf(x, y);
if (sea[i] || costField[i] >= INF) {
seaHits++;
cost += 8;
barrier += 1;
n++;
continue;
}
cost += costField[i];
barrier += clamp(
fieldValue(naturalBarrierScore, i) * 0.80 +
ridgeField[i] * 0.36 +
slope[i] * 0.42 +
Math.max(0, elevation[i] - 0.58) * 0.65 -
fieldValue(passSuitability, i) * 0.42 -
valleyField[i] * 0.12
);
if (elevation[i] > 0.68 || slope[i] > 0.48) high++;
n++;
}
return {
avgCost: n ? cost / n : INF,
barrier: n ? barrier / n : 1,
highShare: n ? high / n : 1,
seaShare: n ? seaHits / n : 1,
};
}
function odDemand(a, b, mode = "trunk") {
const d = Math.hypot(a.x - b.x, a.y - b.y);
const popDemand = Math.sqrt(Math.max(5000, a.population || 0) * Math.max(5000, b.population || 0));
const roleBonus =
(a.role?.includes("capital") || b.role?.includes("capital") ? 0.24 : 0) +
(a.role?.includes("regional") || b.role?.includes("regional") ? 0.18 : 0) +
(a.role?.includes("port") || b.role?.includes("port") ? 0.14 : 0) +
(a.role?.includes("external") || b.role?.includes("external") ? 0.20 : 0);
const distanceBand = mode === "branch"
? clamp(1 - Math.abs(d - 24) / 34)
: clamp(1 - Math.abs(d - 58) / 74);
return popDemand / (mode === "branch" ? 95000 : 145000) + roleBonus + distanceBand * (mode === "branch" ? 0.18 : 0.28);
}
function pairScore(a, b, mode = "trunk") {
const d = Math.hypot(a.x - b.x, a.y - b.y);
const stats = lineStats(a, b);
if (stats.seaShare > 0.06) return null;
if (stats.highShare > (mode === "branch" ? 0.22 : 0.16)) return null;
const demand = odDemand(a, b, mode);
const crossRegion = a.regionId !== b.regionId ? 0.10 : 0;
const barrierPenalty = 1 + stats.barrier * (mode === "branch" ? 1.15 : 1.45) + stats.avgCost * 0.30 + stats.seaShare * 4.0;
const score = d * barrierPenalty / Math.max(0.18, demand + crossRegion);
return { a, b, d, demand, stats, score };
}
function routeRailPair(pair, penalty, branch = false) {
const path = routeBetweenTrafficCandidates(pair.a, pair.b, "rail", transportFields.rail, penalty, {
curvePenalty: branch ? 0.135 : 0.150,
penaltyStrength: branch ? 0.92 : 1.28,
terrainFlowBias: branch ? 0.16 : 0.13,
surfaceGrain: 0.010,
relaxRadius: 1,
relaxLineWeight: branch ? 0.44 : 0.50,
snapRadius: branch ? 2.4 : 2.8,
searchPad: Math.ceil(Math.max(22, Math.min(68, pair.d * 0.48))),
maxPathLength: pair.d * (branch ? 2.28 : 2.48) + (branch ? 18 : 36),
maxSeaRun: 1,
maxSeaShare: 0.006,
});
const len = pathLengthCells(path);
if (len < (branch ? 5 : 14)) { reject(branch ? "branchTooShort" : "trunkTooShort"); return []; }
if (len > pair.d * (branch ? 2.36 : 2.58) + (branch ? 24 : 42)) { reject(branch ? "branchTooLong" : "trunkTooLong"); return []; }
if (!transportRouteAcceptable(path, "rail", transportFields.railPotential, penalty, {
minLength: branch ? 4 : 12,
maxLength: pair.d * (branch ? 2.40 : 2.66) + (branch ? 26 : 46),
maxCompactness: branch ? 2.85 : 2.65,
maxSteepShare: branch ? 0.24 : 0.20,
maxHighElevationShare: branch ? 0.04 : 0.02,
minAvgPotential: branch ? 0.06 : 0.10,
})) { reject(branch ? "branchQuality" : "trunkQuality"); return []; }
if (pathAverageField(path, transportFields.railPotential) < (branch ? 0.08 : 0.13) && pair.d > 24) { reject(branch ? "branchLowPotential" : "trunkLowPotential"); return []; }
return path;
}
function keyOf(p) { return `${p.x},${p.y}`; }
const regionalCityNodes = modernCities
.filter((c) => c.isRegionalCapital || c.isPrefecturalCapital || (c.population || 0) >= 90000)
.map((c) => nearbyRailAnchor(c, c.isRegionalCapital ? "regional-capital-rail" : c.isPrefecturalCapital ? "prefectural-capital-rail" : "major-city-rail", { outer: 8 }))
.filter(Boolean);
const secondaryCityNodes = modernCities
.filter((c) => !regionalCityNodes.some((n) => n.source === c) && (c.population || 0) >= 38000)
.map((c) => nearbyRailAnchor(c, "secondary-city-rail", { outer: 7 }))
.filter(Boolean);
const portNodes = [...commercialPorts, ...ports]
.filter((p, idx, arr) => arr.findIndex((q) => q.x === p.x && q.y === p.y) === idx)
.filter((p) => p.portClass === "major" || p.portClass === "regional" || (p.population || 0) >= 16000)
.map((p) => nearbyRailAnchor(p, "port-rail", { outer: 8 }))
.filter(Boolean);
const externalNodes = externalGateways
.map((g) => nearbyRailAnchor({ ...g, population: 60000 }, "external-rail-gateway", { outer: 5 }))
.filter(Boolean);
const anchorNodes = geographicUrbanAnchors
.filter((a) => (a.score || 0) > 0.76)
.slice(0, 8)
.map((a) => nearbyRailAnchor({ ...a, population: a.population || 52000 }, "geo-anchor-rail", { outer: 6 }))
.filter(Boolean);
let trunkNodes = dedupeNodes([...regionalCityNodes, ...secondaryCityNodes, ...portNodes, ...externalNodes, ...anchorNodes], 7.5)
.sort((a, b) => (b.population || 0) - (a.population || 0))
.slice(0, 34);
if (trunkNodes.length < 2) {
trunkNodes = dedupeNodes([
...modernCities.map((c) => nearbyRailAnchor(c, "fallback-city-rail", { outer: 8 })),
...markets.filter((m) => (m.population || 0) >= 14000).map((m) => nearbyRailAnchor(m, "fallback-market-rail", { outer: 5 })),
], 7).slice(0, 18);
}
debug.nodeCounts = {
regionalCityNodes: regionalCityNodes.length,
secondaryCityNodes: secondaryCityNodes.length,
portNodes: portNodes.length,
externalNodes: externalNodes.length,
geographicAnchorNodes: anchorNodes.length,
trunkNodes: trunkNodes.length,
};
debug.nodes = trunkNodes.map((n) => ({ x: n.x, y: n.y, role: n.role, population: n.population, regionId: n.regionId }));
const trunkPairs = [];
for (let a = 0; a < trunkNodes.length; a++) {
for (let b = a + 1; b < trunkNodes.length; b++) {
const A = trunkNodes[a];
const B = trunkNodes[b];
const d = Math.hypot(A.x - B.x, A.y - B.y);
if (d < 16 || d > 150) { reject("trunkDistanceEnvelope"); continue; }
const pair = pairScore(A, B, "trunk");
if (!pair) { reject("trunkLineStats"); continue; }
trunkPairs.push(pair);
}
}
trunkPairs.sort((a, b) => a.score - b.score);
debug.trunkPairsConsidered = trunkPairs.length;
const uf = makeUnionFind(trunkNodes, keyOf);
const penalty = new Float32Array(SIZE);
const maxTrunk = Math.min(18, Math.max(4, trunkNodes.length - 1));
let connectedEdges = 0;
for (const pair of trunkPairs) {
if (connectedEdges >= maxTrunk) break;
const ak = keyOf(pair.a);
const bk = keyOf(pair.b);
if (uf.find(ak) === uf.find(bk)) continue;
const path = routeRailPair(pair, penalty, false);
if (!path.length) continue;
railways.push(path);
addCorridorInfluencePenalty(penalty, path, 9, 0.74);
uf.unite(ak, bk);
connectedEdges++;
debug.trunkPairsRouted++;
debug.trunkCorridors.push({ from: pair.a.role, to: pair.b.role, distance: Math.round(pair.d), demand: Math.round(pair.demand * 100) / 100, length: Math.round(pathLengthCells(path)), path });
}
// Add a few loops / redundant high-demand links after MST. These are the
// Shinkansen/main-line analogues around dense corridors and port approaches.
let loopAdded = 0;
const trunkInfluenceBeforeLoops = cachedInfluenceFromPaths(railways, 5, "rail-od:trunk-before-loops");
for (const pair of trunkPairs) {
if (loopAdded >= Math.min(7, Math.max(2, Math.ceil(trunkNodes.length / 5)))) break;
const ai = indexOf(pair.a.x, pair.a.y);
const bi = indexOf(pair.b.x, pair.b.y);
if ((trunkInfluenceBeforeLoops[ai] || 0) > 0.62 && (trunkInfluenceBeforeLoops[bi] || 0) > 0.62 && pair.demand < 0.88) continue;
if (pair.score > 92 && pair.demand < 0.78) continue;
const path = routeRailPair(pair, penalty, false);
if (!path.length) continue;
railways.push(path);
addCorridorInfluencePenalty(penalty, path, 11, 0.66);
loopAdded++;
debug.loopPairsRouted++;
debug.loopCorridors.push({ from: pair.a.role, to: pair.b.role, distance: Math.round(pair.d), demand: Math.round(pair.demand * 100) / 100, length: Math.round(pathLengthCells(path)), path });
}
const railInfluence = cachedInfluenceFromPaths(railways, 8, "rail-od:trunk-for-branches");
const branchCandidates = dedupeNodes([
...modernCities
.filter((c) => (c.population || 0) >= 22000 && (c.population || 0) < 90000)
.map((c) => nearbyRailAnchor(c, "branch-city-rail", { outer: 6 })),
...markets
.filter((m) => (m.population || 0) >= 16000)
.map((m) => nearbyRailAnchor(m, "branch-market-rail", { outer: 5 })),
...ports
.filter((p) => p.portClass === "regional" || (p.population || 0) >= 10000)
.map((p) => nearbyRailAnchor(p, "branch-port-rail", { outer: 7 })),
], 6).sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, 36);
const trunkTargets = [];
for (const path of railways) {
const stride = Math.max(5, Math.floor(path.length / 18));
for (let k = 0; k < path.length; k += stride) {
const [x, y] = path[k];
if (inside(x, y) && !sea[indexOf(x, y)]) trunkTargets.push({ x, y, role: "rail-trunk-cell", population: 70000, score: 0.8, regionId: regionIdAt(x, y) });
}
}
trunkTargets.push(...trunkNodes);
let branchAdded = 0;
for (const node of branchCandidates) {
if (branchAdded >= 14) break;
const ni = indexOf(node.x, node.y);
if ((railInfluence[ni] || 0) > 0.34) continue;
const options = trunkTargets
.map((q) => {
const d = Math.hypot(q.x - node.x, q.y - node.y);
if (d < 8 || d > 54) return null;
const pair = pairScore(node, q, "branch");
if (!pair) return null;
return pair;
})
.filter(Boolean)
.sort((a, b) => a.score - b.score);
for (const pair of options.slice(0, 5)) {
if (odDemand(pair.a, pair.b, "branch") < 0.34 && pair.d > 32) { reject("branchWeakDemand"); continue; }
const path = routeRailPair(pair, penalty, true);
if (!path.length) continue;
branchRailways.push(path);
addCorridorInfluencePenalty(penalty, path, 6, 0.42);
branchAdded++;
debug.branchPairsRouted++;
debug.branchCorridors.push({ from: pair.a.role, to: pair.b.role, distance: Math.round(pair.d), demand: Math.round(pair.demand * 100) / 100, length: Math.round(pathLengthCells(path)), path });
break;
}
}
debug.parallelPruning = pruneParallelSameMode([...railways, ...branchRailways], "rail", transportFields.railPotential, {
minKeep: Math.min(3, railways.length),
radius: 2,
threshold: 0.62,
shortLength: 24,
});
// pruneParallelSameMode mutates only the temporary array above, so repeat a
// conservative in-place pass per layer to preserve trunk/branch classification.
debug.trunkParallelPruning = pruneParallelSameMode(railways, "rail", transportFields.railPotential, {
minKeep: 2,
radius: 2,
threshold: 0.66,
shortLength: 30,
});
debug.branchParallelPruning = pruneParallelSameMode(branchRailways, "rail", transportFields.railPotential, {
minKeep: 0,
radius: 2,
threshold: 0.70,
shortLength: 18,
});
debug.finalRailwayCount = railways.length;
debug.finalBranchRailwayCount = branchRailways.length;
return { railways, branchRailways, debug };
}

229
mapTransportUtils.js Normal file
View file

@ -0,0 +1,229 @@
import { SIZE, clamp, indexOf, inside } from "./mapUtils.js";
export function pathSetSignature(paths) {
let cells = 0;
let endpoints = 0;
for (const path of paths || []) {
cells += path?.length || 0;
const a = path?.[0];
const b = path?.[path.length - 1];
if (a) endpoints = (endpoints + a[0] * 17 + a[1] * 31) | 0;
if (b) endpoints = (endpoints + b[0] * 47 + b[1] * 73) | 0;
}
return `${paths?.length || 0}:${cells}:${endpoints >>> 0}`;
}
export function createPathInfluenceCache(influenceFromPaths) {
const cache = new Map();
return (paths, radius, label = "paths") => {
const key = `${label}:${radius}:${pathSetSignature(paths)}`;
let grid = cache.get(key);
if (!grid) {
grid = influenceFromPaths(paths, radius);
cache.set(key, grid);
}
return grid;
};
}
export function packDebugField(field) {
const out = new Uint8Array(SIZE);
for (let i = 0; i < SIZE; i++) out[i] = Math.round(clamp(field?.[i] || 0) * 255);
return out;
}
export function pathLengthCells(path) {
let total = 0;
for (let i = 1; i < (path?.length || 0); i++) {
total += Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]);
}
return total;
}
export function pathAverageField(path, field) {
if (!path?.length || !field) return 0;
let sum = 0;
let n = 0;
for (const [x, y] of path) {
if (!inside(x, y)) continue;
sum += field[indexOf(x, y)] || 0;
n++;
}
return n ? sum / n : 0;
}
export function routeQualityStats(path, fields = {}) {
if (!path?.length) return { length: 0, compactness: Infinity, highElevationShare: 1, steepShare: 1, waterShare: 1, avgPotential: 0, avgPenalty: 0 };
const length = pathLengthCells(path);
const first = path[0];
const last = path[path.length - 1];
const direct = first && last ? Math.hypot(first[0] - last[0], first[1] - last[1]) : 0;
let high = 0;
let steep = 0;
let water = 0;
let potential = 0;
let penalty = 0;
let n = 0;
for (const [x, y] of path) {
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (fields.sea?.[i]) water++;
if ((fields.elevation?.[i] || 0) > (fields.highElevationThreshold ?? 0.72)) high++;
if ((fields.slope?.[i] || 0) > (fields.steepThreshold ?? 0.44)) steep++;
potential += fields.potential?.[i] || 0;
penalty += fields.penalty?.[i] || 0;
n++;
}
return {
length,
compactness: direct > 0.001 ? length / direct : Infinity,
highElevationShare: high / Math.max(1, n),
steepShare: steep / Math.max(1, n),
waterShare: water / Math.max(1, n),
avgPotential: potential / Math.max(1, n),
avgPenalty: penalty / Math.max(1, n),
};
}
export function routeQualityAcceptable(path, fields = {}, limits = {}) {
const q = routeQualityStats(path, fields);
if (q.length < (limits.minLength ?? 2)) return false;
if (q.length > (limits.maxLength ?? Infinity)) return false;
if (q.compactness > (limits.maxCompactness ?? 3.2)) return false;
if (q.highElevationShare > (limits.maxHighElevationShare ?? 0.0)) return false;
if (q.steepShare > (limits.maxSteepShare ?? 0.38)) return false;
if (q.waterShare > (limits.maxWaterShare ?? 0)) return false;
if (q.avgPenalty > (limits.maxAvgPenalty ?? Infinity) && q.avgPotential < (limits.minPotentialIfPenalty ?? 0.35)) return false;
if (q.avgPotential < (limits.minAvgPotential ?? 0)) return false;
return true;
}
export const TRANSPORT_ROUTE_POLICIES = {
mountain: {
road: { maxHighAltitudeShare: 0, longLength: 72, longStraightness: 0.90, maxLongMountain: 0.34, extremeLength: 92, extremeStraightness: 0.86, maxExtremeBoundary: 0.20 },
national: { maxHighAltitudeShare: 0, longLength: 72, longStraightness: 0.90, maxLongMountain: 0.34, extremeLength: 92, extremeStraightness: 0.86, maxExtremeBoundary: 0.20 },
local: { maxHighAltitudeShare: 0.04, longLength: 72, longStraightness: 0.90, maxLongMountain: 0.34, extremeLength: 92, extremeStraightness: 0.86, maxExtremeBoundary: 0.20 },
expressway: { maxHighAltitudeShare: 0, maxDenseShare: 0.12, maxCityCoreShare: 0.11, maxVillageCoreShare: 0.08, longLength: 72, longStraightness: 0.90, maxLongMountain: 0.34, extremeLength: 92, extremeStraightness: 0.86, maxExtremeBoundary: 0.20 },
expresswayMountainOnly: { maxHighAltitudeShare: 0, longLength: 72, longStraightness: 0.90, maxLongMountain: 0.36, extremeLength: 96, extremeStraightness: 0.86, maxExtremeBoundary: 0.22 },
},
expresswayAcceptance: {
strict: { maxCityCoreHits: 0, maxVillageHits: 2, maxDenseShare: 0.16, maxCityCoreShare: 0.13, maxVillageCoreShare: 0.11, maxHighAltitudeShare: 0, maxMountain: 0.64, maxBoundary: 0.44 },
fallback: { maxCityCoreHits: 0, maxVillageHits: 6, maxDenseShare: 0.24, maxCityCoreShare: 0.20, maxVillageCoreShare: 0.20, maxHighAltitudeShare: 0, maxMountain: 0.74, maxBoundary: 0.56 },
approach: { maxCityCoreHits: 5, maxVillageHits: 18, maxDenseShare: 0.34, maxCityCoreShare: 0.30, maxVillageCoreShare: 0.32, maxHighAltitudeShare: 0, maxMountain: 0.90, maxBoundary: 0.72 },
},
fieldBackbone: {
expressway: { minLength: 18, maxLengthMultiplier: 2.85, maxLengthAdd: 92, parallelConnected: 0.075, parallelExtra: 0.030 },
national: { minLength: 5, maxLengthMultiplier: 2.65, maxLengthAdd: 48, maxHighElevationShare: 0.34, maxSteepShare: 0.48, parallelConnected: 0.54, parallelExtra: 0.44 },
},
};
export function routeGeometry(path) {
if (!path || path.length < 2) return { len: 0, direct: 0, straightness: 1 };
const len = pathLengthCells(path);
const a = path[0];
const b = path[path.length - 1];
const direct = Math.hypot(a[0] - b[0], a[1] - b[1]);
return { len, direct, straightness: direct / Math.max(1, len) };
}
export function assessMountainRoute(path, mode, pathTerrainRisk, policies = TRANSPORT_ROUTE_POLICIES) {
if (!path || path.length < 2) return { ok: false, reason: "empty" };
const policy = policies.mountain[mode] || policies.mountain.road;
const { len, straightness } = routeGeometry(path);
const risk = pathTerrainRisk(path);
if (risk.highAltitudeShare > policy.maxHighAltitudeShare) return { ok: false, reason: "highAltitude", risk };
if (policy.maxDenseShare !== undefined && (risk.denseShare > policy.maxDenseShare || risk.cityCoreShare > policy.maxCityCoreShare || risk.villageCoreShare > policy.maxVillageCoreShare)) {
return { ok: false, reason: "settlementCore", risk };
}
if (len > policy.longLength && straightness > policy.longStraightness && risk.mountain > policy.maxLongMountain) return { ok: false, reason: "straightMountain", risk };
if (len > policy.extremeLength && straightness > policy.extremeStraightness && risk.boundary > policy.maxExtremeBoundary) return { ok: false, reason: "straightBoundary", risk };
return { ok: true, reason: "ok", risk };
}
export function expresswayAcceptancePolicy(options = {}, policies = TRANSPORT_ROUTE_POLICIES) {
if (options.allowApproach) return policies.expresswayAcceptance.approach;
if (options.allowFallback) return policies.expresswayAcceptance.fallback;
return policies.expresswayAcceptance.strict;
}
export function assessExpresswayRoute(path, options, pathTerrainRisk, expresswayProximityRisk, policies = TRANSPORT_ROUTE_POLICIES) {
const risk = pathTerrainRisk(path);
const prox = expresswayProximityRisk(path);
const policy = expresswayAcceptancePolicy(options, policies);
if (prox.cityCoreHits > policy.maxCityCoreHits) return { ok: false, reason: "cityCoreHits", risk, prox };
if (prox.villageHits > policy.maxVillageHits) return { ok: false, reason: "villageHits", risk, prox };
if (risk.denseShare > policy.maxDenseShare) return { ok: false, reason: "denseShare", risk, prox };
if (risk.cityCoreShare > policy.maxCityCoreShare) return { ok: false, reason: "cityCoreShare", risk, prox };
if (risk.villageCoreShare > policy.maxVillageCoreShare) return { ok: false, reason: "villageCoreShare", risk, prox };
if (risk.highAltitudeShare > policy.maxHighAltitudeShare) return { ok: false, reason: "highAltitude", risk, prox };
if (risk.mountain > policy.maxMountain) return { ok: false, reason: "mountain", risk, prox };
if (risk.boundary > policy.maxBoundary) return { ok: false, reason: "boundary", risk, prox };
return { ok: true, reason: "ok", risk, prox };
}
export function countReason(stats, bucket, reason) {
if (!stats[bucket]) stats[bucket] = {};
stats[bucket][reason] = (stats[bucket][reason] || 0) + 1;
}
export function fieldBackbonePolicy(mode, policies = TRANSPORT_ROUTE_POLICIES) {
return policies.fieldBackbone[mode] || policies.fieldBackbone.national;
}
export function squaredDistance(a, b, x, y) {
const dx = a - x;
const dy = b - y;
return dx * dx + dy * dy;
}
export function makeSpatialIndex(points, cellSize = 16) {
const buckets = new Map();
const bucketKey = (x, y) => `${Math.floor(x / cellSize)},${Math.floor(y / cellSize)}`;
for (const point of points || []) {
if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) continue;
const key = bucketKey(point.x, point.y);
let bucket = buckets.get(key);
if (!bucket) {
bucket = [];
buckets.set(key, bucket);
}
bucket.push(point);
}
return {
near(x, y, radius) {
const out = [];
const bx0 = Math.floor((x - radius) / cellSize);
const bx1 = Math.floor((x + radius) / cellSize);
const by0 = Math.floor((y - radius) / cellSize);
const by1 = Math.floor((y + radius) / cellSize);
for (let by = by0; by <= by1; by++) {
for (let bx = bx0; bx <= bx1; bx++) {
const bucket = buckets.get(`${bx},${by}`);
if (bucket) out.push(...bucket);
}
}
return out;
},
};
}
export function makeUnionFind(nodes, keyOf) {
const parent = new Map();
const find = (key) => {
let root = parent.get(key) || key;
if (root !== key) {
root = find(root);
parent.set(key, root);
}
return root;
};
const unite = (a, b) => {
const ra = find(a);
const rb = find(b);
if (ra === rb) return false;
parent.set(rb, ra);
return true;
};
for (const node of nodes || []) parent.set(keyOf(node), keyOf(node));
return { find, unite };
}

View file

@ -1,24 +1,13 @@
import { MAP_H, MAP_W, hash2, indexOf, inside } from "./mapUtils.js"; import { MAP_H, MAP_W, hash2, indexOf, inside } from "./mapUtils.js";
export const CUSTOM_NAME_LIST = ["加茂", "春日", "新庄", "常盤", "追分", "清水", "曙", "暁", "旭", "鳥羽", "長門", "吉野", "吉田", "美郷", "美里", "美山", "八幡", "相生",]; export const CUSTOM_NAME_LIST = ["加茂", "瑞穂", "天神", "弁天", "千歳", "朝日", "春日", "新庄", "常盤", "追分", "清水", "曙", "暁", "旭", "鳥羽", "長門", "吉野", "吉田", "美郷", "美里", "美山"];
function nameCharCount(value) {
return Array.from(String(value || "")).length;
}
function isAtomicNamePart(value) {
// The generator used to create visibly synthetic three-part names by joining
// a prefix with a compound terrain word. For template generation, keep each
// lexical slot atomic so a generated root is at most two visible elements.
return nameCharCount(value) <= 1;
}
export const NAME_KANJI_POOLS = { export const NAME_KANJI_POOLS = {
modifiers: [ modifiers: [
"大", "小", "上", "下", "中", "奥", "脇", "大", "小", "上", "下", "中", "奥", "脇",
"東", "西", "南", "北", "東", "西", "南", "北",
"新", "古", "本", "新", "古", "本",
"高", "長", "広", "深", "浅", "明", "重", "荒", "高", "長", "広", "深", "浅", "明", "重", "荒", "富",
"白", "黒", "青", "赤", "藍", "白", "黒", "青", "赤", "藍",
"奥", "前", "後", "内", "外", "奥", "前", "後", "内", "外",
"美", "吉", "福", "幸", "徳", "美", "吉", "福", "幸", "徳",
@ -26,7 +15,7 @@ export const NAME_KANJI_POOLS = {
"霞", "朝", "日", "天", "霞", "朝", "日", "天",
"土", "砂", "石", "岩", "土", "砂", "石", "岩",
"卯", "辰", "卯", "辰",
"駒", "羽", "鳴", "横", "永", "早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", "徳", "栄" "串","駒", "来", "武", "箱", "羽", "鳴", "横", "永", "早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", "徳", "栄"
], ],
inlandTerrain: [ inlandTerrain: [
@ -37,7 +26,7 @@ export const NAME_KANJI_POOLS = {
"塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", "植", "生", "塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", "植", "生",
"郷", "里", "郷", "里",
"馬", "鹿", "亀", "鷲", "鷹", "鶴", "鴨", "竜", "龍", "牛", "鳥", "馬", "鹿", "亀", "鷲", "鷹", "鶴", "鴨", "竜", "龍", "牛", "鳥",
"湯", "宍", "湯",
], ],
waterTerrain: [ waterTerrain: [
@ -60,20 +49,20 @@ export const NAME_KANJI_POOLS = {
"松", "杉", "桜", "梅", "栗", "松", "杉", "桜", "梅", "栗",
"竹", "楠", "藤", "萩", "葦", "竹", "楠", "藤", "萩", "葦",
"菅", "榎", "椿", "桐", "柳", "菅", "榎", "椿", "桐", "柳",
"橘", "柏", "槙", "柿", "桃", "稲", "花", "草", "菊", "橘", "柏", "槙", "柿", "桃", "稲",
"梨", "桑", "麻", "芦", "茅", "根", "梨", "桑", "麻", "芦", "茅", "根",
"粟", "稲", "稗", "米", "飯", "糠", "茜", "葵", "篠", "粟", "稲", "稗", "米", "飯", "糠", "茜", "葵", "篠", "芝", "柴",
"榊", "楢", "檜", "椎", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑", "瓜" "榊", "楢", "檜", "椎", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑", "瓜"
], ],
postfixes: [ postfixes: [
"田", "川", "山", "岡", "森", "林", "田", "川", "山", "岡", "森", "林",
"島", "島", "尻",
"江", "瀬", "井", "戸", "口", "江", "瀬", "井", "戸", "口",
"辺", "里", "郷", "村", "町", "辺", "里", "郷", "村", "町",
"宿", "庄", "台", "坂", "橋", "明", "宿", "庄", "台", "坂", "橋", "明",
"本", "内", "窪", "平", "塚", "根", "本", "内", "窪", "平", "塚", "根", "串",
"畑", "牧", "前", "見", "中", "羽", "生", "駒", "塚", "部", "栄", "永", "平", "畑", "牧", "前", "見", "中", "羽", "生", "駒", "来", "富", "塚", "部", "栄", "永", "平",
], ],
archaicPrefixes: [ archaicPrefixes: [
@ -88,7 +77,7 @@ export const NAME_KANJI_POOLS = {
"和", "輪", "和", "輪",
"出", "播", "但", "出", "播", "但",
"因", "伯", "筑", "肥", "豊", "因", "伯", "筑", "肥", "豊",
"日", "紀", "志", "尾", "駿", "日", "紀", "志", "尾",
"甲", "信", "越", "備", "能", "甲", "信", "越", "備", "能",
"薩", "隠", "美", "三", "若", "薩", "隠", "美", "三", "若",
"遠", "近", "能", "加", "賀", "度", "飾", "遠", "近", "能", "加", "賀", "度", "飾",
@ -117,7 +106,7 @@ export const NAME_KANJI_POOLS = {
], ],
settlementWords: [ settlementWords: [
"里", "郷", "村", "町", "宿", "垣", "坪", "軒", "惣", "條", "里", "郷", "村", "町", "宿", "垣", "坪", "軒", "惣",
"庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂", "庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂",
"城", "館", "屋", "家", "所", "城", "館", "屋", "家", "所",
"市", "場", "関", "地蔵", "辻", "角", "堰", "通", "路", "道", "筋", "市", "場", "関", "地蔵", "辻", "角", "堰", "通", "路", "道", "筋",
@ -429,8 +418,7 @@ export function createNameDebug(probabilities = NAME_PROBABILITIES, pools = NAME
customNameListUsed: 0, customNameListUsed: 0,
invalidNamesRejected: 0, invalidNamesRejected: 0,
repeatedKanjiNamesRejected: 0, repeatedKanjiNamesRejected: 0,
oneCharacterNamesPrevented: 0, shortNamesRejected: 0,
rejectedOneCharacterNames: 0,
duplicateRetries: 0, duplicateRetries: 0,
fallbackAttempts: 0, fallbackAttempts: 0,
legacyFallbackUsed: 0, legacyFallbackUsed: 0,
@ -480,9 +468,8 @@ export function validateGeneratedName(name, options = {}) {
if (!options.allowAsciiDiagnostic && value.startsWith(ASCII_DIAGNOSTIC_PREFIX) && /^N[0-9A-Z]+$/.test(value)) { if (!options.allowAsciiDiagnostic && value.startsWith(ASCII_DIAGNOSTIC_PREFIX) && /^N[0-9A-Z]+$/.test(value)) {
return { valid: false, reason: "asciiDiagnostic" }; return { valid: false, reason: "asciiDiagnostic" };
} }
if (!options.allowOneCharacter && length < 2) return { valid: false, reason: "oneCharacter" }; if (Number.isFinite(options.minLength) && length < options.minLength) return { valid: false, reason: "tooShort" };
if (Number.isFinite(options.maxLength) && length > options.maxLength) return { valid: false, reason: "tooLong" }; if (Number.isFinite(options.maxLength) && length > options.maxLength) return { valid: false, reason: "tooLong" };
if (!options.allowLong && length > 4) return { valid: false, reason: "tooLong" };
if (!options.allowRepeatedKanji && hasRepeatedKanji(value)) return { valid: false, reason: "repeatedKanji" }; if (!options.allowRepeatedKanji && hasRepeatedKanji(value)) return { valid: false, reason: "repeatedKanji" };
return { valid: true, reason: "valid" }; return { valid: true, reason: "valid" };
} }
@ -500,8 +487,6 @@ function generateTemplateNameDetails(seed, id, entity, fields, attempt, usedName
const context = chooseNameContext(entity, fields); const context = chooseNameContext(entity, fields);
const templateKey = chooseTemplate(context, seed, id, attempt); const templateKey = chooseTemplate(context, seed, id, attempt);
const template = NAME_TEMPLATES[templateKey]; const template = NAME_TEMPLATES[templateKey];
// Do not generate old-style three-part random toponyms; names are now either
// curated list entries or at most two lexical elements plus any administrative suffix.
if (!template || (template.slots?.length || 0) > 2) return { name: null, context, templateKey: null }; if (!template || (template.slots?.length || 0) > 2) return { name: null, context, templateKey: null };
const parts = []; const parts = [];
@ -509,15 +494,13 @@ function generateTemplateNameDetails(seed, id, entity, fields, attempt, usedName
const slot = template.slots[slotIndex]; const slot = template.slots[slotIndex];
const slotPool = resolveSlotPool(slot, context, pools, probabilities, seed, id, attempt + slotIndex); const slotPool = resolveSlotPool(slot, context, pools, probabilities, seed, id, attempt + slotIndex);
if (!slotPool?.pool?.length) return { name: null, context, templateKey }; if (!slotPool?.pool?.length) return { name: null, context, templateKey };
const atomicPool = slotPool.pool.filter(isAtomicNamePart); const part = pick(slotPool.pool, seed, id, attempt, 2503 + slotIndex * 127 + stableHash(slotPool.key));
if (!atomicPool.length) return { name: null, context, templateKey };
const part = pick(atomicPool, seed, id, attempt, 2503 + slotIndex * 127 + stableHash(slotPool.key));
if (!part) return { name: null, context, templateKey }; if (!part) return { name: null, context, templateKey };
parts.push(part); parts.push(part);
} }
const name = parts.join(""); const name = parts.join("");
const validation = validateGeneratedName(name, { allowLong: false, maxLength: 2 }); const validation = validateGeneratedName(name);
if (!validation.valid) return { name: null, context, templateKey, invalidReason: validation.reason }; if (!validation.valid) return { name: null, context, templateKey, invalidReason: validation.reason };
if (usedNames?.has(name)) return { name: null, context, templateKey, duplicate: true }; if (usedNames?.has(name)) return { name: null, context, templateKey, duplicate: true };
return { name, context, templateKey }; return { name, context, templateKey };
@ -548,15 +531,10 @@ function tryCustomNameList(seed, id, usedNames, debug) {
const start = Math.floor(roll(seed, id, 0, 3539) * CUSTOM_NAME_LIST.length) % CUSTOM_NAME_LIST.length; const start = Math.floor(roll(seed, id, 0, 3539) * CUSTOM_NAME_LIST.length) % CUSTOM_NAME_LIST.length;
for (let offset = 0; offset < CUSTOM_NAME_LIST.length; offset++) { for (let offset = 0; offset < CUSTOM_NAME_LIST.length; offset++) {
const customName = CUSTOM_NAME_LIST[(start + offset) % CUSTOM_NAME_LIST.length]; const customName = CUSTOM_NAME_LIST[(start + offset) % CUSTOM_NAME_LIST.length];
if (nameCharCount(customName) > 2) {
debug.invalidNamesRejected++;
continue;
}
const validation = validateGeneratedName(customName, { allowAsciiDiagnostic: true }); const validation = validateGeneratedName(customName, { allowAsciiDiagnostic: true });
if (!validation.valid) { if (!validation.valid) {
if (validation.reason === "oneCharacter") { if (validation.reason === "tooShort") {
debug.oneCharacterNamesPrevented++; debug.shortNamesRejected++;
debug.rejectedOneCharacterNames++;
} else if (validation.reason === "repeatedKanji") { } else if (validation.reason === "repeatedKanji") {
debug.repeatedKanjiNamesRejected++; debug.repeatedKanjiNamesRejected++;
} else { } else {
@ -589,9 +567,8 @@ export function generateEntityName(seed, id, entity, fields, usedNames = null, d
continue; continue;
} }
if (result.invalidReason) { if (result.invalidReason) {
if (result.invalidReason === "oneCharacter") { if (result.invalidReason === "tooShort") {
debug.oneCharacterNamesPrevented++; debug.shortNamesRejected++;
debug.rejectedOneCharacterNames++;
} }
else if (result.invalidReason === "repeatedKanji") debug.repeatedKanjiNamesRejected++; else if (result.invalidReason === "repeatedKanji") debug.repeatedKanjiNamesRejected++;
else debug.invalidNamesRejected++; else debug.invalidNamesRejected++;

View file

@ -1,4 +1,4 @@
import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf } from "./mapUtils.js"; import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf, inside } from "./mapUtils.js";
const segmentVectorCache = new WeakMap(); const segmentVectorCache = new WeakMap();
@ -208,15 +208,24 @@ function vectorPath(path) {
if (cached) return cached; if (cached) return cached;
const points = path.map(([x, y]) => [x * CELL_SIZE + CELL_SIZE / 2, y * CELL_SIZE + CELL_SIZE / 2]); const points = path.map(([x, y]) => [x * CELL_SIZE + CELL_SIZE / 2, y * CELL_SIZE + CELL_SIZE / 2]);
// Transport routes are already cost-routed on the raster grid. A large RDP // Transport routes are raster-routed, so shallow diagonal corridors can look
// tolerance erases those small valley/contour bends and makes roads look like // like stair steps. Two light Chaikin passes remove that visual artifact
// ruler-straight overlays, so smooth first and simplify only lightly. // while a small RDP tolerance keeps valley and coastline bends intact.
const smoothedBase = chaikin(points, path.length > 8 ? 1 : 0, false); const smoothIterations = path.length > 12 ? 2 : path.length > 6 ? 1 : 0;
const simplified = simplifyRdp(smoothedBase, CELL_SIZE * 0.14); const smoothedBase = chaikin(points, smoothIterations, false);
const simplified = simplifyRdp(smoothedBase, CELL_SIZE * 0.10);
pathVectorCache.set(path, simplified); pathVectorCache.set(path, simplified);
return simplified; return simplified;
} }
function vectorPathMode(path, mode = "default") {
if (mode !== "expressway") return vectorPath(path);
if (!path || path.length < 2) return [];
const points = path.map(([x, y]) => [x * CELL_SIZE + CELL_SIZE / 2, y * CELL_SIZE + CELL_SIZE / 2]);
const smoothIterations = path.length > 18 ? 3 : path.length > 8 ? 2 : 1;
return simplifyRdp(chaikin(points, smoothIterations, false), CELL_SIZE * 0.18);
}
function drawPolylinePoints(ctx, points) { function drawPolylinePoints(ctx, points) {
if (!points || points.length < 2) return; if (!points || points.length < 2) return;
ctx.moveTo(points[0][0], points[0][1]); ctx.moveTo(points[0][0], points[0][1]);
@ -249,13 +258,31 @@ function sampleCellIndex(fx, fy) {
return indexOf(x, y); return indexOf(x, y);
} }
function seaCoverageSample(map, fx, fy) {
if (!map?.sea) return 0;
// Coastlines are raster-derived, but the renderer should not expose the raw
// cell stair-steps. Sample a small footprint around each pixel and blend the
// land/sea color at the edge; this keeps the mask stable while giving the
// visible coastline a vector-like anti-aliased curve.
const offsets = [
[0, 0], [-0.34, 0], [0.34, 0], [0, -0.34], [0, 0.34],
[-0.26, -0.26], [0.26, -0.26], [-0.26, 0.26], [0.26, 0.26],
];
let sum = 0;
for (const [ox, oy] of offsets) sum += fieldSample(map.sea, fx + ox, fy + oy);
return clamp(sum / offsets.length);
}
function isWaterSample(map, fx, fy) { function isWaterSample(map, fx, fy) {
// The generated terrain arrays are cell-centered, while pixels are drawn across return seaCoverageSample(map, fx, fy) >= 0.50;
// each cell. Water/land classification must therefore follow the discrete sea }
// mask, not the interpolated elevation value. Interpolating elevation near a
// coast makes the right/bottom side of land cells inherit sea values and leaves function mixRgb(a, b, t) {
// visible unpainted strips inside the smoothed coastline. return [
return Boolean(map.sea[sampleCellIndex(fx, fy)]); Math.round(a[0] + (b[0] - a[0]) * t),
Math.round(a[1] + (b[1] - a[1]) * t),
Math.round(a[2] + (b[2] - a[2]) * t),
];
} }
@ -315,16 +342,17 @@ function terrainColorContinuous(map, fx, fy, mode) {
let color; let color;
if (isWaterSample(map, fx, fy)) { const waterCoverage = seaCoverageSample(map, fx, fy);
const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map.elevation, fx, fy)) * 2.4); const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map.elevation, fx, fy)) * 2.4);
// Calm sky-blue water; less saturated than the previous bright cyan. const waterColor = [Math.round(174 - depth * 7), Math.round(203 + depth * 2), Math.round(224 + depth * 5)];
color = [Math.round(174 - depth * 7), Math.round(203 + depth * 2), Math.round(224 + depth * 5)];
} else if (mode === "development") { let landColor;
if (mode === "development") {
const dCity = distToNearest(map.modernCities, fx, fy); const dCity = distToNearest(map.modernCities, fx, fy);
const urban = clamp(1 - dCity / 25); const urban = clamp(1 - dCity / 25);
const density = map.populationDensity ? fieldSample(map.populationDensity, fx, fy) : urban; const density = map.populationDensity ? fieldSample(map.populationDensity, fx, fy) : urban;
const base = 235; const base = 235;
color = [ landColor = [
Math.round(base + density * 20), Math.round(base + density * 20),
Math.round(base + density * 5), Math.round(base + density * 5),
Math.round(230 + density * 10), Math.round(230 + density * 10),
@ -333,7 +361,7 @@ function terrainColorContinuous(map, fx, fy, mode) {
// 地形の基底色は標高のみに従わせる。 // 地形の基底色は標高のみに従わせる。
// 谷や微地形の見え方は陰影側で制御し、谷底だけが不自然に茶色化しないようにする。 // 谷や微地形の見え方は陰影側で制御し、谷底だけが不自然に茶色化しないようにする。
const e = fieldSample(map.elevation, fx, fy); const e = fieldSample(map.elevation, fx, fy);
color = interpolateColorStops(clamp(e), [ landColor = interpolateColorStops(clamp(e), [
[0.20, [231, 236, 223]], [0.20, [231, 236, 223]],
[0.30, [223, 231, 214]], [0.30, [223, 231, 214]],
[0.40, [213, 223, 201]], [0.40, [213, 223, 201]],
@ -351,6 +379,8 @@ function terrainColorContinuous(map, fx, fy, mode) {
]); ]);
} }
const coastBlend = clamp((waterCoverage - 0.36) / 0.28);
color = coastBlend > 0 ? mixRgb(landColor, waterColor, coastBlend) : landColor;
return blendOutside(color, isInside); return blendOutside(color, isInside);
} }
@ -515,8 +545,8 @@ function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) {
ctx.restore(); ctx.restore();
} }
function drawPath(ctx, path, color, width, dashed = false) { function drawPath(ctx, path, color, width, dashed = false, mode = "default") {
const points = vectorPath(path); const points = vectorPathMode(path, mode);
if (points.length < 2) return; if (points.length < 2) return;
ctx.save(); ctx.save();
ctx.lineCap = "round"; ctx.lineCap = "round";
@ -530,6 +560,119 @@ function drawPath(ctx, path, color, width, dashed = false) {
ctx.restore(); ctx.restore();
} }
function landOnlySubpaths(map, path, minCells = 2) {
if (!path || path.length < 2 || !map?.sea) return path?.length >= minCells ? [path] : [];
const chunks = [];
let cur = [];
for (const p of path) {
const [x, y] = p;
const land = inside(x, y) && !map.sea[indexOf(x, y)];
if (land) {
cur.push(p);
} else if (cur.length >= minCells) {
chunks.push(cur);
cur = [];
} else {
cur = [];
}
}
if (cur.length >= minCells) chunks.push(cur);
return chunks;
}
function drawLandPath(ctx, map, path, color, width, dashed = false, minCells = 2, mode = "default") {
for (const chunk of landOnlySubpaths(map, path, minCells)) drawPath(ctx, chunk, color, width, dashed, mode);
}
function specialTransportSubpaths(map, path, predicate, minCells = 1, includeShoulders = true, maxCoreCells = Infinity) {
if (!path || path.length < 2) return [];
const chunks = [];
let cur = [];
let core = 0;
function flush(nextPoint = null) {
if (cur.length && nextPoint && includeShoulders) cur.push(nextPoint);
if (cur.length >= Math.max(2, minCells) && core <= maxCoreCells) chunks.push(cur);
cur = [];
core = 0;
}
for (let idx = 0; idx < path.length; idx++) {
const [x, y] = path[idx];
const i = inside(x, y) ? indexOf(x, y) : -1;
const hit = i >= 0 && predicate(i, x, y);
if (hit) {
if (!cur.length && includeShoulders && idx > 0) cur.push(path[idx - 1]);
cur.push(path[idx]);
core++;
} else if (cur.length) {
flush(path[idx]);
}
}
flush(null);
return chunks;
}
function drawOffsetPolyline(ctx, points, offsetPx) {
if (!points || points.length < 2) return;
ctx.beginPath();
for (let i = 0; i < points.length; i++) {
const prev = points[Math.max(0, i - 1)];
const cur = points[i];
const next = points[Math.min(points.length - 1, i + 1)];
const dx = next[0] - prev[0];
const dy = next[1] - prev[1];
const len = Math.hypot(dx, dy) || 1;
const ox = -dy / len * offsetPx;
const oy = dx / len * offsetPx;
if (i === 0) ctx.moveTo(cur[0] + ox, cur[1] + oy);
else ctx.lineTo(cur[0] + ox, cur[1] + oy);
}
ctx.stroke();
}
function drawDottedOutlinePath(ctx, path, color, width, offsetPx, mode = "default") {
const points = vectorPathMode(path, mode);
if (points.length < 2) return;
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.setLineDash([1.8, 3.2]);
drawOffsetPolyline(ctx, points, offsetPx);
drawOffsetPolyline(ctx, points, -offsetPx);
ctx.restore();
}
function drawBridgeOverlay(ctx, map, path, width, mode = "road") {
const limit = mode === "expressway" ? 20 : 10;
const bridgeChunks = specialTransportSubpaths(map, path, (i) => map.sea?.[i], 2, true, limit);
for (const chunk of bridgeChunks) {
const vectorMode = mode === "expressway" ? "expressway" : "default";
drawPath(ctx, chunk, "rgba(255,255,255,0.98)", width + 2.0, false, vectorMode);
drawPath(ctx, chunk, mode === "expressway" ? "rgba(135, 160, 135, 0.95)" : "rgba(245, 225, 130, 1)", width + 0.2, false, vectorMode);
drawDottedOutlinePath(ctx, chunk, "rgba(55, 85, 130, 0.95)", 1.0, Math.max(1.8, width * 0.72), vectorMode);
}
}
function drawTunnelOverlay(ctx, map, path, width, mode = "road") {
if (mode !== "expressway") return;
const tunnelChunks = specialTransportSubpaths(
map,
path,
(i) => !map.sea?.[i] && (((map.elevation?.[i] || 0) >= 0.74 && (map.ridgeField?.[i] || 0) >= 0.46) || (map.naturalBarrierScore?.[i] || 0) >= 0.82),
2,
true,
10
);
for (const chunk of tunnelChunks) {
drawDottedOutlinePath(ctx, chunk, "rgba(42, 42, 42, 0.96)", 1.15, Math.max(1.9, width * 0.82), "expressway");
}
}
function drawExpresswayPath(ctx, map, path, color, width, dashed = false, minCells = 2) {
drawLandPath(ctx, map, path, color, width, dashed, minCells, "expressway");
}
// 魚の骨(私鉄記号)スタイルを描画するための専用関数 // 魚の骨(私鉄記号)スタイルを描画するための専用関数
function drawRailway(ctx, path, color, lineWidth, tickLen, spacing) { function drawRailway(ctx, path, color, lineWidth, tickLen, spacing) {
const points = vectorPath(path); const points = vectorPath(path);
@ -576,6 +719,10 @@ function drawRailway(ctx, path, color, lineWidth, tickLen, spacing) {
ctx.restore(); ctx.restore();
} }
function drawLandRailway(ctx, map, path, color, lineWidth, tickLen, spacing) {
for (const chunk of landOnlySubpaths(map, path, 3)) drawRailway(ctx, chunk, color, lineWidth, tickLen, spacing);
}
function drawSegments(ctx, segments, color, width, dashed = false) { function drawSegments(ctx, segments, color, width, dashed = false) {
ctx.save(); ctx.save();
ctx.strokeStyle = color; ctx.strokeStyle = color;
@ -724,39 +871,64 @@ function boxesOverlap(a, b, pad = 3) {
function labelWithCollision(ctx, p, occupied) { function labelWithCollision(ctx, p, occupied) {
if (!p.name) return false; if (!p.name) return false;
const isPrefectureLabel = p.kind === "Prefecture" || p.kind === "Current Prefecture" || p.isPrefectureLabel; const isPrefectureLabel = p.kind === "Prefecture" || p.kind === "Current Prefecture" || p.isPrefectureLabel;
const isMunicipalityLabel = p.labelStyle === "municipality";
ctx.save(); ctx.save();
ctx.font = isPrefectureLabel ctx.font = isPrefectureLabel
? "900 20px ui-sans-serif, system-ui, -apple-system, sans-serif" ? "900 20px ui-sans-serif, system-ui, -apple-system, sans-serif"
: "600 11.5px ui-sans-serif, system-ui, -apple-system, sans-serif"; : isMunicipalityLabel
? "600 10px ui-sans-serif, system-ui, -apple-system, sans-serif"
: "600 11.5px ui-sans-serif, system-ui, -apple-system, sans-serif";
const baseX = p.x * CELL_SIZE + CELL_SIZE / 2; const baseX = p.x * CELL_SIZE + CELL_SIZE / 2;
const baseY = p.y * CELL_SIZE + CELL_SIZE / 2; const baseY = p.y * CELL_SIZE + CELL_SIZE / 2;
const textW = ctx.measureText(p.name).width; const textW = ctx.measureText(p.name).width;
const textH = isPrefectureLabel ? 22 : 12; const textH = isPrefectureLabel ? 22 : isMunicipalityLabel ? 10 : 12;
const candidates = isPrefectureLabel const candidates = isPrefectureLabel
? [ ? [
[-textW / 2, 6], [-textW / 2, -12], [-textW / 2, 24], [-textW / 2, 6], [-textW / 2, -12], [-textW / 2, 24],
[10, 6], [-textW - 10, 6], [10, 6], [-textW - 10, 6],
] ]
: [ : isMunicipalityLabel
[7, -4], [7, 13], [-textW - 7, -4], [-textW - 7, 13], ? [
[-textW / 2, -12], [-textW / 2, 20], [12, 4], [-textW - 12, 4], [6, -4], [6, 11], [-textW - 6, -4], [-textW - 6, 11],
]; [-textW / 2, -10], [-textW / 2, 17], [10, 3], [-textW - 10, 3],
[4, -12], [-textW - 4, -12], [4, 18], [-textW - 4, 18],
]
: [
[7, -4], [7, 13], [-textW - 7, -4], [-textW - 7, 13],
[-textW / 2, -12], [-textW / 2, 20], [12, 4], [-textW - 12, 4],
];
let fallback = null;
for (const [ox, oy] of candidates) { for (const [ox, oy] of candidates) {
const x = baseX + ox; const x = baseX + ox;
const y = baseY + oy; const y = baseY + oy;
const box = { x1: x - 3, y1: y - textH, x2: x + textW + 3, y2: y + 5 }; const box = { x1: x - 3, y1: y - textH, x2: x + textW + 3, y2: y + 5 };
if (box.x1 < 0 || box.y1 < 0 || box.x2 > MAP_W * CELL_SIZE || box.y2 > MAP_H * CELL_SIZE) continue; if (box.x1 < 0 || box.y1 < 0 || box.x2 > MAP_W * CELL_SIZE || box.y2 > MAP_H * CELL_SIZE) continue;
if (occupied.some((b) => boxesOverlap(box, b, isPrefectureLabel ? 5 : 3))) continue; const overlaps = occupied.filter((b) => boxesOverlap(box, b, isPrefectureLabel ? 5 : isMunicipalityLabel ? 1 : 3));
if (!overlaps.length) {
ctx.lineJoin = "round";
ctx.lineWidth = isPrefectureLabel ? 6.2 : isMunicipalityLabel ? 3.0 : 3.5;
ctx.strokeStyle = isPrefectureLabel ? "rgba(255, 255, 255, 0.98)" : "rgba(255, 255, 255, 0.95)";
ctx.strokeText(p.name, x, y);
ctx.fillStyle = isPrefectureLabel ? "rgba(62, 38, 112, 0.98)" : p.isPrefecturalCapital ? "#111111" : isMunicipalityLabel ? "rgba(72, 62, 82, 0.92)" : "#333333";
ctx.fillText(p.name, x, y);
occupied.push(box);
ctx.restore();
return true;
}
if (p.forceLabel) {
const score = overlaps.length;
if (!fallback || score < fallback.score) fallback = { x, y, box, score };
}
}
if (p.forceLabel && fallback) {
ctx.lineJoin = "round"; ctx.lineJoin = "round";
ctx.lineWidth = isPrefectureLabel ? 6.2 : 3.5; ctx.lineWidth = isPrefectureLabel ? 6.2 : isMunicipalityLabel ? 3.0 : 3.5;
ctx.strokeStyle = isPrefectureLabel ? "rgba(255, 255, 255, 0.98)" : "rgba(255, 255, 255, 0.95)"; ctx.strokeStyle = isPrefectureLabel ? "rgba(255, 255, 255, 0.98)" : "rgba(255, 255, 255, 0.95)";
ctx.strokeText(p.name, x, y); ctx.strokeText(p.name, fallback.x, fallback.y);
ctx.fillStyle = isPrefectureLabel ? "rgba(62, 38, 112, 0.98)" : p.isPrefecturalCapital ? "#111111" : isMunicipalityLabel ? "rgba(72, 62, 82, 0.92)" : "#333333";
ctx.fillStyle = isPrefectureLabel ? "rgba(62, 38, 112, 0.98)" : p.isPrefecturalCapital ? "#111111" : "#333333"; ctx.fillText(p.name, fallback.x, fallback.y);
ctx.fillText(p.name, x, y); occupied.push(fallback.box);
occupied.push(box);
ctx.restore(); ctx.restore();
return true; return true;
} }
@ -764,18 +936,19 @@ function labelWithCollision(ctx, p, occupied) {
return false; return false;
} }
function drawLabels(ctx, points, limit = Infinity) { function drawLabels(ctx, points, limit = Infinity, occupied = null) {
const occupied = []; const used = occupied || [];
const prioritized = points const prioritized = points
.filter((p) => p?.name) .filter((p) => p?.name)
.map((p) => ({ ...p, labelPriority: (p.labelPriorityBase || 0) + (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 900 + (p.portClass === "major" ? 170 : p.portClass === "regional" ? 95 : 0) })) .map((p) => ({ ...p, labelPriority: (p.labelPriorityBase || 0) + (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 900 + (p.portClass === "major" ? 170 : p.portClass === "regional" ? 95 : 0) }))
.sort((a, b) => b.labelPriority - a.labelPriority); .sort((a, b) => b.labelPriority - a.labelPriority);
for (const p of prioritized.slice(0, limit)) labelWithCollision(ctx, p, occupied); for (const p of prioritized.slice(0, limit)) labelWithCollision(ctx, p, used);
return used;
} }
function drawScaleBar(ctx) { function drawScaleBar(ctx) {
const kmPerCell = 1; const kmPerCell = 1;
const targetKm = 50; const targetKm = 25;
const lengthCells = Math.max(8, Math.round(targetKm / kmPerCell)); const lengthCells = Math.max(8, Math.round(targetKm / kmPerCell));
const lengthPx = lengthCells * CELL_SIZE; const lengthPx = lengthCells * CELL_SIZE;
const margin = 14; const margin = 14;
@ -879,11 +1052,12 @@ export function drawMap(canvas, map, options) {
}, 1.0); }, 1.0);
} }
const showHistory = ["history", "all", "terrain"].includes(mode); const showHistory = mode === "history";
const showTransportDebug = mode === "transport-debug"; const showTransportDebug = mode === "transport-debug";
const showModern = ["modern", "all", "development", "landuse", "borders-debug", "transport-debug"].includes(mode); const showModern = ["modern", "all", "development", "landuse", "borders-debug", "transport-debug"].includes(mode);
const showRoads = ["all", "development", "transport-debug"].includes(mode); const showRoads = ["all", "development", "transport-debug"].includes(mode);
const showMinorRoads = ["all", "modern", "development", "transport-debug"].includes(mode); const showMinorRoads = ["all", "modern", "development", "transport-debug"].includes(mode);
const showPremodernRoads = ["history", "all", "transport-debug"].includes(mode);
const showAdmin = ["admin", "all", "borders-debug"].includes(mode); const showAdmin = ["admin", "all", "borders-debug"].includes(mode);
// 3. Borders // 3. Borders
@ -917,50 +1091,54 @@ export function drawMap(canvas, map, options) {
if (!showFeatures) return; if (!showFeatures) return;
// 4. Transport casings. Layer order: local roads, trunk roads, railways, expressways. // 4. Transport casings. Layer order: local roads, trunk roads, railways, expressways.
if (showHistory) { const localRoadCasing = "rgba(112, 112, 104, 0.58)";
for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(210, 210, 210, 0.7)", 2.5); const localRoadFill = "rgba(255, 255, 255, 0.98)";
} const generalRoadPaths = [
if (showMinorRoads) { ...(showPremodernRoads ? (map.premodernRoads || []) : []),
// Local roads need a visible casing on pale green lowland/farmland tiles. ...(showMinorRoads ? (map.minorRoads || []) : []),
// Keep the fill light, but use a warmer grey outline rather than a nearly ];
// invisible white-on-green stroke. if (generalRoadPaths.length) {
for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(132, 126, 112, 0.72)", 2.75); // Ordinary roads: white centerline with a restrained grey casing. Both the
// current generated local roads and premodernRoads use the same appearance
// in all / transport-debug so the old white layer no longer reads as a
// second road system.
for (const path of generalRoadPaths) drawLandPath(ctx, map, path, localRoadCasing, 3.05);
} }
if (showRoads) { if (showRoads) {
for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8); for (const path of map.nationalRoads) drawLandPath(ctx, map, path, "rgba(190, 175, 140, 1)", 3.8);
for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8); for (const path of map.ringRoads || []) drawLandPath(ctx, map, path, "rgba(190, 175, 140, 1)", 3.8);
for (const path of map.externalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8); for (const path of map.externalRoads) drawLandPath(ctx, map, path, "rgba(190, 175, 140, 1)", 3.8);
} }
if (showModern || showRoads) { if (showModern || showRoads) {
for (const path of map.railways) drawPath(ctx, path, "rgba(255, 255, 255, 0.78)", 3.6); for (const path of map.railways) drawLandPath(ctx, map, path, "rgba(255, 255, 255, 0.78)", 3.6, false, 3);
for (const path of map.branchRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.64)", 2.6); for (const path of map.branchRailways) drawLandPath(ctx, map, path, "rgba(255, 255, 255, 0.64)", 2.6, false, 3);
for (const path of map.externalRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.78)", 3.6); for (const path of map.externalRailways) drawLandPath(ctx, map, path, "rgba(255, 255, 255, 0.78)", 3.6, false, 3);
} }
if (showRoads) { if (showRoads) {
for (const path of map.expressways) drawPath(ctx, path, "rgba(105, 125, 105, 0.78)", 4.6); for (const path of map.expressways) drawExpresswayPath(ctx, map, path, "rgba(105, 125, 105, 0.78)", 4.6);
for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(105, 125, 105, 0.78)", 4.6); for (const path of map.externalExpressways) drawExpresswayPath(ctx, map, path, "rgba(105, 125, 105, 0.78)", 4.6);
} }
// 5. Transport fills. Expressways remain above railways; railways sit above ordinary roads. // 5. Transport fills. Expressways remain above railways; railways sit above ordinary roads.
if (showHistory) { if (generalRoadPaths.length) {
for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(255, 255, 255, 1)", 1.2, false); for (const path of generalRoadPaths) drawLandPath(ctx, map, path, localRoadFill, 1.45, false);
}
if (showMinorRoads) {
for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(255, 253, 244, 0.98)", 1.25, false);
} }
if (showRoads) { if (showRoads) {
for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0); for (const path of map.nationalRoads) drawLandPath(ctx, map, path, "rgba(245, 225, 130, 1)", 2.0);
for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0); for (const path of map.ringRoads || []) drawLandPath(ctx, map, path, "rgba(245, 225, 130, 1)", 2.0);
for (const path of map.externalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0); for (const path of map.externalRoads) drawLandPath(ctx, map, path, "rgba(245, 225, 130, 1)", 2.0);
} }
if (showModern || showRoads) { if (showModern || showRoads) {
for (const path of map.railways) drawRailway(ctx, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0); for (const path of map.railways) drawLandRailway(ctx, map, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0);
for (const path of map.branchRailways) drawRailway(ctx, path, "rgba(125, 125, 125, 1)", 1.05, 4.0, 6.0); for (const path of map.branchRailways) drawLandRailway(ctx, map, path, "rgba(125, 125, 125, 1)", 1.05, 4.0, 6.0);
for (const path of map.externalRailways) drawRailway(ctx, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0); for (const path of map.externalRailways) drawLandRailway(ctx, map, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0);
} }
if (showRoads) { if (showRoads) {
for (const path of map.expressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4); for (const path of generalRoadPaths) { drawBridgeOverlay(ctx, map, path, 1.55, "road"); }
for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4); for (const path of map.nationalRoads) { drawBridgeOverlay(ctx, map, path, 2.0, "road"); }
for (const path of map.externalRoads) { drawBridgeOverlay(ctx, map, path, 2.0, "road"); }
for (const path of map.expressways) { drawBridgeOverlay(ctx, map, path, 2.45, "expressway"); drawTunnelOverlay(ctx, map, path, 2.25, "expressway"); }
for (const path of map.externalExpressways) { drawBridgeOverlay(ctx, map, path, 2.45, "expressway"); drawTunnelOverlay(ctx, map, path, 2.25, "expressway"); }
} }
// 6. Icons & Labels // 6. Icons & Labels
@ -1008,23 +1186,26 @@ export function drawMap(canvas, map, options) {
drawScaleBar(ctx); drawScaleBar(ctx);
return; 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" const allLayerTowns = mode === "all"
? [ ? [
...(map.markets || []).filter((p) => (p.population || 0) >= 5000), ...(map.markets || []).filter((p) => (p.population || 0) >= 5000),
...(map.villages || []).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))
.filter((p) => !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y))
.map((p) => ({ ...p, forceAllLayerTownLabel: true, labelPriorityBase: p.kind === "Village" || p.kind === "Valley Village" || p.kind === "Coastal Village" ? 75 : 135 }))
: []; : [];
const important = [ const important = [
...prefectureLabels, ...prefectureLabels,
...map.modernCities, ...map.modernCities,
...map.ports, ...(map.ports || []).map((p) => ({
...p,
labelPriorityBase: p.portClass === "major" ? 170 : p.portClass === "regional" ? 120 : p.portClass === "fishing" ? 95 : 85,
})),
...(map.logisticsParks || []).map((p) => ({ ...p, labelPriorityBase: 60 })), ...(map.logisticsParks || []).map((p) => ({ ...p, labelPriorityBase: 60 })),
...(map.satelliteCities || []), ...(map.satelliteCities || []),
...allLayerTowns, ...allLayerTowns.map((p) => ({ ...p, forceAllLayerTownLabel: true, labelPriorityBase: 80 })),
].filter((p) => p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000); ].filter((p) => p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000);
drawLabels(ctx, important, mode === "all" ? 85 : 60); drawLabels(ctx, important, mode === "all" ? 95 : 60);
} }
drawScaleBar(ctx); drawScaleBar(ctx);
} }

View file

@ -8,7 +8,7 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
.header h1{margin:0 0 6px;font-size:24px;letter-spacing:-0.02em;color:#1a1a1a;font-weight:700} .header h1{margin:0 0 6px;font-size:24px;letter-spacing:-0.02em;color:#1a1a1a;font-weight:700}
.header p{margin:0;color:#5a5a5a;line-height:1.6;font-size:14px} .header p{margin:0;color:#5a5a5a;line-height:1.6;font-size:14px}
.canvas-shell,.card{background:#ffffff;border:1px solid rgba(0,0,0,0.08);border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.04)} .canvas-shell,.card{background:#ffffff;border:1px solid rgba(0,0,0,0.08);border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.04)}
.canvas-shell{padding:12px;overflow:auto;position:relative} .canvas-shell{padding:12px;overflow:auto;position:relative;cursor:grab;user-select:none;touch-action:none}
.map-canvas{display:block;border-radius:8px;background:#f8f9fa} .map-canvas{display:block;border-radius:8px;background:#f8f9fa}
.sidebar{display:flex;flex-direction:column;gap:12px} .sidebar{display:flex;flex-direction:column;gap:12px}
.card{padding:14px} .card{padding:14px}
@ -69,3 +69,6 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
.progress-stage{color:#5f6368;margin-bottom:10px} .progress-stage{color:#5f6368;margin-bottom:10px}
.progress-timings{display:flex;flex-direction:column;gap:4px;font-family:ui-monospace,monospace;font-size:12px;color:#3c4043} .progress-timings{display:flex;flex-direction:column;gap:4px;font-family:ui-monospace,monospace;font-size:12px;color:#3c4043}
.progress-timing-row{display:flex;justify-content:space-between;gap:16px;border-top:1px solid rgba(0,0,0,0.06);padding-top:4px} .progress-timing-row{display:flex;justify-content:space-between;gap:16px;border-top:1px solid rgba(0,0,0,0.06);padding-top:4px}
.canvas-shell.panning{cursor:grabbing}
.canvas-shell.panning .map-canvas{pointer-events:none}