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

View file

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