town tweak

This commit is contained in:
33333-33333 2026-05-24 17:38:51 +09:00
commit 1ea8ba1701
12 changed files with 5005 additions and 379 deletions

View file

@ -558,20 +558,22 @@ function canShareNaturalCompartment(a, b, classA, classB, barrier, river, flowAc
const bothLivingCorridor = [5, 6, 7, 10].includes(classA) && [5, 6, 7, 10].includes(classB);
if (!bothUrban && !bothLivingCorridor) return false;
}
const majorRiverEdge = Math.max(river[a], river[b]) > 0.58 || Math.max(flowAccum[a], flowAccum[b]) > 0.72;
const majorRiverEdge = Math.max(river[a], river[b]) > 0.56 || Math.max(flowAccum[a], flowAccum[b]) > 0.68;
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.42 && !majorRiverEdge;
const threshold = urbanEdge ? 0.84 : valleyContinuity ? 0.76 : classA === 8 || classB === 8 ? 0.42 : 0.62;
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;
return barrier < threshold && (!majorRiverEdge || urbanEdge);
}
function refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse) {
let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = unit.riverExposure || 0;
let coastalExposure = 0, basinIdentity = 0, valleyIdentity = 0, lowlandFitness = 0, mountainFitness = 0;
let minX = MAP_W, minY = MAP_H, maxX = 0, maxY = 0;
for (const i of unit.cells) {
const [x, y] = xyOf(i);
sx += x; sy += y; pop += populationDensity[i];
minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
urbanWeight += urbanBoundaryPenalty(i, populationDensity, landuse);
ridgeExposure += ridgeField[i];
coastalExposure += coastalLowland[i];
@ -584,6 +586,13 @@ function refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField
unit.area = area;
unit.x = sx / Math.max(1, area);
unit.y = sy / Math.max(1, area);
unit.minX = area ? minX : 0;
unit.minY = area ? minY : 0;
unit.maxX = area ? maxX : 0;
unit.maxY = area ? maxY : 0;
unit.width = area ? maxX - minX + 1 : 0;
unit.height = area ? maxY - minY + 1 : 0;
unit.elongation = Math.max(unit.width, unit.height) / Math.max(1, Math.min(unit.width, unit.height));
unit.population = pop;
unit.urbanWeight = urbanWeight / Math.max(1, area);
unit.ridgeExposure = ridgeExposure / Math.max(1, area);
@ -596,13 +605,25 @@ function refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField
}
function splitOneNaturalCompartment(unit, newId, compartmentId, fields, seed) {
if (!unit || unit.area < 28 || unit.lowlandFitness < 0.24 || unit.mountainFitness > 0.72) return null;
const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore } = fields;
let first = -1, second = -1, bestA = -INF, bestB = -INF;
if (!unit || unit.area < 24) return null;
const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum } = fields;
const minPart = Math.max(8, Math.min(28, Math.floor(unit.area * 0.20)));
let first = -1;
let second = -1;
let bestA = -INF;
let bestB = -INF;
const width = unit.width || (unit.maxX - unit.minX + 1) || 1;
const height = unit.height || (unit.maxY - unit.minY + 1) || 1;
const horizontal = width >= height;
const elongated = Math.max(width, height) / Math.max(1, Math.min(width, height)) > 1.65;
for (const i of unit.cells) {
const [x, y] = xyOf(i);
const low = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
const score = low + populationDensity[i] * 0.22 + hashSeededTie(x, y, seed) * 0.04;
const settled = populationDensity[i] * 0.28 + ([2, 3, 4, 7, 8].includes(landuse[i]) ? 0.34 : 0);
const axis = elongated ? (horizontal ? (unit.maxX - x) / Math.max(1, width) : (unit.maxY - y) / Math.max(1, height)) : 0.0;
const score = axis * 1.7 + low * 0.42 + settled + hashSeededTie(x, y, seed) * 0.05 - ridgeField[i] * 0.10;
if (score > bestA) { bestA = score; first = i; }
}
if (first < 0) return null;
@ -610,36 +631,54 @@ function splitOneNaturalCompartment(unit, newId, compartmentId, fields, seed) {
for (const i of unit.cells) {
const [x, y] = xyOf(i);
const low = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
const axis = elongated ? (horizontal ? (x - unit.minX) / Math.max(1, width) : (y - unit.minY) / Math.max(1, height)) : 0.0;
const d = Math.hypot(x - fx, y - fy);
const score = d * (0.55 + low * 0.45) + hashSeededTie(x, y, seed + 17) * 0.20;
const score = axis * 1.9 + d * (0.18 + low * 0.22) + hashSeededTie(x, y, seed + 17) * 0.08 - ridgeField[i] * 0.08;
if (score > bestB) { bestB = score; second = i; }
}
if (second < 0 || second === first) return null;
const cellSet = new Set(unit.cells);
const localOwner = new Map([[first, 0], [second, 1]]);
const queue = [first, second];
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const owner = localOwner.get(cur);
const [x, y] = xyOf(cur);
for (const [nx, ny] of neighbors4(x, y)) {
const owner = new Int8Array(SIZE);
owner.fill(-1);
const dist = new Float32Array(SIZE);
dist.fill(INF);
const heap = new MinHeap();
for (const [source, sourceOwner] of [[first, 0], [second, 1]]) {
owner[source] = sourceOwner;
dist[source] = 0;
heap.push({ i: source, f: 0, owner: sourceOwner });
}
while (heap.length > 0) {
const cur = heap.pop();
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
const [x, y] = xyOf(cur.i);
for (const [nx, ny, step] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
if (!cellSet.has(ni) || localOwner.has(ni)) continue;
localOwner.set(ni, owner);
queue.push(ni);
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 nd = cur.f + stepCost;
if (nd < dist[ni]) {
dist[ni] = nd;
owner[ni] = cur.owner;
heap.push({ i: ni, f: nd, owner: cur.owner });
}
}
}
for (const ci of unit.cells) if (!localOwner.has(ci)) {
const [x, y] = xyOf(ci);
const d0 = Math.hypot(x - fx, y - fy);
const [sx, sy] = xyOf(second);
const d1 = Math.hypot(x - sx, y - sy);
localOwner.set(ci, d0 <= d1 ? 0 : 1);
const aCells = [];
const bCells = [];
for (const ci of unit.cells) {
if (owner[ci] === 1) bCells.push(ci);
else aCells.push(ci);
}
const aCells = [], bCells = [];
for (const ci of unit.cells) (localOwner.get(ci) === 0 ? aCells : bCells).push(ci);
if (aCells.length < 10 || bCells.length < 10) return null;
if (aCells.length < minPart || bCells.length < minPart) return null;
unit.cells = aCells;
const newUnit = { ...unit, id: newId, cells: bCells, centerIds: [], adjacent: new Map() };
for (const ci of bCells) compartmentId[ci] = newId;
@ -663,7 +702,369 @@ function naturalGroupKey(unit) {
return `plain:${Math.round(unit.x / 14)}:${Math.round(unit.y / 14)}`;
}
function 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 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;
seen[ni] = 1;
queue.push(ni);
}
}
components.push(cells);
}
return components;
}
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);
const mountain = mountainCompartmentFitness(i, elevation, slope, ridgeField, populationDensity, landuse);
const stableInterior = clamp(1 - (naturalBarrierScore[i] || 0));
const settlement = clamp(populationDensity[i] * 0.65 + (klassUrban ? 0.24 : 0));
const streamCorridor = clamp(valleyField[i] * 0.28 + river[i] * 0.08);
const mountainInterior = clamp(mountain * 0.45 + stableInterior * 0.28 - ridgeField[i] * 0.22);
return stableInterior * 0.56 + lowland * 0.42 + mountainInterior * 0.32 + settlement * 0.26 + streamCorridor + hashSeededTie(...xyOf(i), seed) * 0.13 - slope[i] * 0.10;
}
function chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, seed) {
const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore } = fields;
const totalArea = landComponents.reduce((sum, cells) => sum + cells.length, 0);
const seeds = [];
const seedComponentId = [];
const minCellsPerUnit = 9;
let remainingTarget = Math.max(1, Math.min(targetCount || Math.round(totalArea / 42), Math.floor(totalArea / minCellsPerUnit)));
const sortedComponents = landComponents
.map((cells, componentIndex) => ({ cells, componentIndex, area: cells.length }))
.sort((a, b) => b.area - a.area);
for (let componentOrder = 0; componentOrder < sortedComponents.length; componentOrder++) {
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);
localTarget = Math.min(localTarget, Math.max(1, Math.floor(area / minCellsPerUnit)));
if (componentOrder === sortedComponents.length - 1) localTarget = Math.max(1, Math.min(localTarget, remainingTarget));
remainingTarget -= localTarget;
const candidates = cells
.map((i) => ({ i, score: naturalSeedScore(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, seed + componentIndex * 1009) }))
.sort((a, b) => b.score - a.score);
const localSeeds = [];
const idealSpacing = Math.sqrt(area / Math.max(1, localTarget));
const spacingPasses = [0.95, 0.78, 0.62, 0.48, 0.34];
for (const factor of spacingPasses) {
const minDist = Math.max(2.2, idealSpacing * factor);
for (const candidate of candidates) {
if (localSeeds.length >= localTarget) break;
const [x, y] = xyOf(candidate.i);
let ok = true;
for (const existing of localSeeds) {
const [ex, ey] = xyOf(existing);
if (Math.hypot(x - ex, y - ey) < minDist) { ok = false; break; }
}
if (ok) localSeeds.push(candidate.i);
}
if (localSeeds.length >= localTarget) break;
}
for (const i of localSeeds) {
seeds.push(i);
seedComponentId.push(componentIndex);
}
}
if (seeds.length === 0 && landComponents[0]?.length) {
seeds.push(landComponents[0][0]);
seedComponentId.push(0);
}
return { seeds, seedComponentId };
}
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;
const ridge = Math.max(ridgeField[a], ridgeField[b]);
const riverEdge = Math.max(river[a], river[b]);
const flowEdge = Math.max(flowAccum?.[a] || 0, flowAccum?.[b] || 0);
const majorRiverCrossing = riverEdge > 0.44 || flowEdge > 0.55;
const elevationBreak = Math.abs(elevation[a] - elevation[b]);
const slopeBreak = Math.max(slope[a], slope[b]);
const classBreak = cellClass[a] !== cellClass[b] ? 0.34 : -0.08;
const bothUrban = ((landuse[a] >= 2 && landuse[a] <= 4) || landuse[a] === 7 || populationDensity[a] > 0.30) &&
((landuse[b] >= 2 && landuse[b] <= 4) || landuse[b] === 7 || populationDensity[b] > 0.30);
const lowlandContinuity = Math.min(
(plain?.[a] || 0) + (agriculture?.[a] || 0) * 0.35 + basinField[a] * 0.25 + coastalLowland[a] * 0.20,
(plain?.[b] || 0) + (agriculture?.[b] || 0) * 0.35 + basinField[b] * 0.25 + coastalLowland[b] * 0.20
);
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;
return Math.max(0.16,
0.72 +
barrier * 5.1 +
ridge * 0.82 +
elevationBreak * 3.0 +
slopeBreak * 0.56 +
riverPenalty +
classBreak -
corridorBonus
);
}
function buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields) {
const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse } = fields;
let maxId = -1;
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && compartmentId[i] > maxId) maxId = compartmentId[i];
const units = Array.from({ length: maxId + 1 }, (_, id) => ({ id, cells: [], centerIds: [], adjacent: new Map(), area: 0 }));
for (let i = 0; i < SIZE; i++) {
const id = compartmentId[i];
if (id >= 0 && units[id]) units[id].cells.push(i);
}
for (const unit of units) {
if (!unit.cells.length) { unit.area = 0; continue; }
const counts = new Map();
for (const ci of unit.cells) counts.set(cellClass[ci], (counts.get(cellClass[ci]) || 0) + 1);
let klass = -1, best = -1;
for (const [k, count] of counts) if (count > best) { best = count; klass = k; }
unit.classId = klass;
unit.dominantLandscapeClass = klass;
refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
let riverExposure = 0;
for (const ci of unit.cells) riverExposure += river[ci] + (fields.flowAccum?.[ci] || 0) * 0.45;
unit.riverExposure = riverExposure / Math.max(1, unit.area);
}
return units;
}
function splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea) {
const queue = [];
for (const unit of [...compartments]) {
if (!unit || unit.area === 0 || !unit.cells?.length) continue;
const unitCellSet = new Set(unit.cells);
const seen = new Set();
const components = [];
for (const start of unit.cells) {
if (seen.has(start) || compartmentId[start] !== unit.id) continue;
const cells = [];
queue.length = 0;
queue.push(start);
seen.add(start);
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 (!prefectureMask[ni] || sea[ni] || seen.has(ni) || compartmentId[ni] !== unit.id || !unitCellSet.has(ni)) continue;
seen.add(ni);
queue.push(ni);
}
}
components.push(cells);
}
if (components.length <= 1) continue;
components.sort((a, b) => b.length - a.length);
unit.cells = components[0];
for (const extra of components.slice(1)) {
const newId = compartments.length;
for (const ci of extra) compartmentId[ci] = newId;
compartments.push({ id: newId, cells: extra, centerIds: [], adjacent: new Map(), classId: unit.classId, dominantLandscapeClass: unit.dominantLandscapeClass });
}
}
}
function renumberCompartments(compartmentId, compartments, prefectureMask, sea) {
const active = compartments.filter((unit) => unit && unit.area > 0 && unit.cells?.length);
const idMap = new Map();
active.forEach((unit, newId) => idMap.set(unit.id, newId));
for (let i = 0; i < SIZE; i++) {
const id = compartmentId[i];
if (!prefectureMask[i] || sea[i]) compartmentId[i] = -1;
else if (idMap.has(id)) compartmentId[i] = idMap.get(id);
}
active.forEach((unit, newId) => { unit.id = newId; unit.centerIds = []; });
return active;
}
function refreshAllCompartmentStats(compartments, fields) {
const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, river, flowAccum } = fields;
for (const unit of compartments) {
if (!unit || unit.area === 0 || !unit.cells?.length) continue;
refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
let riverExposure = 0;
const counts = new Map();
for (const ci of unit.cells) {
riverExposure += river[ci] + (flowAccum?.[ci] || 0) * 0.45;
if (unit._cellClass) counts.set(unit._cellClass[ci], (counts.get(unit._cellClass[ci]) || 0) + 1);
}
unit.riverExposure = riverExposure / Math.max(1, unit.area);
}
}
function splitNaturalCompartmentCompact(unit, newId, compartmentId, fields, seed) {
if (!unit || unit.area < 20) return null;
const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum, cellClass } = fields;
const minPart = Math.max(7, Math.min(30, Math.floor(unit.area * 0.18)));
let cx = unit.x || 0, cy = unit.y || 0;
let first = -1, second = -1, bestA = -INF, bestB = -INF;
const elongated = (unit.elongation || 1) > 2.3;
const horizontal = (unit.width || 0) >= (unit.height || 0);
for (const i of unit.cells) {
const [x, y] = xyOf(i);
const centerDist = Math.hypot(x - cx, y - cy);
const axis = elongated ? Math.abs((horizontal ? x - cx : y - cy)) / Math.max(1, horizontal ? unit.width : unit.height) : 0;
const interior = 1 - (naturalBarrierScore[i] || 0);
const score = centerDist * 0.13 + axis * 1.1 + interior * 0.35 + hashSeededTie(x, y, seed) * 0.08 - ridgeField[i] * 0.10;
if (score > bestA) { bestA = score; first = i; }
}
if (first < 0) return null;
const [fx, fy] = xyOf(first);
for (const i of unit.cells) {
const [x, y] = xyOf(i);
const d = Math.hypot(x - fx, y - fy);
const interior = 1 - (naturalBarrierScore[i] || 0);
const score = d * 0.20 + interior * 0.38 + hashSeededTie(x, y, seed + 31) * 0.08 - ridgeField[i] * 0.08;
if (score > bestB) { bestB = score; second = i; }
}
if (second < 0 || second === first) return null;
const cellSet = new Set(unit.cells);
const owner = new Int8Array(SIZE);
owner.fill(-1);
const dist = new Float32Array(SIZE);
dist.fill(INF);
const heap = new MinHeap();
for (const [source, sourceOwner] of [[first, 0], [second, 1]]) {
owner[source] = sourceOwner;
dist[source] = 0;
heap.push({ i: source, f: 0, owner: sourceOwner });
}
while (heap.length > 0) {
const cur = heap.pop();
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
const [x, y] = xyOf(cur.i);
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;
if (nd < dist[ni]) {
dist[ni] = nd;
owner[ni] = cur.owner;
heap.push({ i: ni, f: nd, owner: cur.owner });
}
}
}
const aCells = [], bCells = [];
for (const ci of unit.cells) (owner[ci] === 1 ? bCells : aCells).push(ci);
if (aCells.length < minPart || bCells.length < minPart) return null;
unit.cells = aCells;
for (const ci of bCells) compartmentId[ci] = newId;
const newUnit = { id: newId, cells: bCells, 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 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 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)));
const { seeds } = chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, options.seed || 0);
const compartmentId = new Int32Array(SIZE);
compartmentId.fill(-1);
const dist = new Float32Array(SIZE);
dist.fill(INF);
const heap = new MinHeap();
seeds.forEach((i, id) => {
compartmentId[i] = id;
dist[i] = 0;
heap.push({ i, f: 0, id });
});
while (heap.length > 0) {
const cur = heap.pop();
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
const [x, y] = xyOf(cur.i);
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;
if (nd < dist[ni]) {
dist[ni] = nd;
compartmentId[ni] = cur.id;
heap.push({ i: ni, f: nd, id: cur.id });
}
}
}
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && compartmentId[i] < 0) compartmentId[i] = 0;
let compartments = buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields);
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
mergeTinyLandscapeUnits(compartmentId, compartments, 9);
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea);
refreshAllCompartmentStats(compartments, fields);
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(28, Math.round(landArea / Math.max(1, targetCount) * 1.55));
let guard = Math.max(80, targetCount * 3);
while (guard-- > 0) {
let active = compartments.filter((unit) => unit && unit.area > 0);
const needMore = active.length < targetCount;
const worst = active
.filter((unit) => unit.area >= 20 && (needMore || unit.area > maxNaturalCompartmentArea * 1.18 || (unit.elongation || 1) > 4.2))
.sort((a, b) => {
const sa = (a.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (a.elongation || 1) - 3.0) * 1.2;
const sb = (b.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (b.elongation || 1) - 3.0) * 1.2;
return sb - sa;
})[0];
if (!worst) break;
const newUnit = splitNaturalCompartmentCompact(worst, compartments.length, compartmentId, fields, (options.seed || 0) + guard * 97);
if (!newUnit) {
worst._splitRejected = (worst._splitRejected || 0) + 1;
if (worst._splitRejected > 2) worst.elongation = Math.min(worst.elongation || 1, 3.1);
if (!needMore) break;
continue;
}
compartments.push(newUnit);
if (compartments.filter((unit) => unit && unit.area > 0).length >= targetCount && newUnit.area <= maxNaturalCompartmentArea) {
const stillBad = compartments.some((unit) => unit && unit.area >= 20 && (
unit.area > maxNaturalCompartmentArea * 1.35 ||
((unit.elongation || 1) > 4.2 && unit.area > 28)
));
if (!stillBad) break;
}
}
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea);
refreshAllCompartmentStats(compartments, fields);
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
return { compartmentId, compartments, naturalBarrierScore };
}
export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) {
return buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse, options);
const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse);
const compartmentId = new Int32Array(SIZE);
compartmentId.fill(-1);
@ -679,6 +1080,7 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope,
const startClass = cellClass[i];
const cells = [];
let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = 0, coastalExposure = 0, basinIdentity = 0, valleyIdentity = 0;
let minX = MAP_W, minY = MAP_H, maxX = 0, maxY = 0;
queue.length = 0;
queue.push(i);
compartmentId[i] = id;
@ -687,6 +1089,7 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope,
const [x, y] = xyOf(cur);
cells.push(cur);
sx += x; sy += y; pop += populationDensity[cur];
minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
urbanWeight += urbanBoundaryPenalty(cur, populationDensity, landuse);
ridgeExposure += ridgeField[cur];
riverExposure += river[cur] + flowAccum[cur] * 0.45;
@ -711,6 +1114,13 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope,
y: sy / area,
classId: startClass,
dominantLandscapeClass: startClass,
minX,
minY,
maxX,
maxY,
width: maxX - minX + 1,
height: maxY - minY + 1,
elongation: Math.max(maxX - minX + 1, maxY - minY + 1) / Math.max(1, Math.min(maxX - minX + 1, maxY - minY + 1)),
population: pop,
urbanWeight: urbanWeight / area,
ridgeExposure: ridgeExposure / area,
@ -730,22 +1140,47 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope,
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
const targetCount = options.targetCompartmentCount || 0;
if (targetCount > 0) {
const fields = { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore };
let guard = targetCount * 3;
const fields = { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum };
const landArea = compartments.reduce((sum, unit) => sum + (unit.area || 0), 0);
const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(34, Math.round(landArea / Math.max(1, targetCount) * 1.65));
const splitScore = (unit) => {
const elongated = Math.max(0, (unit.elongation || 1) - 2.1);
const areaPressure = unit.area / Math.max(1, maxNaturalCompartmentArea);
const settled = (unit.lowlandFitness || 0) * 0.65 + (unit.urbanWeight || 0) * 0.35;
return areaPressure * 2.2 + elongated * 1.4 + settled - (unit.mountainFitness || 0) * 0.20;
};
let guard = Math.max(targetCount * 4, 80);
while (compartments.filter((unit) => unit.area > 0).length < targetCount && guard-- > 0) {
const candidates = compartments
.filter((unit) => unit.area > 0 && unit.lowlandFitness > 0.24 && unit.mountainFitness < 0.74 && unit.area >= 28)
.sort((a, b) => (b.area * (0.45 + b.lowlandFitness) - b.mountainFitness * 80) - (a.area * (0.45 + a.lowlandFitness) - a.mountainFitness * 80));
.filter((unit) => unit.area > 0 && unit.area >= 24 && ((unit.lowlandFitness || 0) > 0.18 || unit.area > maxNaturalCompartmentArea * 1.20 || (unit.elongation || 1) > 2.8))
.sort((a, b) => splitScore(b) - splitScore(a));
const target = candidates[0];
if (!target) break;
const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard);
if (!newUnit) {
target.lowlandFitness = 0;
target._splitRejected = (target._splitRejected || 0) + 1;
target.elongation = Math.max(1, (target.elongation || 1) * 0.72);
if (target._splitRejected > 2) target.area = target.cells.length;
continue;
}
compartments.push(newUnit);
if (compartments.filter((unit) => unit.area > 0).length % 12 === 0) rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
}
guard = Math.max(targetCount * 2, 60);
while (guard-- > 0) {
const target = compartments
.filter((unit) => unit.area > 0 && unit.area >= 24 && (unit.area > maxNaturalCompartmentArea * 1.55 || ((unit.elongation || 1) > 3.2 && unit.area > maxNaturalCompartmentArea * 0.85)))
.sort((a, b) => splitScore(b) - splitScore(a))[0];
if (!target) break;
const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard + 991);
if (!newUnit) {
target.elongation = Math.max(1, (target.elongation || 1) * 0.70);
break;
}
compartments.push(newUnit);
if (compartments.filter((unit) => unit.area > 0).length % 12 === 0) rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
}
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
}
return { compartmentId, compartments, naturalBarrierScore };
@ -1081,6 +1516,12 @@ export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, e
...relationMetrics,
compartmentBorders: extractCompartmentBorders(compartmentId, prefectureMask, sea),
averageCompartmentArea: activeCompartments.length ? activeCompartments.reduce((sum, unit) => sum + unit.area, 0) / activeCompartments.length : 0,
maxCompartmentArea: activeCompartments.length ? Math.max(...activeCompartments.map((unit) => unit.area || 0)) : 0,
maxCompartmentElongation: activeCompartments.length ? Math.max(...activeCompartments.map((unit) => unit.elongation || 1)) : 1,
worstNaturalCompartments: activeCompartments
.map((unit) => ({ id: unit.id, area: unit.area || 0, width: unit.width || 0, height: unit.height || 0, elongation: unit.elongation || 1, classId: unit.classId, x: Math.round(unit.x || 0), y: Math.round(unit.y || 0) }))
.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),
voronoiLikeRateBefore: 0,
voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore),