chinko
This commit is contained in:
parent
0359bb2445
commit
b17be0e0d2
21 changed files with 8034 additions and 2737 deletions
|
|
@ -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
77
app.js
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
744
mapAdminCompartmentRepair.js
Normal file
744
mapAdminCompartmentRepair.js
Normal 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
200
mapAdminSeedLifecycle.js
Normal 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
80
mapAdminShared.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
2101
mapAdminStage.js
2101
mapAdminStage.js
File diff suppressed because it is too large
Load diff
190
mapAdminTargets.js
Normal file
190
mapAdminTargets.js
Normal 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
239
mapAdminUrbanCatchments.js
Normal 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
1276
mapFeatures.js
1276
mapFeatures.js
File diff suppressed because it is too large
Load diff
387
mapGeography.js
Normal file
387
mapGeography.js
Normal 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),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
237
mapOutput.js
237
mapOutput.js
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
312
mapPostAdminTransport.js
Normal 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
863
mapPrefectureStage.js
Normal 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),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
2632
mapTransport.js
2632
mapTransport.js
File diff suppressed because it is too large
Load diff
415
mapTransportOD.js
Normal file
415
mapTransportOD.js
Normal 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
229
mapTransportUtils.js
Normal 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 };
|
||||||
|
}
|
||||||
63
names.js
63
names.js
|
|
@ -1,24 +1,13 @@
|
||||||
import { MAP_H, MAP_W, hash2, indexOf, inside } from "./mapUtils.js";
|
import { MAP_H, MAP_W, hash2, indexOf, inside } from "./mapUtils.js";
|
||||||
|
|
||||||
export const CUSTOM_NAME_LIST = ["加茂", "春日", "新庄", "常盤", "追分", "清水", "曙", "暁", "旭", "鳥羽", "長門", "吉野", "吉田", "美郷", "美里", "美山", "八幡", "相生",];
|
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++;
|
||||||
|
|
|
||||||
331
renderer.js
331
renderer.js
|
|
@ -1,4 +1,4 @@
|
||||||
import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf } from "./mapUtils.js";
|
import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf, inside } from "./mapUtils.js";
|
||||||
|
|
||||||
|
|
||||||
const segmentVectorCache = new WeakMap();
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue