2026-05-24 17:38:51 +09:00
|
|
|
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, indexOf, inside, weightedScore, xyOf } from "./mapUtils.js";
|
|
|
|
|
|
|
|
|
|
function neighbors8(x, y) {
|
|
|
|
|
const out = [];
|
|
|
|
|
for (let dy = -1; dy <= 1; dy++) {
|
|
|
|
|
for (let dx = -1; dx <= 1; dx++) {
|
|
|
|
|
if (dx === 0 && dy === 0) continue;
|
|
|
|
|
const nx = x + dx;
|
|
|
|
|
const ny = y + dy;
|
|
|
|
|
if (inside(nx, ny)) out.push([nx, ny, Math.hypot(dx, dy)]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function neighbors4(x, y) {
|
|
|
|
|
const out = [];
|
|
|
|
|
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
|
|
|
|
const nx = x + dx;
|
|
|
|
|
const ny = y + dy;
|
|
|
|
|
if (inside(nx, ny)) out.push([nx, ny, 1]);
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function generateAdminRegions(centers, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse) {
|
|
|
|
|
const adminId = new Int16Array(SIZE);
|
|
|
|
|
adminId.fill(-1);
|
|
|
|
|
const dist = new Float32Array(SIZE);
|
|
|
|
|
dist.fill(INF);
|
|
|
|
|
const heap = new MinHeap();
|
|
|
|
|
|
|
|
|
|
centers.forEach((center, regionId) => {
|
|
|
|
|
const i = indexOf(center.x, center.y);
|
|
|
|
|
dist[i] = 0;
|
|
|
|
|
adminId[i] = regionId;
|
|
|
|
|
heap.push({ i, f: 0, regionId });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let guard = 0;
|
|
|
|
|
while (heap.length > 0 && guard++ < SIZE * 12) {
|
|
|
|
|
const current = heap.pop();
|
|
|
|
|
if (!current) continue;
|
|
|
|
|
const curIndex = current.i;
|
|
|
|
|
const curRegion = adminId[curIndex];
|
|
|
|
|
if (curRegion < 0 || current.f > dist[curIndex] + 1e-5) continue;
|
|
|
|
|
|
|
|
|
|
const [cx, cy] = xyOf(curIndex);
|
|
|
|
|
for (const [nx, ny, step] of neighbors8(cx, cy)) {
|
|
|
|
|
const ni = indexOf(nx, ny);
|
|
|
|
|
if (!prefectureMask[ni] || sea[ni]) continue;
|
|
|
|
|
|
|
|
|
|
const ridgeBarrier = Math.max(ridgeField[ni], ridgeField[curIndex]);
|
|
|
|
|
const riverBarrier = Math.max(river[ni], river[curIndex]);
|
|
|
|
|
const highDivide = Math.max(elevation[ni], elevation[curIndex]);
|
|
|
|
|
const watershedBarrier = ridgeBarrier * (27.0 + Math.max(0, highDivide - 0.46) * 46.0);
|
|
|
|
|
const ridgePenalty = Math.max(0, highDivide - 0.36) * 16.0 + Math.abs(elevation[ni] - elevation[curIndex]) * 12.4 + watershedBarrier;
|
|
|
|
|
const slopePenalty = slope[ni] * 10.6;
|
|
|
|
|
const valleyBarrier = valleyField[ni] > 0.50 ? valleyField[ni] * (riverBarrier > 0.16 ? 7.2 : 2.6) : 0;
|
|
|
|
|
const riverPenalty = riverBarrier > 0.7 ? 22.0 : riverBarrier > 0.42 ? 14.8 : riverBarrier > 0.22 ? 7.4 : riverBarrier > 0.12 ? 2.2 : 0;
|
|
|
|
|
const urbanContinuityBonus = (landuse[ni] >= 2 && landuse[ni] <= 4 && populationDensity[ni] > 0.20) ? 1.65 : 0;
|
|
|
|
|
const valleyLocalityBonus = valleyField[ni] * 0.16;
|
|
|
|
|
const stepCost = Math.max(0.25, 0.72 + ridgePenalty + slopePenalty + riverPenalty + valleyBarrier - valleyLocalityBonus - urbanContinuityBonus) * step;
|
|
|
|
|
const nextDist = dist[curIndex] + stepCost;
|
|
|
|
|
|
|
|
|
|
if (nextDist < dist[ni]) {
|
|
|
|
|
dist[ni] = nextDist;
|
|
|
|
|
adminId[ni] = curRegion;
|
|
|
|
|
heap.push({ i: ni, f: nextDist, regionId: curRegion });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return adminId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function terrainBoundaryStrength(i, elevation, slope, river, ridgeField, valleyField) {
|
|
|
|
|
return clamp(weightedScore([
|
|
|
|
|
[ridgeField[i], 3.15],
|
|
|
|
|
[river[i], 2.45],
|
|
|
|
|
[valleyField[i], 0.62],
|
|
|
|
|
[slope[i], 1.06],
|
|
|
|
|
[Math.max(0, elevation[i] - 0.5), 1.18],
|
|
|
|
|
]));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse, passes = 5) {
|
|
|
|
|
let current = new Int16Array(adminId);
|
|
|
|
|
for (let pass = 0; pass < passes; pass++) {
|
|
|
|
|
const next = new Int16Array(current);
|
|
|
|
|
for (let y = 1; y < MAP_H - 1; y++) {
|
|
|
|
|
for (let x = 1; x < MAP_W - 1; x++) {
|
|
|
|
|
const i = indexOf(x, y);
|
|
|
|
|
const own = current[i];
|
|
|
|
|
if (!prefectureMask[i] || sea[i] || own < 0) continue;
|
|
|
|
|
const urbanCell = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.24;
|
|
|
|
|
const barrier = terrainBoundaryStrength(i, elevation, slope, river, ridgeField, valleyField);
|
|
|
|
|
if (barrier > 0.62 || urbanCell) continue;
|
|
|
|
|
|
|
|
|
|
const counts = new Map();
|
|
|
|
|
let ownCount = 0;
|
|
|
|
|
for (const [nx, ny] of neighbors4(x, y)) {
|
|
|
|
|
const ni = indexOf(nx, ny);
|
|
|
|
|
if (!prefectureMask[ni] || sea[ni]) continue;
|
|
|
|
|
const id = current[ni];
|
|
|
|
|
if (id < 0) continue;
|
|
|
|
|
const weight = terrainBoundaryStrength(ni, elevation, slope, river, ridgeField, valleyField) > 0.72 ? 0.45 : 1;
|
|
|
|
|
counts.set(id, (counts.get(id) || 0) + weight);
|
|
|
|
|
if (id === own) ownCount += weight;
|
|
|
|
|
}
|
|
|
|
|
let bestId = own;
|
|
|
|
|
let best = ownCount;
|
|
|
|
|
for (const [id, score] of counts) if (score > best) { best = score; bestId = id; }
|
|
|
|
|
if (bestId !== own && (best >= 4.2 || ownCount <= 2.1)) next[i] = bestId;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
current = next;
|
|
|
|
|
}
|
|
|
|
|
adminId.set(current);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, maxCells = 360) {
|
|
|
|
|
const seen = new Uint8Array(SIZE);
|
|
|
|
|
const queue = [];
|
|
|
|
|
for (let i = 0; i < SIZE; i++) {
|
|
|
|
|
if (seen[i] || !prefectureMask[i] || sea[i]) continue;
|
|
|
|
|
const isUrbanStart = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8 || populationDensity[i] > 0.20;
|
|
|
|
|
if (!isUrbanStart) continue;
|
|
|
|
|
const component = [];
|
|
|
|
|
queue.length = 0;
|
|
|
|
|
queue.push(i);
|
|
|
|
|
seen[i] = 1;
|
|
|
|
|
for (let q = 0; q < queue.length; q++) {
|
|
|
|
|
const cur = queue[q];
|
|
|
|
|
component.push(cur);
|
|
|
|
|
const [x, y] = xyOf(cur);
|
|
|
|
|
for (const [nx, ny] of neighbors8(x, y)) {
|
|
|
|
|
const ni = indexOf(nx, ny);
|
|
|
|
|
if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue;
|
|
|
|
|
const isUrban = (landuse[ni] >= 2 && landuse[ni] <= 4) || landuse[ni] === 7 || landuse[ni] === 8 || populationDensity[ni] > 0.20;
|
|
|
|
|
if (!isUrban) continue;
|
|
|
|
|
seen[ni] = 1;
|
|
|
|
|
queue.push(ni);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (component.length === 0 || component.length > maxCells) continue;
|
|
|
|
|
const counts = new Map();
|
|
|
|
|
for (const ci of component) {
|
|
|
|
|
const id = adminId[ci];
|
|
|
|
|
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1 + populationDensity[ci]);
|
|
|
|
|
}
|
|
|
|
|
let bestId = -1;
|
|
|
|
|
let best = -1;
|
|
|
|
|
for (const [id, score] of counts) if (score > best) { best = score; bestId = id; }
|
|
|
|
|
if (bestId >= 0) for (const ci of component) adminId[ci] = bestId;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities = [], minArea = 320, options = {}) {
|
|
|
|
|
const area = new Map();
|
|
|
|
|
const pop = new Map();
|
|
|
|
|
const adjacency = new Map();
|
|
|
|
|
const cityMunicipalities = new Set();
|
|
|
|
|
for (const city of modernCities || []) {
|
|
|
|
|
if (!inside(city.x, city.y)) continue;
|
|
|
|
|
const id = adminId[indexOf(city.x, city.y)];
|
|
|
|
|
if (id < 0) continue;
|
|
|
|
|
if (options.protectAllModernCities !== false || city.isPrefecturalCapital || (city.population || 0) >= (options.majorCityPopulationThreshold || 120000)) cityMunicipalities.add(id);
|
|
|
|
|
}
|
|
|
|
|
for (const point of options.protectedPoints || []) {
|
|
|
|
|
if (!point || !inside(point.x, point.y)) continue;
|
|
|
|
|
const id = adminId[indexOf(point.x, point.y)];
|
|
|
|
|
if (id >= 0) cityMunicipalities.add(id);
|
|
|
|
|
}
|
|
|
|
|
const satelliteByAdmin = new Map();
|
|
|
|
|
for (const sat of options.satelliteCities || []) {
|
|
|
|
|
if (!sat || !inside(sat.x, sat.y)) continue;
|
|
|
|
|
const id = adminId[indexOf(sat.x, sat.y)];
|
|
|
|
|
if (id < 0) continue;
|
|
|
|
|
if (!satelliteByAdmin.has(id)) satelliteByAdmin.set(id, []);
|
|
|
|
|
satelliteByAdmin.get(id).push(sat);
|
|
|
|
|
}
|
|
|
|
|
const satelliteStats = options.satelliteStats || null;
|
|
|
|
|
|
|
|
|
|
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]) continue;
|
|
|
|
|
const id = adminId[i];
|
|
|
|
|
if (id < 0) continue;
|
|
|
|
|
area.set(id, (area.get(id) || 0) + 1);
|
|
|
|
|
pop.set(id, (pop.get(id) || 0) + populationDensity[i]);
|
|
|
|
|
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]) continue;
|
|
|
|
|
const other = adminId[ni];
|
|
|
|
|
if (other < 0 || other === id) continue;
|
|
|
|
|
const key = id < other ? `${id}:${other}` : `${other}:${id}`;
|
|
|
|
|
adjacency.set(key, (adjacency.get(key) || 0) + 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const mergeTarget = new Map();
|
|
|
|
|
for (const [id, cells] of area) {
|
|
|
|
|
const score = cells + (pop.get(id) || 0) * 16;
|
|
|
|
|
if (cells >= minArea || cityMunicipalities.has(id)) continue;
|
|
|
|
|
const satellites = satelliteByAdmin.get(id) || [];
|
|
|
|
|
const protectedSatellite = satellites.some((sat) => {
|
|
|
|
|
const minSatelliteArea = sat.satelliteMinArea || options.satelliteMinArea || 110;
|
|
|
|
|
return sat.municipalityClass === "independentSatelliteMunicipality" && (
|
|
|
|
|
cells >= minSatelliteArea ||
|
|
|
|
|
(sat.population || 0) >= (options.satelliteIndependentPopulationThreshold || 60000) ||
|
|
|
|
|
(sat.distinctUrbanComponentArea || 0) >= 80 ||
|
|
|
|
|
sat.separatedByBarrier
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
if (protectedSatellite) continue;
|
|
|
|
|
let bestNeighbor = -1;
|
|
|
|
|
let bestScore = -1;
|
|
|
|
|
for (const [key, border] of adjacency) {
|
|
|
|
|
const [a, b] = key.split(":").map(Number);
|
|
|
|
|
if (a !== id && b !== id) continue;
|
|
|
|
|
const other = a === id ? b : a;
|
|
|
|
|
const parentBias = satellites.some((sat) => inside(sat.parentX ?? -1, sat.parentY ?? -1) && adminId[indexOf(sat.parentX, sat.parentY)] === other) ? 26 : 0;
|
|
|
|
|
const ruralBias = satellites.some((sat) => sat.municipalityClass === "smallTownAttachedToRuralMunicipality") ? Math.min(12, (area.get(other) || 0) * 0.01) : 0;
|
|
|
|
|
const candidate = border * 3 + (area.get(other) || 0) * 0.012 + (pop.get(other) || 0) * 0.24 + parentBias + ruralBias;
|
|
|
|
|
if (candidate > bestScore) { bestScore = candidate; bestNeighbor = other; }
|
|
|
|
|
}
|
|
|
|
|
if (bestNeighbor >= 0 && (area.get(bestNeighbor) || 0) >= score * 0.35) {
|
|
|
|
|
mergeTarget.set(id, bestNeighbor);
|
|
|
|
|
if (satelliteStats && satellites.length) {
|
|
|
|
|
satelliteStats.satelliteMunicipalitiesMerged += satellites.length;
|
|
|
|
|
for (const sat of satellites) sat.mergedMunicipalityTarget = bestNeighbor;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (mergeTarget.size === 0) return;
|
|
|
|
|
for (let i = 0; i < SIZE; i++) if (mergeTarget.has(adminId[i])) adminId[i] = mergeTarget.get(adminId[i]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function removeMunicipalExclaves(adminId, prefectureMask, sea, adminCenters = [], protectedPoints = [], maxIslandCells = 220) {
|
|
|
|
|
const protectedByAdmin = new Map();
|
|
|
|
|
for (const p of [...adminCenters, ...protectedPoints]) {
|
|
|
|
|
if (!p || !inside(p.x, p.y)) continue;
|
|
|
|
|
const id = adminId[indexOf(p.x, p.y)];
|
|
|
|
|
if (id < 0) continue;
|
|
|
|
|
if (!protectedByAdmin.has(id)) protectedByAdmin.set(id, new Set());
|
|
|
|
|
protectedByAdmin.get(id).add(indexOf(p.x, p.y));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ids = new Set();
|
|
|
|
|
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && adminId[i] >= 0) ids.add(adminId[i]);
|
|
|
|
|
|
|
|
|
|
const globalSeen = new Uint8Array(SIZE);
|
|
|
|
|
const queue = [];
|
|
|
|
|
for (const id of ids) {
|
|
|
|
|
const components = [];
|
|
|
|
|
for (let i = 0; i < SIZE; i++) {
|
|
|
|
|
if (globalSeen[i] || adminId[i] !== id || !prefectureMask[i] || sea[i]) continue;
|
|
|
|
|
const comp = [];
|
|
|
|
|
let hasProtected = protectedByAdmin.get(id)?.has(i) || false;
|
|
|
|
|
queue.length = 0;
|
|
|
|
|
queue.push(i);
|
|
|
|
|
globalSeen[i] = 1;
|
|
|
|
|
for (let q = 0; q < queue.length; q++) {
|
|
|
|
|
const cur = queue[q];
|
|
|
|
|
comp.push(cur);
|
|
|
|
|
if (protectedByAdmin.get(id)?.has(cur)) hasProtected = true;
|
|
|
|
|
const [x, y] = xyOf(cur);
|
|
|
|
|
for (const [nx, ny] of neighbors4(x, y)) {
|
|
|
|
|
const ni = indexOf(nx, ny);
|
|
|
|
|
if (globalSeen[ni] || adminId[ni] !== id || !prefectureMask[ni] || sea[ni]) continue;
|
|
|
|
|
globalSeen[ni] = 1;
|
|
|
|
|
queue.push(ni);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
components.push({ cells: comp, hasProtected });
|
|
|
|
|
}
|
|
|
|
|
if (components.length <= 1) continue;
|
|
|
|
|
components.sort((a, b) => (b.hasProtected ? 1000000 : 0) + b.cells.length - ((a.hasProtected ? 1000000 : 0) + a.cells.length));
|
|
|
|
|
for (const component of components.slice(1)) {
|
|
|
|
|
const mainSize = components[0].cells.length;
|
|
|
|
|
if (component.hasProtected && component.cells.length > maxIslandCells && component.cells.length > mainSize * 0.42) continue;
|
|
|
|
|
if (component.cells.length > maxIslandCells && component.cells.length > mainSize * 0.36) continue;
|
|
|
|
|
const counts = new Map();
|
|
|
|
|
for (const ci of component.cells) {
|
|
|
|
|
const [x, y] = xyOf(ci);
|
|
|
|
|
for (const [nx, ny] of neighbors4(x, y)) {
|
|
|
|
|
const ni = indexOf(nx, ny);
|
|
|
|
|
if (!prefectureMask[ni] || sea[ni]) continue;
|
|
|
|
|
const other = adminId[ni];
|
|
|
|
|
if (other >= 0 && other !== id) counts.set(other, (counts.get(other) || 0) + 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let target = -1;
|
|
|
|
|
let best = -1;
|
|
|
|
|
for (const [other, count] of counts) if (count > best) { best = count; target = other; }
|
|
|
|
|
if (target >= 0) for (const ci of component.cells) adminId[ci] = target;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 15:48:42 +09:00
|
|
|
|
|
|
|
|
export function enforceMunicipalityConnectivityStrict(adminId, prefectureMask, sea, adminCenters = [], protectedPoints = [], maxPasses = 6) {
|
|
|
|
|
// Final cell-level invariant: each municipality should be one contiguous land
|
|
|
|
|
// component. Earlier stages are allowed to leave sizeable satellite pieces
|
|
|
|
|
// while boundaries are still being snapped; this pass removes the remaining
|
|
|
|
|
// visual exclaves by attaching every non-primary component to the neighboring
|
|
|
|
|
// municipality with the largest shared boundary. A component that contains a
|
|
|
|
|
// protected point may become the primary component, but it no longer protects
|
|
|
|
|
// additional detached pieces.
|
|
|
|
|
const protectedByAdmin = new Map();
|
|
|
|
|
for (const p of [...(adminCenters || []), ...(protectedPoints || [])]) {
|
|
|
|
|
if (!p || !inside(p.x, p.y)) continue;
|
|
|
|
|
const i = indexOf(Math.round(p.x), Math.round(p.y));
|
|
|
|
|
const id = adminId[i];
|
|
|
|
|
if (id < 0) continue;
|
|
|
|
|
if (!protectedByAdmin.has(id)) protectedByAdmin.set(id, new Set());
|
|
|
|
|
protectedByAdmin.get(id).add(i);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let changed = 0;
|
|
|
|
|
const queue = [];
|
|
|
|
|
for (let pass = 0; pass < maxPasses; pass++) {
|
|
|
|
|
let passChanged = 0;
|
|
|
|
|
const ids = new Set();
|
|
|
|
|
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && adminId[i] >= 0) ids.add(adminId[i]);
|
|
|
|
|
for (const id of ids) {
|
|
|
|
|
const seen = new Uint8Array(SIZE);
|
|
|
|
|
const components = [];
|
|
|
|
|
for (let i = 0; i < SIZE; i++) {
|
|
|
|
|
if (seen[i] || adminId[i] !== id || !prefectureMask[i] || sea[i]) continue;
|
|
|
|
|
const comp = [];
|
|
|
|
|
let protectedHits = 0;
|
|
|
|
|
queue.length = 0;
|
|
|
|
|
queue.push(i);
|
|
|
|
|
seen[i] = 1;
|
|
|
|
|
for (let q = 0; q < queue.length; q++) {
|
|
|
|
|
const cur = queue[q];
|
|
|
|
|
comp.push(cur);
|
|
|
|
|
if (protectedByAdmin.get(id)?.has(cur)) protectedHits++;
|
|
|
|
|
const [x, y] = xyOf(cur);
|
|
|
|
|
for (const [nx, ny] of neighbors4(x, y)) {
|
|
|
|
|
const ni = indexOf(nx, ny);
|
|
|
|
|
if (seen[ni] || adminId[ni] !== id || !prefectureMask[ni] || sea[ni]) continue;
|
|
|
|
|
seen[ni] = 1;
|
|
|
|
|
queue.push(ni);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
components.push({ cells: comp, protectedHits });
|
|
|
|
|
}
|
|
|
|
|
if (components.length <= 1) continue;
|
|
|
|
|
components.sort((a, b) =>
|
|
|
|
|
(b.protectedHits ? 1_000_000 : 0) + b.cells.length -
|
|
|
|
|
((a.protectedHits ? 1_000_000 : 0) + a.cells.length)
|
|
|
|
|
);
|
|
|
|
|
const primary = components[0];
|
|
|
|
|
for (const component of components.slice(1)) {
|
|
|
|
|
const counts = new Map();
|
|
|
|
|
for (const ci of component.cells) {
|
|
|
|
|
const [x, y] = xyOf(ci);
|
|
|
|
|
for (const [nx, ny] of neighbors4(x, y)) {
|
|
|
|
|
const ni = indexOf(nx, ny);
|
|
|
|
|
if (!prefectureMask[ni] || sea[ni]) continue;
|
|
|
|
|
const other = adminId[ni];
|
|
|
|
|
if (other >= 0 && other !== id) counts.set(other, (counts.get(other) || 0) + 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let target = -1;
|
|
|
|
|
let best = -1;
|
|
|
|
|
for (const [other, count] of counts) {
|
|
|
|
|
const bonus = protectedByAdmin.get(other)?.size ? 0.25 : 0;
|
|
|
|
|
const score = count + bonus;
|
|
|
|
|
if (score > best || (score === best && other < target)) { best = score; target = other; }
|
|
|
|
|
}
|
|
|
|
|
if (target < 0) {
|
|
|
|
|
// Very rare: a detached island component has no labeled neighbor.
|
|
|
|
|
// Keep the largest/protected primary and merge the component into it
|
|
|
|
|
// only if it is directly adjacent after previous changes; otherwise
|
|
|
|
|
// leave it for the next pass rather than inventing over-sea ownership.
|
|
|
|
|
target = id;
|
|
|
|
|
}
|
|
|
|
|
if (target >= 0 && target !== id) {
|
|
|
|
|
for (const ci of component.cells) adminId[ci] = target;
|
|
|
|
|
passChanged += component.cells.length;
|
|
|
|
|
} else if (component !== primary) {
|
|
|
|
|
// If no external target exists, still mark it as handled by keeping it;
|
|
|
|
|
// another pass may expose a target after surrounding cells change.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
changed += passChanged;
|
|
|
|
|
if (!passChanged) break;
|
|
|
|
|
}
|
|
|
|
|
return changed;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 17:38:51 +09:00
|
|
|
export function terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse) {
|
|
|
|
|
const urbanPenalty = urbanBoundaryPenalty(i, populationDensity, landuse);
|
|
|
|
|
const majorRiver = clamp(Math.max(river[i] - 0.32, 0) * 1.9 + Math.max(flowAccum[i] - 0.38, 0) * 0.75);
|
|
|
|
|
const minorStream = clamp(river[i] * 0.34 + flowAccum[i] * 0.18);
|
2026-05-28 00:30:09 +09:00
|
|
|
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);
|
2026-05-24 17:38:51 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function urbanBoundaryPenalty(i, populationDensity, landuse) {
|
|
|
|
|
const lu = landuse[i];
|
|
|
|
|
const core = lu === 3 ? 1.45 : lu === 2 ? 1.12 : lu === 4 ? 0.95 : lu === 7 ? 0.90 : lu === 8 ? 0.64 : lu === 5 || lu === 6 ? 0.48 : 0;
|
|
|
|
|
return clamp(core + populationDensity[i] * 1.35);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isAdminBoundaryCell(labels, prefectureMask, sea, x, y, useEight = true) {
|
|
|
|
|
const i = indexOf(x, y);
|
|
|
|
|
const own = labels[i];
|
|
|
|
|
if (!prefectureMask[i] || sea[i] || own < 0) return false;
|
|
|
|
|
const neighbors = useEight ? neighbors8(x, y) : neighbors4(x, y);
|
|
|
|
|
for (const [nx, ny] of neighbors) {
|
|
|
|
|
const ni = indexOf(nx, ny);
|
|
|
|
|
if (prefectureMask[ni] && !sea[ni] && labels[ni] >= 0 && labels[ni] !== own) return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildBoundaryBand(labels, prefectureMask, sea, radius = 5) {
|
|
|
|
|
const band = new Uint8Array(SIZE);
|
|
|
|
|
for (let y = 1; y < MAP_H - 1; y++) {
|
|
|
|
|
for (let x = 1; x < MAP_W - 1; x++) {
|
|
|
|
|
if (!isAdminBoundaryCell(labels, prefectureMask, sea, x, y, true)) continue;
|
|
|
|
|
for (let dy = -radius; dy <= radius; dy++) {
|
|
|
|
|
for (let dx = -radius; dx <= radius; dx++) {
|
|
|
|
|
if (Math.hypot(dx, dy) > radius) continue;
|
|
|
|
|
const nx = x + dx;
|
|
|
|
|
const ny = y + dy;
|
|
|
|
|
if (!inside(nx, ny)) continue;
|
|
|
|
|
const ni = indexOf(nx, ny);
|
|
|
|
|
if (prefectureMask[ni] && !sea[ni]) band[ni] = 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return band;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildAdminProtectedMask(adminId, prefectureMask, sea, adminCenters = [], protectedPoints = [], populationDensity, landuse) {
|
|
|
|
|
const protectedMask = new Uint8Array(SIZE);
|
|
|
|
|
function protectDisk(p, radius) {
|
|
|
|
|
if (!p || !inside(p.x, p.y)) return;
|
|
|
|
|
const owner = adminId[indexOf(p.x, p.y)];
|
|
|
|
|
if (owner < 0) return;
|
|
|
|
|
const r = Math.ceil(radius);
|
|
|
|
|
for (let dy = -r; dy <= r; dy++) {
|
|
|
|
|
for (let dx = -r; dx <= r; dx++) {
|
|
|
|
|
if (Math.hypot(dx, dy) > radius) continue;
|
|
|
|
|
const x = p.x + dx;
|
|
|
|
|
const y = p.y + dy;
|
|
|
|
|
if (!inside(x, y)) continue;
|
|
|
|
|
const i = indexOf(x, y);
|
|
|
|
|
if (prefectureMask[i] && !sea[i] && adminId[i] === owner) protectedMask[i] = 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (const center of adminCenters) protectDisk(center, 2.2);
|
|
|
|
|
for (const p of protectedPoints || []) protectDisk(p, p.population ? clamp(1.6 + Math.sqrt(p.population) / 520, 2.1, 6.0) : p.portClass ? 2.0 : 1.7);
|
|
|
|
|
for (let i = 0; i < SIZE; i++) {
|
|
|
|
|
if (prefectureMask[i] && !sea[i] && (landuse[i] === 3 || populationDensity[i] > 0.72)) protectedMask[i] = 1;
|
|
|
|
|
}
|
|
|
|
|
return protectedMask;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function localBoundaryEnergy(labels, i, candidateId, targetScore, centerDist, populationDensity, landuse, river, valleyField) {
|
|
|
|
|
const [x, y] = xyOf(i);
|
|
|
|
|
const oldId = labels[i];
|
|
|
|
|
let energy = centerDist[candidateId]?.[i] ?? 0;
|
|
|
|
|
let same4 = 0;
|
|
|
|
|
let diff4 = 0;
|
|
|
|
|
let diagDiff = 0;
|
|
|
|
|
|
|
|
|
|
for (const [nx, ny] of neighbors8(x, y)) {
|
|
|
|
|
const ni = indexOf(nx, ny);
|
|
|
|
|
const neighborId = labels[ni];
|
|
|
|
|
if (neighborId < 0) continue;
|
|
|
|
|
const isCardinal = nx === x || ny === y;
|
|
|
|
|
const differs = neighborId !== candidateId;
|
|
|
|
|
if (isCardinal) {
|
|
|
|
|
if (differs) {
|
|
|
|
|
diff4++;
|
|
|
|
|
const boundaryTarget = (targetScore[i] + targetScore[ni]) * 0.5;
|
|
|
|
|
const urbanCut = (urbanBoundaryPenalty(i, populationDensity, landuse) + urbanBoundaryPenalty(ni, populationDensity, landuse)) * 0.5;
|
|
|
|
|
const minorValley = (valleyField[i] + valleyField[ni]) * 0.5 > 0.34 && Math.max(river[i], river[ni]) < 0.30 ? 0.72 : 0;
|
|
|
|
|
const dHere = centerDist[candidateId]?.[i] ?? 99;
|
|
|
|
|
const dThere = centerDist[neighborId]?.[i] ?? 99;
|
|
|
|
|
const weakBisectorPenalty = Math.abs(dHere - dThere) < 4.0 && boundaryTarget < 0.42 ? 0.62 : 0;
|
|
|
|
|
energy += 2.15 - boundaryTarget * 1.55 + urbanCut * 3.0 + minorValley + weakBisectorPenalty;
|
|
|
|
|
} else same4++;
|
|
|
|
|
} else if (differs) diagDiff++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (same4 === 0) energy += 5.2;
|
|
|
|
|
if (same4 === 1) energy += 1.7;
|
|
|
|
|
if (diff4 >= 3 && targetScore[i] < 0.42) energy += 1.25;
|
|
|
|
|
if (diagDiff >= 3 && diff4 >= 2 && targetScore[i] < 0.50) energy += 0.42;
|
2026-05-26 00:45:01 +09:00
|
|
|
if (candidateId !== oldId) {
|
|
|
|
|
const candidateDistance = centerDistanceAt(centerDist, candidateId, i);
|
|
|
|
|
const oldDistance = centerDistanceAt(centerDist, oldId, i);
|
|
|
|
|
if (Number.isFinite(candidateDistance) && Number.isFinite(oldDistance)) {
|
|
|
|
|
const drift = candidateDistance - oldDistance;
|
|
|
|
|
if (drift > 0) energy += Math.min(0.9, drift * 0.012);
|
|
|
|
|
}
|
2026-05-24 17:38:51 +09:00
|
|
|
}
|
|
|
|
|
return energy;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 00:45:01 +09:00
|
|
|
function centerDistanceAt(centerDist, id, i) {
|
|
|
|
|
const field = centerDist?.fields?.[id] || centerDist?.[id];
|
|
|
|
|
if (field) return field[i];
|
|
|
|
|
const center = centerDist?.centers?.[id];
|
|
|
|
|
if (!center || !inside(center.x, center.y)) return 24;
|
|
|
|
|
const [x, y] = xyOf(i);
|
|
|
|
|
return Math.hypot(x - center.x, y - center.y);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 17:38:51 +09:00
|
|
|
function buildCenterDistanceFields(adminIds, adminCenters, prefectureMask, sea) {
|
2026-05-26 00:45:01 +09:00
|
|
|
// Older versions materialized one full SIZE Float32Array per municipality.
|
|
|
|
|
// In multi-prefecture generation this can create heavy transient memory use.
|
|
|
|
|
// Keep the same interface conceptually, but compute distances on demand.
|
|
|
|
|
return { ids: adminIds, centers: adminCenters, prefectureMask, sea };
|
2026-05-24 17:38:51 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function repairAdminTopology(adminId, prefectureMask, sea, adminCenters = [], targetScore = null, populationDensity = null, landuse = null) {
|
|
|
|
|
const ids = new Set();
|
|
|
|
|
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && adminId[i] >= 0) ids.add(adminId[i]);
|
|
|
|
|
const queue = [];
|
|
|
|
|
|
|
|
|
|
for (const id of ids) {
|
|
|
|
|
const seen = new Uint8Array(SIZE);
|
|
|
|
|
const components = [];
|
|
|
|
|
for (let i = 0; i < SIZE; i++) {
|
|
|
|
|
if (seen[i] || adminId[i] !== id || !prefectureMask[i] || sea[i]) continue;
|
|
|
|
|
const 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] || adminId[ni] !== id || !prefectureMask[ni] || sea[ni]) continue;
|
|
|
|
|
seen[ni] = 1;
|
|
|
|
|
queue.push(ni);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
components.push(cells);
|
|
|
|
|
}
|
|
|
|
|
if (components.length <= 1) continue;
|
|
|
|
|
|
|
|
|
|
const centerIndex = adminCenters[id] && inside(adminCenters[id].x, adminCenters[id].y) ? indexOf(adminCenters[id].x, adminCenters[id].y) : -1;
|
|
|
|
|
let keepIndex = centerIndex >= 0 ? components.findIndex((cells) => cells.includes(centerIndex)) : -1;
|
|
|
|
|
if (keepIndex < 0) {
|
|
|
|
|
let bestSize = -1;
|
|
|
|
|
for (let c = 0; c < components.length; c++) if (components[c].length > bestSize) { bestSize = components[c].length; keepIndex = c; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (let c = 0; c < components.length; c++) {
|
|
|
|
|
if (c === keepIndex) continue;
|
|
|
|
|
const counts = new Map();
|
|
|
|
|
for (const ci of components[c]) {
|
|
|
|
|
const [x, y] = xyOf(ci);
|
|
|
|
|
for (const [nx, ny] of neighbors4(x, y)) {
|
|
|
|
|
const ni = indexOf(nx, ny);
|
|
|
|
|
if (!prefectureMask[ni] || sea[ni]) continue;
|
|
|
|
|
const other = adminId[ni];
|
|
|
|
|
if (other < 0 || other === id) continue;
|
|
|
|
|
const terrainFit = targetScore ? targetScore[ci] * 0.18 : 0;
|
|
|
|
|
const urbanFit = populationDensity && landuse ? (1 - urbanBoundaryPenalty(ci, populationDensity, landuse)) * 0.08 : 0;
|
|
|
|
|
counts.set(other, (counts.get(other) || 0) + 1 + terrainFit + urbanFit);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let target = -1;
|
|
|
|
|
let best = -1;
|
|
|
|
|
for (const [other, score] of counts) if (score > best) { best = score; target = other; }
|
|
|
|
|
if (target >= 0) for (const ci of components[c]) adminId[ci] = target;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse) {
|
|
|
|
|
if (landuse[i] === 3 || populationDensity[i] > 0.70) return 1;
|
|
|
|
|
if ((landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8 || populationDensity[i] > 0.24) return 2;
|
|
|
|
|
if (landuse[i] === 5 || landuse[i] === 6) return 3;
|
|
|
|
|
if ((river[i] > 0.50 && flowAccum[i] > 0.34) || flowAccum[i] > 0.68) return 4;
|
|
|
|
|
if (coastalLowland[i] > 0.42 && elevation[i] < 0.44) return 5;
|
|
|
|
|
if (basinField[i] > 0.38 && plain[i] > 0.26) return 6;
|
|
|
|
|
if (valleyField[i] > 0.42 && ridgeField[i] < 0.55) return 7;
|
|
|
|
|
if (ridgeField[i] > 0.54 || (ridgeField[i] > 0.40 && elevation[i] > 0.54)) return 8;
|
|
|
|
|
if (elevation[i] > 0.62 || slope[i] > 0.42) return 9;
|
|
|
|
|
if (landuse[i] === 0 || agriculture[i] > 0.45 || plain[i] > 0.48) return 10;
|
|
|
|
|
return 11;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null) {
|
|
|
|
|
const score = new Float32Array(SIZE);
|
|
|
|
|
for (let i = 0; i < SIZE; i++) {
|
|
|
|
|
if (!prefectureMask[i] || sea[i]) continue;
|
|
|
|
|
const [x, y] = xyOf(i);
|
|
|
|
|
let coastEdge = 0;
|
|
|
|
|
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);
|
2026-05-28 00:30:09 +09:00
|
|
|
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);
|
2026-05-24 17:38:51 +09:00
|
|
|
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(
|
2026-05-28 00:30:09 +09:00
|
|
|
ridgeDivide * 1.42 +
|
|
|
|
|
crest * 1.10 +
|
2026-05-24 17:38:51 +09:00
|
|
|
majorRiver * 0.86 +
|
2026-05-28 00:30:09 +09:00
|
|
|
basinRim * 0.66 +
|
|
|
|
|
foothillBreak * 0.58 +
|
2026-05-24 17:38:51 +09:00
|
|
|
coastEdge * 0.34 +
|
2026-05-28 00:30:09 +09:00
|
|
|
terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity || score, landuse || score) * 0.44 -
|
|
|
|
|
livingCorridor * 0.28 -
|
2026-05-24 17:38:51 +09:00
|
|
|
urbanContinuity * 0.72
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return score;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse) {
|
|
|
|
|
const lowRelief = clamp((0.68 - elevation[i]) * 1.25) + clamp((0.36 - slope[i]) * 1.45) + clamp((0.48 - ridgeField[i]) * 1.10);
|
|
|
|
|
const landuseFit = [1, 2, 3, 4, 7, 8].includes(landuse[i]) ? 0.42 : landuse[i] === 5 || landuse[i] === 6 ? 0.20 : 0;
|
|
|
|
|
return clamp(
|
|
|
|
|
lowRelief * 0.30 +
|
|
|
|
|
(plain?.[i] || 0) * 0.30 +
|
|
|
|
|
(agriculture?.[i] || 0) * 0.16 +
|
|
|
|
|
basinField[i] * 0.24 +
|
|
|
|
|
coastalLowland[i] * 0.24 +
|
|
|
|
|
valleyField[i] * 0.10 +
|
|
|
|
|
populationDensity[i] * 0.34 +
|
|
|
|
|
landuseFit
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mountainCompartmentFitness(i, elevation, slope, ridgeField, populationDensity, landuse) {
|
|
|
|
|
const settled = populationDensity[i] * 0.85 + ([2, 3, 4, 7, 8].includes(landuse[i]) ? 0.35 : 0);
|
|
|
|
|
return clamp(elevation[i] * 0.38 + slope[i] * 0.32 + ridgeField[i] * 0.42 - settled);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function canShareNaturalCompartment(a, b, classA, classB, barrier, river, flowAccum, valleyField, populationDensity, landuse) {
|
|
|
|
|
if (classA !== classB) {
|
|
|
|
|
const bothUrban = classA <= 3 && classB <= 3;
|
|
|
|
|
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.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.48 && !majorRiverEdge;
|
2026-05-28 00:30:09 +09:00
|
|
|
const threshold = urbanEdge ? 0.74 : valleyContinuity ? 0.56 : classA === 8 || classB === 8 ? 0.28 : 0.43;
|
2026-05-24 17:38:51 +09:00
|
|
|
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];
|
|
|
|
|
basinIdentity += basinField[i];
|
|
|
|
|
valleyIdentity += valleyField[i];
|
|
|
|
|
lowlandFitness += lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
|
|
|
|
|
mountainFitness += mountainCompartmentFitness(i, elevation, slope, ridgeField, populationDensity, landuse);
|
|
|
|
|
}
|
|
|
|
|
const area = unit.cells.length;
|
|
|
|
|
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);
|
|
|
|
|
unit.riverExposure = riverExposure / Math.max(1, area);
|
|
|
|
|
unit.coastalExposure = coastalExposure / Math.max(1, area);
|
|
|
|
|
unit.basinIdentity = basinIdentity / Math.max(1, area);
|
|
|
|
|
unit.valleyIdentity = valleyIdentity / Math.max(1, area);
|
|
|
|
|
unit.lowlandFitness = lowlandFitness / Math.max(1, area);
|
|
|
|
|
unit.mountainFitness = mountainFitness / Math.max(1, area);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function splitOneNaturalCompartment(unit, newId, compartmentId, fields, seed) {
|
|
|
|
|
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 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;
|
|
|
|
|
const [fx, fy] = xyOf(first);
|
|
|
|
|
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 = 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 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 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;
|
2026-05-28 00:30:09 +09:00
|
|
|
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;
|
2026-05-24 17:38:51 +09:00
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const aCells = [];
|
|
|
|
|
const bCells = [];
|
|
|
|
|
for (const ci of unit.cells) {
|
|
|
|
|
if (owner[ci] === 1) bCells.push(ci);
|
|
|
|
|
else aCells.push(ci);
|
|
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
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 hashSeededTie(x, y, seed) {
|
|
|
|
|
let h = Math.imul((x | 0) ^ (seed | 0), 1597334677) ^ Math.imul((y | 0) ^ ((seed >>> 1) | 0), 3812015801);
|
|
|
|
|
h = (h ^ (h >>> 15)) >>> 0;
|
|
|
|
|
return h / 4294967295;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function naturalGroupKey(unit) {
|
|
|
|
|
if (unit.classId <= 3) return `urban:${Math.round(unit.x / 10)}:${Math.round(unit.y / 10)}`;
|
|
|
|
|
if (unit.classId === 5) return `coast:${Math.round(unit.y / 8)}`;
|
|
|
|
|
if (unit.classId === 6) return `basin:${Math.round(unit.x / 12)}:${Math.round(unit.y / 12)}`;
|
2026-05-26 21:14:37 +09:00
|
|
|
if (unit.classId === 7) return `valley:${Math.round(unit.x / 11)}:${Math.round(unit.y / 11)}`;
|
2026-05-24 17:38:51 +09:00
|
|
|
if (unit.classId === 8 || unit.classId === 9) return `mountain:${Math.round(unit.x / 14)}:${Math.round(unit.y / 14)}`;
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 00:30:09 +09:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 17:38:51 +09:00
|
|
|
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));
|
2026-05-28 00:30:09 +09:00
|
|
|
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));
|
2026-05-24 17:38:51 +09:00
|
|
|
localTarget = Math.min(localTarget, Math.max(1, Math.floor(area / minCellsPerUnit)));
|
2026-05-28 00:30:09 +09:00
|
|
|
const reservedForRest = Math.max(0, sortedComponents.length - componentOrder - 1);
|
|
|
|
|
localTarget = Math.min(localTarget, Math.max(1, remainingTarget - reservedForRest));
|
2026-05-24 17:38:51 +09:00
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 00:30:09 +09:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 17:38:51 +09:00
|
|
|
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;
|
2026-05-28 00:30:09 +09:00
|
|
|
if (isHardNaturalRidgeCrossing(a, b, cellClass, fields)) return INF;
|
2026-05-24 17:38:51 +09:00
|
|
|
return Math.max(0.16,
|
|
|
|
|
0.72 +
|
2026-05-28 00:30:09 +09:00
|
|
|
barrier * 8.8 +
|
|
|
|
|
ridge * 2.35 +
|
|
|
|
|
elevationBreak * 5.10 +
|
|
|
|
|
slopeBreak * 1.08 +
|
2026-05-24 17:38:51 +09:00
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 00:30:09 +09:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 17:38:51 +09:00
|
|
|
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;
|
2026-05-28 00:30:09 +09:00
|
|
|
const stepCost = naturalStepCost(cur.i, ni, cellClass, fields);
|
|
|
|
|
if (stepCost >= INF) continue;
|
|
|
|
|
const nd = cur.f + stepCost * step;
|
2026-05-24 17:38:51 +09:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 00:30:09 +09:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 17:38:51 +09:00
|
|
|
function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) {
|
2026-05-26 00:45:01 +09:00
|
|
|
const progress = typeof options.progress === "function" ? options.progress : null;
|
2026-05-24 17:38:51 +09:00
|
|
|
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);
|
2026-05-28 00:30:09 +09:00
|
|
|
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);
|
2026-05-24 17:38:51 +09:00
|
|
|
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);
|
2026-05-26 00:45:01 +09:00
|
|
|
progress?.(`natural seeds chosen: ${seeds.length}/${targetCount}`);
|
2026-05-24 17:38:51 +09:00
|
|
|
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;
|
2026-05-28 00:30:09 +09:00
|
|
|
const stepCost = naturalStepCost(cur.i, ni, cellClass, fields);
|
|
|
|
|
if (stepCost >= INF) continue;
|
|
|
|
|
const nd = cur.f + stepCost * step;
|
2026-05-24 17:38:51 +09:00
|
|
|
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;
|
2026-05-26 00:45:01 +09:00
|
|
|
progress?.("natural seeded growth complete");
|
2026-05-24 17:38:51 +09:00
|
|
|
|
|
|
|
|
let compartments = buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields);
|
|
|
|
|
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
|
|
|
|
mergeTinyLandscapeUnits(compartmentId, compartments, 9);
|
2026-05-26 21:14:37 +09:00
|
|
|
mergeWeakArtificialLandscapeUnits(compartmentId, compartments, fields, prefectureMask, sea, Math.max(42, Math.round(landArea / Math.max(1, targetCount) * 2.15)), 5);
|
2026-05-26 00:45:01 +09:00
|
|
|
progress?.(`natural post-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`);
|
2026-05-24 17:38:51 +09:00
|
|
|
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
|
2026-05-28 00:30:09 +09:00
|
|
|
splitCompartmentsByWatershed(compartmentId, compartments, fields);
|
2026-05-24 17:38:51 +09:00
|
|
|
compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea);
|
|
|
|
|
refreshAllCompartmentStats(compartments, fields);
|
|
|
|
|
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
2026-05-26 00:45:01 +09:00
|
|
|
progress?.(`natural pre-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`);
|
2026-05-24 17:38:51 +09:00
|
|
|
|
|
|
|
|
const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(28, Math.round(landArea / Math.max(1, targetCount) * 1.55));
|
2026-05-26 00:45:01 +09:00
|
|
|
let guard = Math.max(60, targetCount * 2);
|
2026-05-24 17:38:51 +09:00
|
|
|
while (guard-- > 0) {
|
|
|
|
|
let active = compartments.filter((unit) => unit && unit.area > 0);
|
|
|
|
|
const needMore = active.length < targetCount;
|
|
|
|
|
const worst = active
|
2026-05-26 00:45:01 +09:00
|
|
|
.filter((unit) => unit.area >= 20 && (unit._splitRejected || 0) < 3 && (needMore || unit.area > maxNaturalCompartmentArea * 1.18 || (unit.elongation || 1) > 4.2))
|
2026-05-24 17:38:51 +09:00
|
|
|
.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;
|
2026-05-28 00:30:09 +09:00
|
|
|
const newUnit = splitNaturalCompartmentCompact(worst, compartments.length, compartmentId, fields, (options.seed || 0) + guard * 97)
|
|
|
|
|
|| splitNaturalCompartmentByAxis(worst, compartments.length, compartmentId, fields, (options.seed || 0) + guard * 131);
|
2026-05-24 17:38:51 +09:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 21:14:37 +09:00
|
|
|
mergeWeakArtificialLandscapeUnits(compartmentId, compartments, fields, prefectureMask, sea, Math.max(48, Math.round(landArea / Math.max(1, targetCount) * 2.05)), 4);
|
2026-05-24 17:38:51 +09:00
|
|
|
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
|
2026-05-28 00:30:09 +09:00
|
|
|
splitCompartmentsByWatershed(compartmentId, compartments, fields);
|
2026-05-24 17:38:51 +09:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildLandscapeUnits(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse) {
|
|
|
|
|
const unitId = new Int32Array(SIZE);
|
|
|
|
|
unitId.fill(-1);
|
|
|
|
|
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 units = [];
|
|
|
|
|
const queue = [];
|
|
|
|
|
for (let i = 0; i < SIZE; i++) {
|
|
|
|
|
if (cellClass[i] < 0 || unitId[i] >= 0) continue;
|
|
|
|
|
const id = units.length;
|
|
|
|
|
const klass = cellClass[i];
|
|
|
|
|
const cells = [];
|
|
|
|
|
let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = 0;
|
|
|
|
|
queue.length = 0;
|
|
|
|
|
queue.push(i);
|
|
|
|
|
unitId[i] = id;
|
|
|
|
|
for (let q = 0; q < queue.length; q++) {
|
|
|
|
|
const cur = queue[q];
|
|
|
|
|
const [x, y] = xyOf(cur);
|
|
|
|
|
cells.push(cur);
|
|
|
|
|
sx += x; sy += y; pop += populationDensity[cur];
|
|
|
|
|
urbanWeight += urbanBoundaryPenalty(cur, populationDensity, landuse);
|
|
|
|
|
ridgeExposure += ridgeField[cur];
|
|
|
|
|
riverExposure += river[cur] + flowAccum[cur] * 0.45;
|
|
|
|
|
for (const [nx, ny] of neighbors4(x, y)) {
|
|
|
|
|
const ni = indexOf(nx, ny);
|
|
|
|
|
if (unitId[ni] >= 0 || cellClass[ni] !== klass) continue;
|
|
|
|
|
unitId[ni] = id;
|
|
|
|
|
queue.push(ni);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
units.push({ id, classId: klass, cells, area: cells.length, x: sx / cells.length, y: sy / cells.length, population: pop, urbanWeight: urbanWeight / cells.length, ridgeExposure: ridgeExposure / cells.length, riverExposure: riverExposure / cells.length, adjacent: new Map(), centerIds: [], owner: -1 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const targetScore = new Float32Array(SIZE);
|
|
|
|
|
for (let i = 0; i < SIZE; i++) if (cellClass[i] >= 0) targetScore[i] = terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse);
|
|
|
|
|
rebuildLandscapeUnitAdjacency(unitId, units, targetScore, prefectureMask, sea);
|
|
|
|
|
mergeTinyLandscapeUnits(unitId, units, 10);
|
|
|
|
|
rebuildLandscapeUnitAdjacency(unitId, units, targetScore, prefectureMask, sea);
|
|
|
|
|
return { unitId, units, targetScore };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rebuildLandscapeUnitAdjacency(unitId, units, targetScore, prefectureMask, sea) {
|
|
|
|
|
for (const unit of units) unit.adjacent = new Map();
|
|
|
|
|
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]) continue;
|
|
|
|
|
const a = unitId[i];
|
|
|
|
|
if (a < 0 || !units[a] || units[a].area === 0) continue;
|
|
|
|
|
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]) continue;
|
|
|
|
|
const b = unitId[ni];
|
|
|
|
|
if (b < 0 || b === a || !units[b] || units[b].area === 0) continue;
|
|
|
|
|
const v = (targetScore[i] + targetScore[ni]) * 0.5;
|
2026-05-26 21:14:37 +09:00
|
|
|
const vertical = nx !== x ? 1 : 0;
|
|
|
|
|
const keyA = units[a].adjacent.get(b) || { count: 0, target: 0, vertical: 0, horizontal: 0 };
|
|
|
|
|
keyA.count++; keyA.target += v; if (vertical) keyA.vertical++; else keyA.horizontal++; units[a].adjacent.set(b, keyA);
|
|
|
|
|
const keyB = units[b].adjacent.get(a) || { count: 0, target: 0, vertical: 0, horizontal: 0 };
|
|
|
|
|
keyB.count++; keyB.target += v; if (vertical) keyB.vertical++; else keyB.horizontal++; units[b].adjacent.set(a, keyB);
|
2026-05-24 17:38:51 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mergeTinyLandscapeUnits(unitId, units, minArea = 10) {
|
|
|
|
|
for (const unit of units) {
|
|
|
|
|
if (unit.area === 0 || unit.area >= minArea) continue;
|
|
|
|
|
let bestId = -1, bestScore = -INF;
|
|
|
|
|
for (const [otherId, edge] of unit.adjacent) {
|
|
|
|
|
const other = units[otherId];
|
|
|
|
|
if (!other || other.area === 0) continue;
|
|
|
|
|
const score = edge.count * 3 + (other.classId === unit.classId ? 8 : 0) + other.area * 0.01 - edge.target / Math.max(1, edge.count);
|
|
|
|
|
if (score > bestScore) { bestScore = score; bestId = otherId; }
|
|
|
|
|
}
|
|
|
|
|
if (bestId < 0) continue;
|
|
|
|
|
const target = units[bestId];
|
|
|
|
|
for (const i of unit.cells) { unitId[i] = bestId; target.cells.push(i); }
|
|
|
|
|
const totalArea = target.area + unit.area;
|
|
|
|
|
target.x = (target.x * target.area + unit.x * unit.area) / totalArea;
|
|
|
|
|
target.y = (target.y * target.area + unit.y * unit.area) / totalArea;
|
|
|
|
|
target.population += unit.population;
|
|
|
|
|
target.urbanWeight = (target.urbanWeight * target.area + unit.urbanWeight * unit.area) / totalArea;
|
|
|
|
|
target.ridgeExposure = (target.ridgeExposure * target.area + unit.ridgeExposure * unit.area) / totalArea;
|
|
|
|
|
target.riverExposure = (target.riverExposure * target.area + unit.riverExposure * unit.area) / totalArea;
|
|
|
|
|
target.area = totalArea;
|
|
|
|
|
unit.area = 0;
|
|
|
|
|
unit.cells = [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 21:14:37 +09:00
|
|
|
|
|
|
|
|
function mergeUnitInto(unitId, units, fromId, toId, fields) {
|
|
|
|
|
const from = units[fromId];
|
|
|
|
|
const to = units[toId];
|
|
|
|
|
if (!from || !to || from.area === 0 || to.area === 0 || fromId === toId) return false;
|
|
|
|
|
for (const ci of from.cells) { unitId[ci] = toId; to.cells.push(ci); }
|
|
|
|
|
from.area = 0;
|
|
|
|
|
from.cells = [];
|
|
|
|
|
refreshCompartmentStats(to, fields.elevation, fields.slope, fields.ridgeField, fields.valleyField, fields.basinField, fields.coastalLowland, fields.plain, fields.agriculture, fields.populationDensity, fields.landuse);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mergeWeakArtificialLandscapeUnits(unitId, units, fields, prefectureMask, sea, maxMergedArea = 84, passes = 5) {
|
|
|
|
|
// Seeded graph growth can create diagonal stair-step borders in uniform plains
|
|
|
|
|
// and gentle hills. If the shared edge is weak, balanced H/V, and the two
|
|
|
|
|
// sides are the same natural group, merge it instead of preserving an
|
|
|
|
|
// artificial Voronoi-like cut.
|
|
|
|
|
for (let pass = 0; pass < passes; pass++) {
|
|
|
|
|
rebuildLandscapeUnitAdjacency(unitId, units, fields.naturalBarrierScore, prefectureMask, sea);
|
|
|
|
|
let best = null;
|
|
|
|
|
let bestScore = 0.0;
|
|
|
|
|
for (const unit of units) {
|
|
|
|
|
if (!unit || unit.area === 0) continue;
|
|
|
|
|
for (const [otherId, edge] of unit.adjacent) {
|
|
|
|
|
if (otherId <= unit.id) continue;
|
|
|
|
|
const other = units[otherId];
|
|
|
|
|
if (!other || other.area === 0) continue;
|
|
|
|
|
const avgBarrier = edge.target / Math.max(1, edge.count);
|
|
|
|
|
const sameClass = unit.classId === other.classId;
|
|
|
|
|
const sameGroup = naturalGroupKey(unit) === naturalGroupKey(other);
|
|
|
|
|
const combinedArea = unit.area + other.area;
|
|
|
|
|
if (!sameClass && !sameGroup) continue;
|
|
|
|
|
if (combinedArea > maxMergedArea && unit.area > 18 && other.area > 18) continue;
|
|
|
|
|
const h = edge.horizontal || 0;
|
|
|
|
|
const v = edge.vertical || 0;
|
|
|
|
|
const balancedStair = Math.min(h, v) / Math.max(1, h + v);
|
|
|
|
|
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);
|
2026-05-28 00:30:09 +09:00
|
|
|
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;
|
2026-05-26 21:14:37 +09:00
|
|
|
const score =
|
|
|
|
|
(sameClass ? 1.7 : 0) +
|
|
|
|
|
(sameGroup ? 1.2 : 0) +
|
|
|
|
|
balancedStair * 1.4 +
|
|
|
|
|
edge.count * 0.018 +
|
|
|
|
|
lowlandContinuity * 0.85 +
|
|
|
|
|
mountainContinuity * 0.35 -
|
|
|
|
|
avgBarrier * 4.8 -
|
|
|
|
|
Math.max(0, combinedArea - maxMergedArea) * 0.020 -
|
|
|
|
|
urbanGuard * 0.20;
|
|
|
|
|
if (score > bestScore) best = { fromId: unit.area <= other.area ? unit.id : otherId, toId: unit.area <= other.area ? otherId : unit.id }, bestScore = score;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!best) break;
|
|
|
|
|
mergeUnitInto(unitId, units, best.fromId, best.toId, fields);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 17:38:51 +09:00
|
|
|
function naturalOwnershipAffinity(unit, neighbor, edge) {
|
|
|
|
|
const boundaryTarget = edge.target / Math.max(1, edge.count);
|
|
|
|
|
const sameClass = unit.classId === neighbor.classId ? 1.0 : 0;
|
|
|
|
|
const sameGroup = naturalGroupKey(unit) === naturalGroupKey(neighbor) ? 1.1 : 0;
|
|
|
|
|
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;
|
2026-05-28 00:30:09 +09:00
|
|
|
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;
|
2026-05-24 17:38:51 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function compartmentCrossingCost(unit, neighbor, edge) {
|
|
|
|
|
const boundaryScore = edge.target / Math.max(1, edge.count);
|
|
|
|
|
const sameClass = unit.classId === neighbor.classId ? 1 : 0;
|
|
|
|
|
const sameGroup = naturalGroupKey(unit) === naturalGroupKey(neighbor) ? 1 : 0;
|
|
|
|
|
const lowlandContinuity = Math.min(unit.lowlandFitness || 0, neighbor.lowlandFitness || 0);
|
|
|
|
|
const urbanContinuity = Math.min(unit.urbanWeight || 0, neighbor.urbanWeight || 0);
|
|
|
|
|
const mountainPenalty = Math.max(unit.mountainFitness || 0, neighbor.mountainFitness || 0);
|
|
|
|
|
const ridgePenalty = Math.max(unit.ridgeExposure || 0, neighbor.ridgeExposure || 0);
|
|
|
|
|
return Math.max(0.18,
|
|
|
|
|
1.0 +
|
2026-05-28 00:30:09 +09:00
|
|
|
boundaryScore * 9.2 +
|
|
|
|
|
mountainPenalty * 2.65 +
|
|
|
|
|
ridgePenalty * 2.75 +
|
|
|
|
|
(boundaryScore > 0.64 || ridgePenalty > 0.64 ? 5.5 : 0) -
|
2026-05-24 17:38:51 +09:00
|
|
|
sameClass * 0.45 -
|
|
|
|
|
sameGroup * 0.35 -
|
2026-05-28 00:30:09 +09:00
|
|
|
lowlandContinuity * 0.98 -
|
|
|
|
|
urbanContinuity * 0.64 -
|
2026-05-24 17:38:51 +09:00
|
|
|
Math.min(1.0, edge.count / 12) * 0.25
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 00:30:09 +09:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 17:38:51 +09:00
|
|
|
function graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options = {}) {
|
|
|
|
|
const owner = new Int16Array(compartments.length);
|
|
|
|
|
const dist = new Float32Array(compartments.length);
|
|
|
|
|
owner.fill(-1);
|
|
|
|
|
dist.fill(INF);
|
|
|
|
|
const heap = new MinHeap();
|
|
|
|
|
for (let id = 0; id < adminCenters.length; id++) {
|
|
|
|
|
const center = adminCenters[id];
|
|
|
|
|
if (!center || !inside(center.x, center.y)) continue;
|
|
|
|
|
const compIndex = compartmentId[indexOf(center.x, center.y)];
|
|
|
|
|
const unit = compartments[compIndex];
|
|
|
|
|
if (compIndex < 0 || !unit || unit.area === 0) continue;
|
|
|
|
|
unit.centerIds.push(id);
|
|
|
|
|
if (dist[compIndex] > 0) {
|
|
|
|
|
dist[compIndex] = 0;
|
|
|
|
|
owner[compIndex] = id;
|
|
|
|
|
heap.push({ i: compIndex, f: 0, owner: id });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
while (heap.length > 0) {
|
|
|
|
|
const cur = heap.pop();
|
|
|
|
|
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
|
|
|
|
|
const unit = compartments[cur.i];
|
|
|
|
|
if (!unit || unit.area === 0) continue;
|
|
|
|
|
const center = adminCenters[cur.owner];
|
|
|
|
|
for (const [neighborId, edge] of unit.adjacent) {
|
|
|
|
|
const neighbor = compartments[neighborId];
|
|
|
|
|
if (!neighbor || neighbor.area === 0) continue;
|
|
|
|
|
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;
|
2026-05-28 00:30:09 +09:00
|
|
|
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;
|
2026-05-24 17:38:51 +09:00
|
|
|
if (next + 1e-5 < dist[neighborId]) {
|
|
|
|
|
dist[neighborId] = next;
|
|
|
|
|
owner[neighborId] = cur.owner;
|
|
|
|
|
heap.push({ i: neighborId, f: next, owner: cur.owner });
|
|
|
|
|
} else if (Math.abs(next - dist[neighborId]) < 0.08 && owner[neighborId] >= 0) {
|
|
|
|
|
const oldCenter = adminCenters[owner[neighborId]];
|
|
|
|
|
const oldD = oldCenter ? Math.hypot(neighbor.x - oldCenter.x, neighbor.y - oldCenter.y) : INF;
|
|
|
|
|
const newD = center ? Math.hypot(neighbor.x - center.x, neighbor.y - center.y) : INF;
|
|
|
|
|
if (newD < oldD - 1.5 || (newD < oldD + 1.5 && cur.owner < owner[neighborId])) owner[neighborId] = cur.owner;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (const unit of compartments) {
|
|
|
|
|
if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue;
|
|
|
|
|
let bestOwner = -1, bestScore = INF;
|
|
|
|
|
for (const [neighborId, edge] of unit.adjacent) {
|
|
|
|
|
if (owner[neighborId] < 0) continue;
|
|
|
|
|
const neighbor = compartments[neighborId];
|
|
|
|
|
const score = compartmentCrossingCost(unit, neighbor, edge) + (neighbor?.area || 0) * -0.001;
|
|
|
|
|
if (score < bestScore) { bestScore = score; bestOwner = owner[neighborId]; }
|
|
|
|
|
}
|
|
|
|
|
owner[unit.id] = bestOwner >= 0 ? bestOwner : 0;
|
|
|
|
|
}
|
|
|
|
|
return owner;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function compartmentMunicipalityMetrics(compartments, owner, targetMunicipalityCount = 0, targetCompartmentCount = 0) {
|
|
|
|
|
const counts = new Map();
|
|
|
|
|
let active = 0;
|
|
|
|
|
for (const unit of compartments) {
|
|
|
|
|
if (!unit || unit.area === 0) continue;
|
|
|
|
|
active++;
|
|
|
|
|
const id = owner[unit.id];
|
|
|
|
|
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
|
|
|
|
|
}
|
|
|
|
|
const actual = counts.size;
|
|
|
|
|
const singles = [...counts.values()].filter((value) => value === 1).length;
|
|
|
|
|
return {
|
|
|
|
|
targetMunicipalityCount,
|
|
|
|
|
actualMunicipalityCount: actual,
|
|
|
|
|
targetNaturalCompartmentCount: targetCompartmentCount,
|
|
|
|
|
naturalCompartmentCount: active,
|
|
|
|
|
compartmentCount: active,
|
|
|
|
|
averageCompartmentsPerMunicipality: actual ? active / actual : 0,
|
|
|
|
|
singleCompartmentMunicipalityRatio: actual ? singles / actual : 0,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore) {
|
|
|
|
|
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 += (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5;
|
|
|
|
|
count++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return count ? sum / count : 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 00:30:09 +09:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 17:38:51 +09:00
|
|
|
function assignCompartmentsToAdminOwners(compartments, compartmentId, adminCenters, naturalBarrierScore, prefectureMask, sea) {
|
|
|
|
|
const owner = new Int16Array(compartments.length);
|
|
|
|
|
owner.fill(-1);
|
|
|
|
|
for (let id = 0; id < adminCenters.length; id++) {
|
|
|
|
|
const center = adminCenters[id];
|
|
|
|
|
if (!center || !inside(center.x, center.y)) continue;
|
|
|
|
|
const compIndex = compartmentId[indexOf(center.x, center.y)];
|
|
|
|
|
if (compIndex >= 0 && compartments[compIndex]?.area > 0) {
|
|
|
|
|
const unit = compartments[compIndex];
|
|
|
|
|
unit.centerIds.push(id);
|
|
|
|
|
owner[compIndex] = id;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (let pass = 0; pass < compartments.length + 8; pass++) {
|
|
|
|
|
let changed = 0;
|
|
|
|
|
for (const unit of compartments) {
|
|
|
|
|
if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue;
|
|
|
|
|
let bestOwner = -1;
|
|
|
|
|
let bestScore = -INF;
|
|
|
|
|
for (const [neighborId, edge] of unit.adjacent) {
|
|
|
|
|
const neighborOwner = owner[neighborId];
|
|
|
|
|
if (neighborOwner < 0) continue;
|
|
|
|
|
const neighbor = compartments[neighborId];
|
|
|
|
|
if (!neighbor || neighbor.area === 0) continue;
|
|
|
|
|
const center = adminCenters[neighborOwner];
|
|
|
|
|
const d = center ? Math.hypot(unit.x - center.x, unit.y - center.y) : 0;
|
|
|
|
|
const score = naturalOwnershipAffinity(unit, neighbor, edge) - d * 0.006 + Math.min(0.9, Math.sqrt(Math.max(1, neighbor.area)) * 0.020);
|
|
|
|
|
if (score > bestScore) { bestScore = score; bestOwner = neighborOwner; }
|
|
|
|
|
}
|
|
|
|
|
const accept = unit.classId <= 3 ? bestScore > -0.35 : unit.classId === 8 || unit.classId === 9 ? bestScore > -1.05 : bestScore > -0.70;
|
|
|
|
|
if (bestOwner >= 0 && accept) {
|
|
|
|
|
owner[unit.id] = bestOwner;
|
|
|
|
|
changed++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (changed === 0) break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const unit of compartments) {
|
|
|
|
|
if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue;
|
|
|
|
|
let bestId = -1;
|
|
|
|
|
let bestScore = -INF;
|
|
|
|
|
for (let id = 0; id < adminCenters.length; id++) {
|
|
|
|
|
const center = adminCenters[id];
|
|
|
|
|
if (!center || !inside(center.x, center.y)) continue;
|
|
|
|
|
const centerComp = compartments[compartmentId[indexOf(center.x, center.y)]];
|
|
|
|
|
const sameGroup = centerComp && naturalGroupKey(centerComp) === naturalGroupKey(unit) ? 2.3 : 0;
|
|
|
|
|
const sameClass = centerComp && centerComp.classId === unit.classId ? 0.8 : 0;
|
|
|
|
|
const urbanFit = unit.urbanWeight > 0.55 && centerComp?.urbanWeight > 0.55 ? 1.3 : 0;
|
|
|
|
|
const d = Math.hypot(unit.x - center.x, unit.y - center.y);
|
|
|
|
|
const score = sameGroup + sameClass + urbanFit - d * 0.020 - unit.ridgeExposure * 0.16;
|
|
|
|
|
if (score > bestScore) { bestScore = score; bestId = id; }
|
|
|
|
|
}
|
|
|
|
|
owner[unit.id] = bestId >= 0 ? bestId : 0;
|
|
|
|
|
}
|
|
|
|
|
return owner;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function extractCompartmentBorders(compartmentId, 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] || compartmentId[i] < 0) continue;
|
|
|
|
|
const a = compartmentId[i];
|
|
|
|
|
if (x + 1 < MAP_W) {
|
|
|
|
|
const ni = indexOf(x + 1, y);
|
|
|
|
|
if (prefectureMask[ni] && !sea[ni] && compartmentId[ni] >= 0 && compartmentId[ni] !== a) segments.push([[x + 1, y], [x + 1, y + 1]]);
|
|
|
|
|
}
|
|
|
|
|
if (y + 1 < MAP_H) {
|
|
|
|
|
const ni = indexOf(x, y + 1);
|
|
|
|
|
if (prefectureMask[ni] && !sea[ni] && compartmentId[ni] >= 0 && compartmentId[ni] !== a) segments.push([[x, y + 1], [x + 1, y + 1]]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return segments;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], options = {}) {
|
2026-05-26 00:45:01 +09:00
|
|
|
const progress = typeof options.progress === "function" ? options.progress : null;
|
2026-05-26 15:32:27 +09:00
|
|
|
const sharedCompartments = options.naturalCompartmentId && options.naturalCompartments
|
|
|
|
|
? { compartmentId: options.naturalCompartmentId, compartments: options.naturalCompartments, naturalBarrierScore: options.naturalBarrierScore || ridgeField }
|
|
|
|
|
: null;
|
|
|
|
|
const { compartmentId, compartments, naturalBarrierScore } = sharedCompartments ||
|
|
|
|
|
buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse, options);
|
2026-05-26 00:45:01 +09:00
|
|
|
progress?.("natural compartments built");
|
2026-05-24 17:38:51 +09:00
|
|
|
const adminId = new Int16Array(SIZE);
|
|
|
|
|
adminId.fill(-1);
|
2026-05-28 00:30:09 +09:00
|
|
|
const unifiedGeographyApplied = enrichCompartmentsWithUnifiedGeography(compartments, options);
|
2026-05-24 17:38:51 +09:00
|
|
|
const owner = graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options);
|
2026-05-26 00:45:01 +09:00
|
|
|
progress?.("natural compartments assigned");
|
2026-05-24 17:38:51 +09:00
|
|
|
for (const unit of compartments) {
|
|
|
|
|
const assigned = owner[unit.id];
|
|
|
|
|
if (assigned < 0) continue;
|
|
|
|
|
for (const i of unit.cells) adminId[i] = assigned;
|
|
|
|
|
}
|
|
|
|
|
for (let i = 0; i < SIZE; i++) {
|
|
|
|
|
if (!prefectureMask[i] || sea[i] || adminId[i] >= 0) continue;
|
|
|
|
|
const comp = compartments[compartmentId[i]];
|
|
|
|
|
adminId[i] = comp && owner[comp.id] >= 0 ? owner[comp.id] : 0;
|
|
|
|
|
}
|
|
|
|
|
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse);
|
2026-05-26 00:45:01 +09:00
|
|
|
progress?.("natural topology repaired");
|
2026-05-24 17:38:51 +09:00
|
|
|
const activeCompartments = compartments.filter((unit) => unit.area > 0);
|
|
|
|
|
const relationMetrics = compartmentMunicipalityMetrics(compartments, owner, options.targetMunicipalityCount || adminCenters.length, options.targetCompartmentCount || 0);
|
|
|
|
|
return {
|
|
|
|
|
adminId,
|
|
|
|
|
compartmentId,
|
|
|
|
|
compartments,
|
|
|
|
|
naturalBarrierScore,
|
|
|
|
|
debug: {
|
|
|
|
|
...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),
|
2026-05-28 00:30:09 +09:00
|
|
|
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),
|
2026-05-24 17:38:51 +09:00
|
|
|
voronoiLikeRateBefore: 0,
|
|
|
|
|
voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore) {
|
|
|
|
|
let weak = 0;
|
|
|
|
|
let total = 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);
|
|
|
|
|
const a = adminId[i], b = adminId[ni];
|
|
|
|
|
if (!prefectureMask[ni] || sea[ni] || a < 0 || b < 0 || a === b) continue;
|
|
|
|
|
total++;
|
|
|
|
|
const ca = adminCenters[a], cb = adminCenters[b];
|
|
|
|
|
if (!ca || !cb) continue;
|
|
|
|
|
const mx = (x + nx) * 0.5, my = (y + ny) * 0.5;
|
|
|
|
|
const nearBisector = Math.abs(Math.hypot(mx - ca.x, my - ca.y) - Math.hypot(mx - cb.x, my - cb.y)) < 4.0;
|
|
|
|
|
if (nearBisector && (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5 < 0.38) weak++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return total ? weak / total : 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = []) {
|
|
|
|
|
const before = new Int16Array(adminId);
|
|
|
|
|
const initialNaturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse);
|
|
|
|
|
const beforeVoronoiLikeRate = weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, initialNaturalBarrierScore);
|
|
|
|
|
const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse);
|
|
|
|
|
if (compartments.length === 0) return;
|
|
|
|
|
for (let id = 0; id < adminCenters.length; id++) {
|
|
|
|
|
const center = adminCenters[id];
|
|
|
|
|
if (!center || !inside(center.x, center.y)) continue;
|
|
|
|
|
const unit = compartments[compartmentId[indexOf(center.x, center.y)]];
|
|
|
|
|
if (unit) unit.centerIds.push(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const owner = new Int16Array(compartments.length);
|
|
|
|
|
owner.fill(-1);
|
|
|
|
|
for (const unit of compartments) {
|
|
|
|
|
if (unit.area === 0 || unit.centerIds.length === 0) continue;
|
|
|
|
|
owner[unit.id] = unit.centerIds[0];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (let pass = 0; pass < compartments.length + 4; pass++) {
|
|
|
|
|
let changed = 0;
|
|
|
|
|
for (const unit of compartments) {
|
|
|
|
|
if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue;
|
|
|
|
|
let bestOwner = -1;
|
|
|
|
|
let bestScore = -INF;
|
|
|
|
|
for (const [neighborId, edge] of unit.adjacent) {
|
|
|
|
|
const neighborOwner = owner[neighborId];
|
|
|
|
|
if (neighborOwner < 0) continue;
|
|
|
|
|
const neighbor = compartments[neighborId];
|
|
|
|
|
if (!neighbor || neighbor.area === 0) continue;
|
|
|
|
|
const score = naturalOwnershipAffinity(unit, neighbor, edge) + Math.min(0.8, Math.sqrt(Math.max(1, neighbor.area)) * 0.018);
|
|
|
|
|
if (score > bestScore) { bestScore = score; bestOwner = neighborOwner; }
|
|
|
|
|
}
|
|
|
|
|
const accept = unit.classId <= 3 ? bestScore > -0.15 : unit.classId === 8 || unit.classId === 9 ? bestScore > -0.80 : bestScore > -0.45;
|
|
|
|
|
if (bestOwner >= 0 && accept) { owner[unit.id] = bestOwner; changed++; }
|
|
|
|
|
}
|
|
|
|
|
if (changed === 0) break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const unit of compartments) {
|
|
|
|
|
if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue;
|
|
|
|
|
let bestId = -1, bestScore = -INF;
|
|
|
|
|
for (let id = 0; id < adminCenters.length; id++) {
|
|
|
|
|
const center = adminCenters[id];
|
|
|
|
|
if (!center || !inside(center.x, center.y)) continue;
|
|
|
|
|
const centerComp = compartments[compartmentId[indexOf(center.x, center.y)]];
|
|
|
|
|
const sameGroup = centerComp && naturalGroupKey(centerComp) === naturalGroupKey(unit) ? 2.4 : 0;
|
|
|
|
|
const sameClass = centerComp && centerComp.classId === unit.classId ? 0.9 : 0;
|
|
|
|
|
const urbanFit = unit.urbanWeight > 0.55 && centerComp?.urbanWeight > 0.55 ? 1.2 : 0;
|
|
|
|
|
const d = Math.hypot(unit.x - center.x, unit.y - center.y);
|
|
|
|
|
const score = sameGroup + sameClass + urbanFit - d * 0.018 - unit.ridgeExposure * 0.18;
|
|
|
|
|
if (score > bestScore) { bestScore = score; bestId = id; }
|
|
|
|
|
}
|
|
|
|
|
owner[unit.id] = bestId >= 0 ? bestId : 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const unit of compartments) {
|
|
|
|
|
const assigned = owner[unit.id];
|
|
|
|
|
if (assigned >= 0) for (const i of unit.cells) adminId[i] = assigned;
|
|
|
|
|
}
|
|
|
|
|
for (let i = 0; i < SIZE; i++) {
|
|
|
|
|
if (!prefectureMask[i] || sea[i] || adminId[i] >= 0) continue;
|
|
|
|
|
const comp = compartments[compartmentId[i]];
|
|
|
|
|
adminId[i] = comp && owner[comp.id] >= 0 ? owner[comp.id] : 0;
|
|
|
|
|
}
|
|
|
|
|
for (let id = 0; id < adminCenters.length; id++) {
|
|
|
|
|
const center = adminCenters[id];
|
|
|
|
|
if (!center || !inside(center.x, center.y)) continue;
|
|
|
|
|
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) {
|
|
|
|
|
if (Math.hypot(dx, dy) > 2) continue;
|
|
|
|
|
const x = center.x + dx, y = center.y + dy;
|
|
|
|
|
if (!inside(x, y)) continue;
|
|
|
|
|
const i = indexOf(x, y);
|
|
|
|
|
if (prefectureMask[i] && !sea[i]) adminId[i] = id;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse);
|
|
|
|
|
let changedCells = 0;
|
|
|
|
|
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== adminId[i]) changedCells++;
|
|
|
|
|
const activeCompartments = compartments.filter((unit) => unit.area > 0);
|
|
|
|
|
applyLandscapeUnitAdminPartition.lastDebug = {
|
|
|
|
|
compartmentCount: activeCompartments.length,
|
|
|
|
|
averageCompartmentArea: activeCompartments.length ? activeCompartments.reduce((sum, unit) => sum + unit.area, 0) / activeCompartments.length : 0,
|
|
|
|
|
changedAfterNaturalCompartmentPartition: changedCells,
|
|
|
|
|
finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore),
|
|
|
|
|
voronoiLikeRateBefore: beforeVoronoiLikeRate,
|
|
|
|
|
voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse, adminCenters = [], protectedPoints = [], passes = 6) {
|
|
|
|
|
const targetScore = new Float32Array(SIZE);
|
|
|
|
|
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) targetScore[i] = terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse);
|
|
|
|
|
const protectedMask = buildAdminProtectedMask(adminId, prefectureMask, sea, adminCenters, protectedPoints, populationDensity, landuse);
|
|
|
|
|
const band = buildBoundaryBand(adminId, prefectureMask, sea, 5);
|
|
|
|
|
const adminIds = [...new Set([...adminId].filter((id) => id >= 0))];
|
|
|
|
|
const centerDist = buildCenterDistanceFields(adminIds, adminCenters, prefectureMask, sea);
|
|
|
|
|
let current = new Int16Array(adminId);
|
|
|
|
|
|
|
|
|
|
for (let pass = 0; pass < passes; pass++) {
|
|
|
|
|
const next = new Int16Array(current);
|
|
|
|
|
let changed = 0;
|
|
|
|
|
for (let y = 1; y < MAP_H - 1; y++) {
|
|
|
|
|
for (let x = 1; x < MAP_W - 1; x++) {
|
|
|
|
|
const i = indexOf(x, y);
|
|
|
|
|
const own = current[i];
|
|
|
|
|
if (!band[i] || protectedMask[i] || !prefectureMask[i] || sea[i] || own < 0) continue;
|
|
|
|
|
if (!isAdminBoundaryCell(current, prefectureMask, sea, x, y, true)) continue;
|
|
|
|
|
const candidates = new Set();
|
|
|
|
|
for (const [nx, ny] of neighbors8(x, y)) {
|
|
|
|
|
const ni = indexOf(nx, ny);
|
|
|
|
|
if (prefectureMask[ni] && !sea[ni] && current[ni] >= 0 && current[ni] !== own) candidates.add(current[ni]);
|
|
|
|
|
}
|
|
|
|
|
if (candidates.size === 0) continue;
|
|
|
|
|
const currentEnergy = localBoundaryEnergy(current, i, own, targetScore, centerDist, populationDensity, landuse, river, valleyField);
|
|
|
|
|
let bestId = own, bestEnergy = currentEnergy;
|
|
|
|
|
for (const candidate of candidates) {
|
|
|
|
|
const candidateEnergy = localBoundaryEnergy(current, i, candidate, targetScore, centerDist, populationDensity, landuse, river, valleyField);
|
|
|
|
|
const threshold = 0.18 + (targetScore[i] < 0.36 ? 0.16 : 0) + urbanBoundaryPenalty(i, populationDensity, landuse) * 0.25;
|
|
|
|
|
if (bestEnergy - candidateEnergy > threshold) { bestEnergy = candidateEnergy; bestId = candidate; }
|
|
|
|
|
}
|
|
|
|
|
if (bestId !== own) { next[i] = bestId; changed++; }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
current = next;
|
|
|
|
|
if (changed === 0) break;
|
|
|
|
|
}
|
|
|
|
|
adminId.set(current);
|
|
|
|
|
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, targetScore, populationDensity, landuse);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], settlements = []) {
|
|
|
|
|
const before = new Int16Array(adminId);
|
|
|
|
|
const area = new Map();
|
|
|
|
|
const lowland = new Map();
|
|
|
|
|
const mountain = new Map();
|
|
|
|
|
for (let i = 0; i < SIZE; i++) {
|
|
|
|
|
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
|
|
|
|
|
const id = adminId[i];
|
|
|
|
|
area.set(id, (area.get(id) || 0) + 1);
|
|
|
|
|
const living = (plain[i] || 0) * 0.42 + (agriculture[i] || 0) * 0.28 + basinField[i] * 0.20 + coastalLowland[i] * 0.20 + valleyField[i] * 0.12;
|
|
|
|
|
const rough = ridgeField[i] * 0.54 + slope[i] * 0.36 + Math.max(0, elevation[i] - 0.58) * 0.38;
|
|
|
|
|
lowland.set(id, (lowland.get(id) || 0) + living);
|
|
|
|
|
mountain.set(id, (mountain.get(id) || 0) + rough);
|
|
|
|
|
}
|
|
|
|
|
const areas = [...area.values()].sort((a, b) => a - b);
|
|
|
|
|
const median = areas.length ? areas[Math.floor(areas.length / 2)] : 0;
|
|
|
|
|
if (!median) return { changedCells: 0, splitMunicipalities: 0 };
|
|
|
|
|
|
|
|
|
|
const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse);
|
|
|
|
|
const unitOwner = new Int16Array(compartments.length);
|
|
|
|
|
unitOwner.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; }
|
|
|
|
|
unitOwner[unit.id] = bestId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const adminCenterIndex = new Map();
|
|
|
|
|
for (let id = 0; id < adminCenters.length; id++) {
|
|
|
|
|
const c = adminCenters[id];
|
|
|
|
|
if (c && inside(c.x, c.y)) adminCenterIndex.set(id, indexOf(c.x, c.y));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let splitMunicipalities = 0;
|
|
|
|
|
let rejectedMunicipalities = 0;
|
|
|
|
|
for (const [id, cells] of area) {
|
|
|
|
|
const averageLowland = (lowland.get(id) || 0) / cells;
|
|
|
|
|
const averageMountain = (mountain.get(id) || 0) / cells;
|
|
|
|
|
if (cells < median * 1.85 || averageLowland < 0.24 || averageMountain > 0.48) {
|
|
|
|
|
if (cells >= median * 1.85) rejectedMunicipalities++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const localSettlements = settlements.filter((p) => p && inside(p.x, p.y) && adminId[indexOf(p.x, p.y)] === id);
|
|
|
|
|
const meaningfulNodes = localSettlements.filter((p) => p.kind === "Satellite City" || p.kind === "New Town" || p.kind === "Market Town" || (p.population || 0) >= 30000);
|
|
|
|
|
if (meaningfulNodes.length < 2) {
|
|
|
|
|
rejectedMunicipalities++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let changedHere = 0;
|
|
|
|
|
for (const unit of compartments) {
|
|
|
|
|
if (!unit || unit.area === 0 || unitOwner[unit.id] !== id) continue;
|
|
|
|
|
const centerIndex = adminCenterIndex.get(id);
|
|
|
|
|
if (centerIndex >= 0 && unit.cells.includes(centerIndex)) continue;
|
|
|
|
|
if (unit.classId === 8 || unit.classId === 9) continue;
|
|
|
|
|
let bestNeighbor = -1;
|
|
|
|
|
let bestScore = -INF;
|
|
|
|
|
for (const [neighborId, edge] of unit.adjacent) {
|
|
|
|
|
const neighborOwner = unitOwner[neighborId];
|
|
|
|
|
if (neighborOwner < 0 || neighborOwner === id) continue;
|
|
|
|
|
const boundaryTarget = edge.target / Math.max(1, edge.count);
|
|
|
|
|
const neighbor = compartments[neighborId];
|
|
|
|
|
const nodePull = meaningfulNodes.reduce((best, p) => Math.max(best, 1 / (1 + Math.hypot(p.x - unit.x, p.y - unit.y) / 6)), 0);
|
|
|
|
|
const score = edge.count * 0.7 + boundaryTarget * 1.4 + nodePull * 1.2 - Math.max(0, (neighbor?.ridgeExposure || 0) - unit.ridgeExposure) * 0.35;
|
|
|
|
|
if (score > bestScore) { bestScore = score; bestNeighbor = neighborOwner; }
|
|
|
|
|
}
|
|
|
|
|
if (bestNeighbor < 0 || bestScore < 2.2) continue;
|
|
|
|
|
for (const ci of unit.cells) {
|
|
|
|
|
if (adminId[ci] === id) {
|
|
|
|
|
adminId[ci] = bestNeighbor;
|
|
|
|
|
changedHere++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (changedHere > Math.max(28, cells * 0.035)) splitMunicipalities++;
|
|
|
|
|
}
|
|
|
|
|
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse);
|
|
|
|
|
let changedCells = 0;
|
|
|
|
|
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== adminId[i]) changedCells++;
|
|
|
|
|
return { changedCells, splitMunicipalities, rejectedMunicipalities };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function splitOversizedLowlandMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], settlements = []) {
|
|
|
|
|
return splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters, settlements);
|
|
|
|
|
}
|