2471 lines
118 KiB
JavaScript
2471 lines
118 KiB
JavaScript
import {
|
|
applyLandscapeUnitAdminPartition,
|
|
assignAdminRegionsFromNaturalCompartments,
|
|
lockSmallUrbanComponentsToMunicipality,
|
|
mergeTinyMunicipalities,
|
|
removeMunicipalExclaves,
|
|
smoothAdminRegionsTerrainAware,
|
|
splitOversizedLowlandMunicipalities,
|
|
snapAdminBoundariesToTerrain,
|
|
} from "./adminRegions.js";
|
|
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, xyOf } from "./mapUtils.js";
|
|
import { extractAdminBorderSegments } from "./mapGeneratorHelpers.js";
|
|
|
|
function changedCellsSince(before, after, prefectureMask, sea) {
|
|
let changed = 0;
|
|
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== after[i]) changed++;
|
|
return changed;
|
|
}
|
|
|
|
function municipalityAreaById(adminId, prefectureMask, sea) {
|
|
const area = new Map();
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
|
|
area.set(adminId[i], (area.get(adminId[i]) || 0) + 1);
|
|
}
|
|
return area;
|
|
}
|
|
|
|
function maskLandArea(mask, sea) {
|
|
let area = 0;
|
|
for (let i = 0; i < SIZE; i++) if (mask[i] && !sea[i]) area++;
|
|
return area;
|
|
}
|
|
|
|
function compactWholeCompartmentMunicipalities(adminId, centers, prefectureMask, sea, fields = {}) {
|
|
const activeIds = [...new Set([...adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i]))].sort((a, b) => a - b);
|
|
const idMap = new Map(activeIds.map((id, n) => [id, n]));
|
|
const compactId = new Int16Array(SIZE);
|
|
compactId.fill(-1);
|
|
const stats = activeIds.map(() => ({ sx: 0, sy: 0, count: 0, bestI: -1, bestScore: -INF }));
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
|
|
const nextId = idMap.get(adminId[i]);
|
|
if (nextId === undefined) continue;
|
|
compactId[i] = nextId;
|
|
const [x, y] = xyOf(i);
|
|
const row = stats[nextId];
|
|
row.sx += x;
|
|
row.sy += y;
|
|
row.count++;
|
|
const score = (fields.populationDensity?.[i] || 0) * 2.2 + (fields.plain?.[i] || 0) * 0.25 - (fields.slope?.[i] || 0) * 0.2;
|
|
if (score > row.bestScore) { row.bestScore = score; row.bestI = i; }
|
|
}
|
|
const compactCenters = activeIds.map((oldId, newId) => {
|
|
const current = centers[oldId];
|
|
if (current && inside(current.x, current.y) && compactId[indexOf(current.x, current.y)] === newId) {
|
|
return { ...current, originalAdminId: oldId };
|
|
}
|
|
const row = stats[newId];
|
|
const fallback = row.bestI >= 0 ? xyOf(row.bestI) : [Math.round(row.sx / Math.max(1, row.count)), Math.round(row.sy / Math.max(1, row.count))];
|
|
return {
|
|
...(current || {}),
|
|
x: fallback[0],
|
|
y: fallback[1],
|
|
originalAdminId: oldId,
|
|
generatedOfficePoint: true,
|
|
invisibleLowlandAdminSeed: current?.invisibleLowlandAdminSeed ?? true,
|
|
seedKind: current?.seedKind || "compactedMunicipalityOffice",
|
|
};
|
|
});
|
|
return { adminId: compactId, adminCentersRaw: compactCenters, activeMunicipalityCount: compactCenters.length };
|
|
}
|
|
|
|
function enforceCompartmentMunicipalityOwnership(adminId, compartmentId, compartments, prefectureMask, sea) {
|
|
if (!compartmentId || !compartments) return 0;
|
|
let changed = 0;
|
|
for (const comp of compartments) {
|
|
if (!comp || !comp.cells?.length) continue;
|
|
const counts = new Map();
|
|
for (const i of comp.cells) {
|
|
if (!prefectureMask[i] || sea[i]) continue;
|
|
const id = adminId[i];
|
|
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
|
|
}
|
|
let bestId = -1, bestCount = -1;
|
|
for (const [id, count] of counts) {
|
|
if (count > bestCount || (count === bestCount && id < bestId)) {
|
|
bestId = id;
|
|
bestCount = count;
|
|
}
|
|
}
|
|
if (bestId < 0) continue;
|
|
for (const i of comp.cells) {
|
|
if (!prefectureMask[i] || sea[i] || adminId[i] === bestId) continue;
|
|
adminId[i] = bestId;
|
|
changed++;
|
|
}
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
|
|
function mergeSingleCompartmentMunicipalities(adminId, compartments, prefectureMask, sea, minCompartments = 2, maxPasses = 8) {
|
|
if (!compartments?.length) return { changedCells: 0, mergedMunicipalities: 0, remainingSingleCompartmentMunicipalities: 0 };
|
|
let totalChangedCells = 0;
|
|
let mergedMunicipalities = 0;
|
|
let remainingSingles = 0;
|
|
for (let pass = 0; pass < maxPasses; pass++) {
|
|
const owner = dominantCompartmentOwners(compartments, adminId);
|
|
const byOwner = new Map();
|
|
const areaByOwner = new Map();
|
|
for (const unit of compartments) {
|
|
if (!unit || unit.area === 0) continue;
|
|
const id = owner[unit.id];
|
|
if (id < 0) continue;
|
|
if (!byOwner.has(id)) byOwner.set(id, []);
|
|
byOwner.get(id).push(unit);
|
|
areaByOwner.set(id, (areaByOwner.get(id) || 0) + unit.area);
|
|
}
|
|
const singles = [...byOwner.entries()]
|
|
.filter(([, units]) => units.length > 0 && units.length < minCompartments)
|
|
.sort((a, b) => (areaByOwner.get(a[0]) || 0) - (areaByOwner.get(b[0]) || 0) || a[0] - b[0]);
|
|
remainingSingles = singles.length;
|
|
if (!singles.length) break;
|
|
let passChanged = 0;
|
|
for (const [id, units] of singles) {
|
|
// The old rule allowed one natural compartment to become one municipality.
|
|
// That produces many tiny office-only municipalities and makes the hierarchy
|
|
// hard to read. Merge such municipalities into the strongest adjacent owner.
|
|
const neighborScores = new Map();
|
|
for (const unit of units) {
|
|
for (const [neighborId, edge] of unit.adjacent || []) {
|
|
const candidate = owner[neighborId];
|
|
if (candidate < 0 || candidate === id) continue;
|
|
const neighborUnit = compartments[neighborId];
|
|
const shared = edge.count || 1;
|
|
const barrier = edge.target ? edge.target / Math.max(1, shared) : 0;
|
|
const sameLandscape = neighborUnit?.classId === unit.classId ? 0.7 : 0;
|
|
const score = shared * (2.2 - Math.min(1.6, barrier) + sameLandscape) + Math.sqrt(areaByOwner.get(candidate) || 1) * 0.05;
|
|
neighborScores.set(candidate, (neighborScores.get(candidate) || 0) + score);
|
|
}
|
|
}
|
|
let best = -1, bestScore = -INF;
|
|
for (const [candidate, score] of neighborScores) {
|
|
if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; }
|
|
}
|
|
if (best < 0) {
|
|
// One-cell islets have no land adjacency. Attach them to the nearest
|
|
// existing municipality instead of leaving a one-compartment municipality.
|
|
const ux = units.reduce((sum, unit) => sum + (unit.x || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0));
|
|
const uy = units.reduce((sum, unit) => sum + (unit.y || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0));
|
|
let bestDist = INF;
|
|
for (const [candidate, candidateUnits] of byOwner) {
|
|
if (candidate === id || candidateUnits.length < minCompartments) continue;
|
|
for (const unit of candidateUnits) {
|
|
const d = Math.hypot((unit.x || 0) - ux, (unit.y || 0) - uy);
|
|
if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; }
|
|
}
|
|
}
|
|
if (bestDist > 28) best = -1;
|
|
}
|
|
if (best < 0) continue;
|
|
for (const unit of units) {
|
|
for (const i of unit.cells || []) {
|
|
if (!prefectureMask[i] || sea[i]) continue;
|
|
if (adminId[i] !== best) {
|
|
adminId[i] = best;
|
|
passChanged++;
|
|
}
|
|
}
|
|
}
|
|
mergedMunicipalities++;
|
|
}
|
|
totalChangedCells += passChanged;
|
|
if (!passChanged) break;
|
|
}
|
|
const finalOwner = dominantCompartmentOwners(compartments, adminId);
|
|
const finalCounts = new Map();
|
|
for (const unit of compartments) {
|
|
if (!unit || unit.area === 0) continue;
|
|
const id = finalOwner[unit.id];
|
|
if (id >= 0) finalCounts.set(id, (finalCounts.get(id) || 0) + 1);
|
|
}
|
|
remainingSingles = [...finalCounts.values()].filter((count) => count > 0 && count < minCompartments).length;
|
|
return { changedCells: totalChangedCells, mergedMunicipalities, remainingSingleCompartmentMunicipalities: remainingSingles };
|
|
}
|
|
|
|
function ownerAreaByCompartment(owner, compartments) {
|
|
const area = new Map();
|
|
const count = new Map();
|
|
for (const unit of compartments || []) {
|
|
if (!unit || unit.area === 0) continue;
|
|
const id = owner[unit.id];
|
|
if (id < 0) continue;
|
|
area.set(id, (area.get(id) || 0) + (unit.area || 0));
|
|
count.set(id, (count.get(id) || 0) + 1);
|
|
}
|
|
return { area, count };
|
|
}
|
|
|
|
function compartmentTouchesOutside(unit, prefectureMask, sea) {
|
|
if (!unit?.cells?.length) return true;
|
|
for (const i of unit.cells) {
|
|
const [x, y] = xyOf(i);
|
|
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) return true;
|
|
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
|
const nx = x + dx, ny = y + dy;
|
|
if (!inside(nx, ny)) return true;
|
|
const ni = indexOf(nx, ny);
|
|
if (!prefectureMask[ni] || sea[ni]) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function bestNeighborOwnerForUnits(units, owner, compartments, allowNearestFallback = false) {
|
|
const { area } = ownerAreaByCompartment(owner, compartments);
|
|
const scores = new Map();
|
|
const blocked = new Set(units.map((unit) => owner[unit.id]));
|
|
for (const unit of units) {
|
|
for (const [neighborId, edge] of unit.adjacent || []) {
|
|
const candidate = owner[neighborId];
|
|
if (candidate < 0 || blocked.has(candidate)) continue;
|
|
const neighbor = compartments[neighborId];
|
|
const shared = edge.count || 1;
|
|
const barrier = (edge.target || 0) / Math.max(1, shared);
|
|
const landscape = neighbor?.classId === unit.classId ? 0.75 : 0;
|
|
const lowland = Math.min(unit.lowlandFitness || 0, neighbor?.lowlandFitness || 0) * 0.5;
|
|
const score = shared * (2.4 + landscape + lowland - Math.min(1.8, barrier)) + Math.sqrt(area.get(candidate) || 1) * 0.04;
|
|
scores.set(candidate, (scores.get(candidate) || 0) + score);
|
|
}
|
|
}
|
|
let best = -1, bestScore = -INF;
|
|
for (const [candidate, score] of scores) {
|
|
if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; }
|
|
}
|
|
if (best >= 0 || !allowNearestFallback) return best;
|
|
const ux = units.reduce((sum, unit) => sum + (unit.x || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0));
|
|
const uy = units.reduce((sum, unit) => sum + (unit.y || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0));
|
|
let bestDist = INF;
|
|
for (const unit of compartments || []) {
|
|
if (!unit || unit.area === 0) continue;
|
|
const candidate = owner[unit.id];
|
|
if (candidate < 0 || blocked.has(candidate)) continue;
|
|
const d = Math.hypot((unit.x || 0) - ux, (unit.y || 0) - uy);
|
|
if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; }
|
|
}
|
|
return bestDist <= 32 ? best : -1;
|
|
}
|
|
|
|
function mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, minCompartments = 2, maxPasses = 8) {
|
|
let changedCells = 0;
|
|
let mergedMunicipalities = 0;
|
|
let remainingSingleCompartmentMunicipalities = 0;
|
|
for (let pass = 0; pass < maxPasses; pass++) {
|
|
const byOwner = new Map();
|
|
for (const unit of compartments || []) {
|
|
if (!unit || unit.area === 0) continue;
|
|
const id = owner[unit.id];
|
|
if (id < 0) continue;
|
|
if (!byOwner.has(id)) byOwner.set(id, []);
|
|
byOwner.get(id).push(unit);
|
|
}
|
|
const small = [...byOwner.entries()]
|
|
.filter(([, units]) => units.length > 0 && units.length < minCompartments)
|
|
.sort((a, b) => a[1].length - b[1].length || a[0] - b[0]);
|
|
remainingSingleCompartmentMunicipalities = small.length;
|
|
if (!small.length) break;
|
|
let passChanged = 0;
|
|
for (const [id, units] of small) {
|
|
const target = bestNeighborOwnerForUnits(units, owner, compartments, true);
|
|
if (target < 0 || target === id) continue;
|
|
for (const unit of units) {
|
|
if (owner[unit.id] === target) continue;
|
|
owner[unit.id] = target;
|
|
changedCells += unit.area || 0;
|
|
passChanged += unit.area || 0;
|
|
}
|
|
mergedMunicipalities++;
|
|
}
|
|
if (!passChanged) break;
|
|
}
|
|
const counts = ownerAreaByCompartment(owner, compartments).count;
|
|
remainingSingleCompartmentMunicipalities = [...counts.values()].filter((count) => count > 0 && count < minCompartments).length;
|
|
return { changedCells, mergedMunicipalities, remainingSingleCompartmentMunicipalities };
|
|
}
|
|
|
|
function repairCompartmentOwnerConnectivity(owner, compartments, maxPasses = 8) {
|
|
let changedCells = 0;
|
|
let changedComponents = 0;
|
|
for (let pass = 0; pass < maxPasses; pass++) {
|
|
const ownerIds = [...new Set([...owner].filter((id) => id >= 0))].sort((a, b) => a - b);
|
|
let passChanged = 0;
|
|
for (const id of ownerIds) {
|
|
const members = (compartments || []).filter((unit) => unit && unit.area > 0 && owner[unit.id] === id);
|
|
if (members.length <= 1) continue;
|
|
const memberSet = new Set(members.map((unit) => unit.id));
|
|
const seen = new Set();
|
|
const components = [];
|
|
for (const unit of members) {
|
|
if (seen.has(unit.id)) continue;
|
|
const queue = [unit.id];
|
|
const comp = [];
|
|
seen.add(unit.id);
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
comp.push(compartments[cur]);
|
|
for (const next of compartments[cur]?.adjacent?.keys?.() || []) {
|
|
if (!memberSet.has(next) || seen.has(next)) continue;
|
|
seen.add(next);
|
|
queue.push(next);
|
|
}
|
|
}
|
|
components.push(comp);
|
|
}
|
|
if (components.length <= 1) continue;
|
|
components.sort((a, b) => b.reduce((sum, unit) => sum + (unit.area || 0), 0) - a.reduce((sum, unit) => sum + (unit.area || 0), 0));
|
|
for (const comp of components.slice(1)) {
|
|
const target = bestNeighborOwnerForUnits(comp, owner, compartments, true);
|
|
if (target < 0 || target === id) continue;
|
|
for (const unit of comp) {
|
|
owner[unit.id] = target;
|
|
changedCells += unit.area || 0;
|
|
passChanged += unit.area || 0;
|
|
}
|
|
changedComponents++;
|
|
}
|
|
}
|
|
if (!passChanged) break;
|
|
}
|
|
return { changedCells, changedComponents };
|
|
}
|
|
|
|
function repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, maxPasses = 6) {
|
|
let changedCells = 0;
|
|
let changedComponents = 0;
|
|
const outsideCache = new Map();
|
|
const touchesOutside = (unit) => {
|
|
if (!outsideCache.has(unit.id)) outsideCache.set(unit.id, compartmentTouchesOutside(unit, prefectureMask, sea));
|
|
return outsideCache.get(unit.id);
|
|
};
|
|
for (let pass = 0; pass < maxPasses; pass++) {
|
|
const ownerIds = [...new Set([...owner].filter((id) => id >= 0))].sort((a, b) => a - b);
|
|
let passChanged = 0;
|
|
for (const id of ownerIds) {
|
|
const members = (compartments || []).filter((unit) => unit && unit.area > 0 && owner[unit.id] === id);
|
|
if (!members.length) continue;
|
|
const memberSet = new Set(members.map((unit) => unit.id));
|
|
const seen = new Set();
|
|
for (const unit of members) {
|
|
if (seen.has(unit.id)) continue;
|
|
const queue = [unit.id];
|
|
const comp = [];
|
|
const boundaryOwners = new Map();
|
|
let outside = false;
|
|
seen.add(unit.id);
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
const curUnit = compartments[cur];
|
|
if (!curUnit) continue;
|
|
comp.push(curUnit);
|
|
if (touchesOutside(curUnit)) outside = true;
|
|
for (const [next, edge] of curUnit.adjacent || []) {
|
|
const nextOwner = owner[next];
|
|
if (nextOwner === id) {
|
|
if (!seen.has(next) && memberSet.has(next)) { seen.add(next); queue.push(next); }
|
|
} else if (nextOwner >= 0) {
|
|
boundaryOwners.set(nextOwner, (boundaryOwners.get(nextOwner) || 0) + (edge.count || 1));
|
|
}
|
|
}
|
|
}
|
|
if (outside || boundaryOwners.size !== 1) continue;
|
|
const [target] = boundaryOwners.keys();
|
|
if (target < 0 || target === id) continue;
|
|
for (const compUnit of comp) {
|
|
owner[compUnit.id] = target;
|
|
changedCells += compUnit.area || 0;
|
|
passChanged += compUnit.area || 0;
|
|
}
|
|
changedComponents++;
|
|
}
|
|
}
|
|
if (!passChanged) break;
|
|
}
|
|
return { changedCells, changedComponents };
|
|
}
|
|
|
|
function lockCompactUrbanCompartmentsToDominantOwner(owner, compartments, maxCells = 1100) {
|
|
const isUrbanUnit = (unit) => unit && unit.area > 0 && (
|
|
unit.classId <= 3 ||
|
|
(unit.urbanWeight || 0) >= 0.34 ||
|
|
((unit.urbanWeight || 0) >= 0.22 && (unit.lowlandFitness || 0) >= 0.34)
|
|
);
|
|
const seen = new Set();
|
|
let changedCells = 0;
|
|
let unifiedComponents = 0;
|
|
for (const start of compartments || []) {
|
|
if (!isUrbanUnit(start) || seen.has(start.id)) continue;
|
|
const queue = [start.id];
|
|
const comp = [];
|
|
seen.add(start.id);
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
const unit = compartments[cur];
|
|
if (!isUrbanUnit(unit)) continue;
|
|
comp.push(unit);
|
|
for (const next of unit.adjacent?.keys?.() || []) {
|
|
if (seen.has(next) || !isUrbanUnit(compartments[next])) continue;
|
|
seen.add(next);
|
|
queue.push(next);
|
|
}
|
|
}
|
|
const totalArea = comp.reduce((sum, unit) => sum + (unit.area || 0), 0);
|
|
if (comp.length <= 1 || totalArea <= 0 || totalArea > maxCells) continue;
|
|
const ownerScore = new Map();
|
|
for (const unit of comp) {
|
|
const id = owner[unit.id];
|
|
if (id < 0) continue;
|
|
const score = (unit.area || 0) * (1 + (unit.urbanWeight || 0) * 1.8);
|
|
ownerScore.set(id, (ownerScore.get(id) || 0) + score);
|
|
}
|
|
let best = -1, bestScore = -INF;
|
|
for (const [id, score] of ownerScore) if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; }
|
|
if (best < 0) continue;
|
|
let localChanged = 0;
|
|
for (const unit of comp) {
|
|
if (owner[unit.id] === best) continue;
|
|
owner[unit.id] = best;
|
|
localChanged += unit.area || 0;
|
|
}
|
|
if (localChanged > 0) {
|
|
changedCells += localChanged;
|
|
unifiedComponents++;
|
|
}
|
|
}
|
|
return { changedCells, unifiedComponents };
|
|
}
|
|
|
|
|
|
function lockCityMetroCompartmentsToSingleMunicipality(owner, compartments, fields = {}) {
|
|
const { modernCities = [] } = fields;
|
|
if (!owner || !compartments?.length || !modernCities?.length) return { changedCells: 0, unifiedCities: 0 };
|
|
let changedCells = 0;
|
|
let unifiedCities = 0;
|
|
const urbanUnit = (unit) => unit && unit.area > 0 && (
|
|
unit.classId <= 3 ||
|
|
(unit.urbanWeight || 0) >= 0.24 ||
|
|
((unit.urbanWeight || 0) >= 0.16 && (unit.lowlandFitness || 0) >= 0.40)
|
|
);
|
|
for (const city of modernCities) {
|
|
if (!city || !Number.isFinite(city.x) || !Number.isFinite(city.y) || (city.population || 0) < 18000) continue;
|
|
const radius = clamp(
|
|
(city.urbanRadius || 8) * ((city.population || 0) >= 200000 ? 1.95 : (city.population || 0) >= 80000 ? 1.65 : 1.35),
|
|
8,
|
|
(city.population || 0) >= 200000 ? 34 : 24
|
|
);
|
|
const units = [];
|
|
for (const unit of compartments) {
|
|
if (!urbanUnit(unit)) continue;
|
|
const d = Math.hypot((unit.x || 0) - city.x, (unit.y || 0) - city.y);
|
|
if (d > radius) continue;
|
|
const weight = (unit.area || 1) *
|
|
(1 + (unit.urbanWeight || 0) * 2.4 + (unit.lowlandFitness || 0) * 0.55) *
|
|
Math.max(0.20, 1 - d / Math.max(1, radius) * 0.58);
|
|
units.push({ unit, weight, d });
|
|
}
|
|
if (units.length <= 1) continue;
|
|
const totalArea = units.reduce((sum, row) => sum + (row.unit.area || 0), 0);
|
|
// Large multi-core conurbations may legitimately contain multiple municipalities.
|
|
// This pass targets compact urban areas that visually read as one city.
|
|
const maxArea = (city.population || 0) >= 200000 ? 1800 : 900;
|
|
if (totalArea > maxArea) continue;
|
|
const ownerScore = new Map();
|
|
for (const row of units) {
|
|
const id = owner[row.unit.id];
|
|
if (id < 0) continue;
|
|
ownerScore.set(id, (ownerScore.get(id) || 0) + row.weight);
|
|
}
|
|
if (ownerScore.size <= 1) continue;
|
|
let best = -1, bestScore = -INF, totalScore = 0;
|
|
for (const [id, score] of ownerScore) {
|
|
totalScore += score;
|
|
if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; }
|
|
}
|
|
if (best < 0 || bestScore / Math.max(1, totalScore) < 0.28) continue;
|
|
let localChanged = 0;
|
|
for (const row of units) {
|
|
if (owner[row.unit.id] === best) continue;
|
|
owner[row.unit.id] = best;
|
|
localChanged += row.unit.area || 0;
|
|
}
|
|
if (localChanged > 0) {
|
|
changedCells += localChanged;
|
|
unifiedCities++;
|
|
}
|
|
}
|
|
return { changedCells, unifiedCities };
|
|
}
|
|
|
|
function enforceSimpleAdministrativeHierarchy(adminId, compartments, prefectureMask, sea, fields = {}) {
|
|
const owner = dominantCompartmentOwners(compartments, adminId);
|
|
const urban = lockCompactUrbanCompartmentsToDominantOwner(owner, compartments, fields.maxUrbanClusterCells || 1100);
|
|
const metro = lockCityMetroCompartmentsToSingleMunicipality(owner, compartments, fields);
|
|
const connectivity1 = repairCompartmentOwnerConnectivity(owner, compartments, 8);
|
|
const enclave1 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 6);
|
|
const singleMerge = mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, fields.minCompartmentsPerMunicipality || 2, 8);
|
|
const connectivity2 = repairCompartmentOwnerConnectivity(owner, compartments, 8);
|
|
const enclave2 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 6);
|
|
const singleMerge2 = mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, fields.minCompartmentsPerMunicipality || 2, 4);
|
|
const connectivity3 = repairCompartmentOwnerConnectivity(owner, compartments, 4);
|
|
const enclave3 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 4);
|
|
applyCompartmentOwners(adminId, compartments, owner);
|
|
const counts = ownerAreaByCompartment(owner, compartments).count;
|
|
return {
|
|
changedAfterUrbanUnification: urban.changedCells + metro.changedCells,
|
|
urbanComponentsUnified: urban.unifiedComponents,
|
|
changedAfterCityMetroMunicipalityUnification: metro.changedCells,
|
|
cityMetroMunicipalitiesUnified: metro.unifiedCities,
|
|
changedAfterCompartmentConnectivity: connectivity1.changedCells + connectivity2.changedCells + connectivity3.changedCells,
|
|
disconnectedCompartmentComponentsMerged: connectivity1.changedComponents + connectivity2.changedComponents + connectivity3.changedComponents,
|
|
changedAfterCompartmentEnclaveRepair: enclave1.changedCells + enclave2.changedCells + enclave3.changedCells,
|
|
compartmentEnclaveComponentsMerged: enclave1.changedComponents + enclave2.changedComponents + enclave3.changedComponents,
|
|
changedAfterSingleCompartmentMunicipalityMerge: singleMerge.changedCells + singleMerge2.changedCells,
|
|
singleCompartmentMunicipalitiesMerged: singleMerge.mergedMunicipalities + singleMerge2.mergedMunicipalities,
|
|
remainingSingleCompartmentMunicipalities: [...counts.values()].filter((count) => count > 0 && count < (fields.minCompartmentsPerMunicipality || 2)).length,
|
|
};
|
|
}
|
|
|
|
function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity, settlementFeatures = []) {
|
|
const nodes = new Map();
|
|
const edges = new Map();
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
|
|
const id = adminId[i];
|
|
if (!nodes.has(id)) nodes.set(id, { id, area: 0, population: 0, cityPopulation: 0, settlementPopulation: 0, majorCityCount: 0, sx: 0, sy: 0, touchesOutside: false });
|
|
const node = nodes.get(id);
|
|
const [x, y] = xyOf(i);
|
|
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) node.touchesOutside = true;
|
|
for (const [ox, oy] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) {
|
|
if (!inside(ox, oy)) { node.touchesOutside = true; continue; }
|
|
const oi = indexOf(ox, oy);
|
|
if (!prefectureMask[oi] || sea[oi]) node.touchesOutside = true;
|
|
}
|
|
node.area++;
|
|
node.population += populationDensity?.[i] || 0;
|
|
node.sx += x;
|
|
node.sy += y;
|
|
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
|
|
if (!inside(nx, ny)) continue;
|
|
const ni = indexOf(nx, ny);
|
|
if (!prefectureMask[ni] || sea[ni] || adminId[ni] < 0 || adminId[ni] === id) continue;
|
|
const a = Math.min(id, adminId[ni]);
|
|
const b = Math.max(id, adminId[ni]);
|
|
const key = `${a}:${b}`;
|
|
const edge = edges.get(key) || { a, b, count: 0, barrier: 0 };
|
|
edge.count++;
|
|
edge.barrier += ((naturalBarrierScore?.[i] || 0) + (naturalBarrierScore?.[ni] || 0)) * 0.5;
|
|
edges.set(key, edge);
|
|
}
|
|
}
|
|
for (const feature of settlementFeatures || []) {
|
|
if (!feature || !inside(feature.x, feature.y)) continue;
|
|
const i = indexOf(feature.x, feature.y);
|
|
if (!prefectureMask[i] || sea[i]) continue;
|
|
const id = adminId[i];
|
|
const node = nodes.get(id);
|
|
if (!node) continue;
|
|
const pop = Math.max(0, feature.population || 0);
|
|
node.settlementPopulation += pop;
|
|
if (feature.kind === "Regional Capital" || feature.kind === "Prefectural Capital" || feature.kind === "Regional City" || feature.kind === "Local City" || feature.isRegionalCapital || feature.isPrefecturalCapital) {
|
|
node.cityPopulation += pop;
|
|
node.majorCityCount += pop >= 120000 ? 1 : 0;
|
|
}
|
|
node.population += pop / 16000;
|
|
}
|
|
for (const node of nodes.values()) {
|
|
node.x = node.sx / Math.max(1, node.area);
|
|
node.y = node.sy / Math.max(1, node.area);
|
|
node.adjacent = new Map();
|
|
}
|
|
for (const edge of edges.values()) {
|
|
edge.barrier /= Math.max(1, edge.count);
|
|
// Prefecture grouping should pay the same natural-compartment crossing
|
|
// cost that municipality generation uses: ridges, rivers, valley walls and
|
|
// other strong natural dividers should be expensive to cross. Short shared
|
|
// boundaries are also unstable, so they get a small extra penalty.
|
|
edge.crossingCost = 1.0 + edge.barrier * 8.5 + 2.6 / Math.sqrt(Math.max(1, edge.count));
|
|
nodes.get(edge.a)?.adjacent.set(edge.b, edge);
|
|
nodes.get(edge.b)?.adjacent.set(edge.a, edge);
|
|
}
|
|
return { nodes, edges };
|
|
}
|
|
|
|
function choosePrefectureMunicipalitySeeds(nodes, seed) {
|
|
const active = [...nodes.values()].filter((node) => node.area > 0).sort((a, b) => b.area - a.area || a.id - b.id);
|
|
const totalArea = active.reduce((sum, node) => sum + node.area, 0);
|
|
// Real Japan's smallest prefecture by municipality count is roughly Toyama's 15.
|
|
// Keep generated prefectures near that scale by limiting prefecture count unless
|
|
// enough municipalities exist to give each prefecture a meaningful set.
|
|
const minMunicipalitiesPerPrefecture = 14;
|
|
const maxByMunicipalityCount = Math.max(3, Math.floor(active.length / minMunicipalitiesPerPrefecture));
|
|
const areaBased = clamp(Math.round(totalArea / 7800), 3, 6);
|
|
const targetCount = clamp(Math.min(areaBased, maxByMunicipalityCount || areaBased), 3, 6);
|
|
const seeds = [];
|
|
const minSpacing = Math.max(18, Math.sqrt(totalArea / Math.max(1, targetCount)) * 0.46);
|
|
function tryAdd(node, relaxed = false) {
|
|
if (!node || seeds.includes(node) || seeds.length >= targetCount) return false;
|
|
const nearest = seeds.length ? Math.min(...seeds.map((s) => Math.hypot(node.x - s.x, node.y - s.y))) : INF;
|
|
if (!relaxed && nearest < minSpacing) return false;
|
|
seeds.push(node);
|
|
return true;
|
|
}
|
|
const capitalLike = active
|
|
.filter((node) => (node.cityPopulation || 0) >= 120000 || node.majorCityCount > 0)
|
|
.sort((a, b) => (b.cityPopulation || 0) - (a.cityPopulation || 0) || b.population - a.population || a.id - b.id);
|
|
for (const node of capitalLike) tryAdd(node, false);
|
|
for (const node of capitalLike) tryAdd(node, true);
|
|
if (!seeds.length) {
|
|
const first = active.slice().sort((a, b) => (b.population * 1.8 + b.area * 0.08) - (a.population * 1.8 + a.area * 0.08) || a.id - b.id)[0];
|
|
if (first) seeds.push(first);
|
|
}
|
|
while (seeds.length < targetCount) {
|
|
let best = null, bestScore = -INF;
|
|
for (const node of active) {
|
|
if (seeds.includes(node)) continue;
|
|
const nearest = Math.min(...seeds.map((s) => Math.hypot(node.x - s.x, node.y - s.y)));
|
|
const capitalBonus = Math.sqrt(Math.max(0, node.cityPopulation || 0)) * 0.05 + (node.majorCityCount || 0) * 8;
|
|
const score = nearest * (1.0 + hash2(seed, node.id) * 0.08) + Math.sqrt(node.area) * 0.12 + node.population * 0.28 + capitalBonus;
|
|
if (score > bestScore) { bestScore = score; best = node; }
|
|
}
|
|
if (!best) break;
|
|
seeds.push(best);
|
|
}
|
|
seeds.minMunicipalitiesPerPrefecture = minMunicipalitiesPerPrefecture;
|
|
return seeds;
|
|
}
|
|
|
|
function assignMunicipalitiesToPrefectures(nodes, seeds) {
|
|
const owner = new Map();
|
|
const area = new Map();
|
|
const heap = new MinHeap();
|
|
seeds.forEach((node, id) => {
|
|
owner.set(node.id, id);
|
|
area.set(id, node.area);
|
|
heap.push({ i: node.id, id, f: 0 });
|
|
});
|
|
const totalArea = [...nodes.values()].reduce((sum, node) => sum + node.area, 0);
|
|
const maxArea = Math.max(900, totalArea * 0.30);
|
|
while (heap.length) {
|
|
const cur = heap.pop();
|
|
if (!cur || owner.get(cur.i) !== cur.id) continue;
|
|
const node = nodes.get(cur.i);
|
|
if (!node) continue;
|
|
for (const [nextId, edge] of node.adjacent) {
|
|
if (owner.has(nextId)) continue;
|
|
const next = nodes.get(nextId);
|
|
if (!next) continue;
|
|
const areaPressure = Math.max(0, ((area.get(cur.id) || 0) + next.area - maxArea) / Math.max(1, maxArea));
|
|
const cost = cur.f + (edge.crossingCost ?? (1.0 + edge.barrier * 8.5)) + areaPressure * 14 + hash2(cur.id, nextId) * 0.05;
|
|
owner.set(nextId, cur.id);
|
|
area.set(cur.id, (area.get(cur.id) || 0) + next.area);
|
|
heap.push({ i: nextId, id: cur.id, f: cost });
|
|
}
|
|
}
|
|
let fallback = 0;
|
|
for (const id of [...nodes.keys()].sort((a, b) => a - b)) {
|
|
if (!owner.has(id)) owner.set(id, fallback++ % Math.max(1, seeds.length));
|
|
}
|
|
return owner;
|
|
}
|
|
|
|
function repairPrefectureMunicipalityConnectivity(nodes, owner) {
|
|
let changed = 0;
|
|
for (let pass = 0; pass < 8; pass++) {
|
|
let passChanged = 0;
|
|
const prefIds = [...new Set(owner.values())].sort((a, b) => a - b);
|
|
for (const prefId of prefIds) {
|
|
const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId);
|
|
const memberSet = new Set(members);
|
|
const seen = new Set();
|
|
const components = [];
|
|
for (const start of members) {
|
|
if (seen.has(start)) continue;
|
|
const queue = [start];
|
|
const comp = [];
|
|
seen.add(start);
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
comp.push(cur);
|
|
for (const next of nodes.get(cur)?.adjacent.keys() || []) {
|
|
if (!memberSet.has(next) || seen.has(next)) continue;
|
|
seen.add(next);
|
|
queue.push(next);
|
|
}
|
|
}
|
|
components.push(comp);
|
|
}
|
|
if (components.length <= 1) continue;
|
|
components.sort((a, b) => b.length - a.length);
|
|
for (const comp of components.slice(1)) {
|
|
const neighborCounts = new Map();
|
|
for (const id of comp) {
|
|
for (const next of nodes.get(id)?.adjacent.keys() || []) {
|
|
const nOwner = owner.get(next);
|
|
if (nOwner !== prefId) neighborCounts.set(nOwner, (neighborCounts.get(nOwner) || 0) + 1);
|
|
}
|
|
}
|
|
let best = -1, bestCount = -1;
|
|
for (const [id, count] of neighborCounts) if (count > bestCount || (count === bestCount && id < best)) { best = id; bestCount = count; }
|
|
if (best < 0) continue;
|
|
for (const id of comp) owner.set(id, best);
|
|
passChanged += comp.length;
|
|
}
|
|
}
|
|
changed += passChanged;
|
|
if (!passChanged) break;
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function repairPrefectureMunicipalityEnclaves(nodes, owner, maxPasses = 6) {
|
|
let changed = 0;
|
|
for (let pass = 0; pass < maxPasses; pass++) {
|
|
let passChanged = 0;
|
|
const prefIds = [...new Set(owner.values())].filter((id) => id >= 0).sort((a, b) => a - b);
|
|
for (const prefId of prefIds) {
|
|
const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId);
|
|
const memberSet = new Set(members);
|
|
const seen = new Set();
|
|
for (const start of members) {
|
|
if (seen.has(start)) continue;
|
|
const queue = [start];
|
|
const comp = [];
|
|
seen.add(start);
|
|
let touchesOutside = false;
|
|
const boundaryPrefs = new Map();
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
comp.push(cur);
|
|
const node = nodes.get(cur);
|
|
if (node?.touchesOutside) touchesOutside = true;
|
|
for (const next of node?.adjacent.keys() || []) {
|
|
const nextOwner = owner.get(next);
|
|
if (nextOwner === prefId) {
|
|
if (!seen.has(next)) { seen.add(next); queue.push(next); }
|
|
} else if (nextOwner >= 0) {
|
|
boundaryPrefs.set(nextOwner, (boundaryPrefs.get(nextOwner) || 0) + 1);
|
|
}
|
|
}
|
|
}
|
|
if (touchesOutside || boundaryPrefs.size !== 1) continue;
|
|
const [targetPref] = boundaryPrefs.keys();
|
|
if (targetPref < 0 || targetPref === prefId) continue;
|
|
for (const id of comp) owner.set(id, targetPref);
|
|
passChanged += comp.length;
|
|
}
|
|
}
|
|
changed += passChanged;
|
|
if (!passChanged) break;
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function lockCompactUrbanMunicipalitiesToSinglePrefecture(owner, adminId, context, maxCells = 2600) {
|
|
const { prefectureMask, sea, landuse, populationDensity } = context;
|
|
if (!owner || !adminId || !landuse) return 0;
|
|
const seen = new Uint8Array(SIZE);
|
|
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
|
|
const isUrban = (i) => {
|
|
const lu = landuse[i];
|
|
return lu === 2 || lu === 3 || lu === 4 || lu === 7 || lu === 8 || (populationDensity?.[i] || 0) > 0.18;
|
|
};
|
|
let changedMunicipalities = 0;
|
|
for (let start = 0; start < SIZE; start++) {
|
|
if (seen[start] || !prefectureMask[start] || sea[start] || adminId[start] < 0 || !isUrban(start)) continue;
|
|
const queue = [start];
|
|
const comp = [];
|
|
seen[start] = 1;
|
|
const municipalityWeights = new Map();
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
comp.push(cur);
|
|
const id = adminId[cur];
|
|
const weight = 1 + Math.max(0, (populationDensity?.[cur] || 0) - 0.12) * 2.4 + (landuse[cur] === 3 ? 2.0 : landuse[cur] === 2 ? 1.2 : 0);
|
|
municipalityWeights.set(id, (municipalityWeights.get(id) || 0) + weight);
|
|
const [x, y] = xyOf(cur);
|
|
for (const [dx, dy] of dirs) {
|
|
const nx = x + dx, ny = y + dy;
|
|
if (!inside(nx, ny)) continue;
|
|
const ni = indexOf(nx, ny);
|
|
if (seen[ni] || !prefectureMask[ni] || sea[ni] || adminId[ni] < 0 || !isUrban(ni)) continue;
|
|
seen[ni] = 1;
|
|
queue.push(ni);
|
|
}
|
|
}
|
|
if (comp.length < 14 || comp.length > maxCells || municipalityWeights.size <= 1) continue;
|
|
const prefectureWeights = new Map();
|
|
for (const [munId, weight] of municipalityWeights) {
|
|
const prefId = owner.get(munId);
|
|
if (prefId < 0) continue;
|
|
prefectureWeights.set(prefId, (prefectureWeights.get(prefId) || 0) + weight);
|
|
}
|
|
if (prefectureWeights.size <= 1) continue;
|
|
let bestPref = -1, bestWeight = -INF, totalWeight = 0;
|
|
for (const [prefId, weight] of prefectureWeights) {
|
|
totalWeight += weight;
|
|
if (weight > bestWeight || (weight === bestWeight && prefId < bestPref)) { bestPref = prefId; bestWeight = weight; }
|
|
}
|
|
if (bestPref < 0 || bestWeight / Math.max(1, totalWeight) < 0.34) continue;
|
|
for (const munId of municipalityWeights.keys()) {
|
|
if (owner.get(munId) === bestPref) continue;
|
|
owner.set(munId, bestPref);
|
|
changedMunicipalities++;
|
|
}
|
|
}
|
|
return changedMunicipalities;
|
|
}
|
|
|
|
function lockCityMetroMunicipalitiesToSinglePrefecture(owner, adminId, context) {
|
|
const { prefectureMask, sea, landuse, populationDensity, modernCities = [] } = context;
|
|
if (!owner || !adminId || !modernCities?.length) return 0;
|
|
let changed = 0;
|
|
const isUrban = (i) => {
|
|
const lu = landuse?.[i];
|
|
return lu === 2 || lu === 3 || lu === 4 || lu === 7 || lu === 8 || (populationDensity?.[i] || 0) > 0.14;
|
|
};
|
|
for (const city of modernCities) {
|
|
if (!city || (city.population || 0) < 24000 || !inside(city.x, city.y)) continue;
|
|
const centerAdmin = adminId[indexOf(city.x, city.y)];
|
|
if (centerAdmin < 0) continue;
|
|
const centerPref = owner.get(centerAdmin);
|
|
if (centerPref < 0) continue;
|
|
const radius = clamp(Math.round((city.urbanRadius || 8) * ((city.population || 0) >= 160000 ? 1.55 : 1.25)), 7, 26);
|
|
const municipalityWeights = new Map();
|
|
for (let dy = -radius; dy <= radius; dy++) {
|
|
for (let dx = -radius; dx <= radius; dx++) {
|
|
const x = city.x + dx;
|
|
const y = city.y + dy;
|
|
if (!inside(x, y) || Math.hypot(dx, dy) > radius) continue;
|
|
const i = indexOf(x, y);
|
|
if (!prefectureMask[i] || sea[i] || adminId[i] < 0 || !isUrban(i)) continue;
|
|
const dist = Math.hypot(dx, dy) / Math.max(1, radius);
|
|
const weight = (1 - dist * 0.55) * (1 + (populationDensity?.[i] || 0) * 2.6 + (landuse?.[i] === 3 ? 1.6 : 0));
|
|
municipalityWeights.set(adminId[i], (municipalityWeights.get(adminId[i]) || 0) + weight);
|
|
}
|
|
}
|
|
if (municipalityWeights.size <= 1) continue;
|
|
let total = 0, centerOwnedWeight = 0;
|
|
for (const [munId, weight] of municipalityWeights) {
|
|
total += weight;
|
|
if (owner.get(munId) === centerPref) centerOwnedWeight += weight;
|
|
}
|
|
// Only force compact city regions. If the center prefecture has almost no
|
|
// share, this is probably a genuine cross-prefecture conurbation or a city
|
|
// center on the edge; leave it to the graph repair.
|
|
if (centerOwnedWeight / Math.max(1, total) < 0.24) continue;
|
|
for (const munId of municipalityWeights.keys()) {
|
|
if (owner.get(munId) === centerPref) continue;
|
|
owner.set(munId, centerPref);
|
|
changed++;
|
|
}
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function repairPrefectureCellEnclavesFromGrid(adminId, owner, prefectureMask, sea, maxPasses = 5) {
|
|
let changed = 0;
|
|
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
|
|
for (let pass = 0; pass < maxPasses; pass++) {
|
|
const prefId = new Int16Array(SIZE);
|
|
prefId.fill(-1);
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
|
|
prefId[i] = owner.get(adminId[i]) ?? -1;
|
|
}
|
|
const seen = new Uint8Array(SIZE);
|
|
let passChanged = 0;
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (seen[i] || prefId[i] < 0) continue;
|
|
const id = prefId[i];
|
|
const queue = [i];
|
|
const comp = [];
|
|
seen[i] = 1;
|
|
let touchesOutside = false;
|
|
const boundaryCounts = new Map();
|
|
const adminCounts = new Map();
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
comp.push(cur);
|
|
const aid = adminId[cur];
|
|
if (aid >= 0) adminCounts.set(aid, (adminCounts.get(aid) || 0) + 1);
|
|
const [x, y] = xyOf(cur);
|
|
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesOutside = true;
|
|
for (const [dx, dy] of dirs) {
|
|
const nx = x + dx, ny = y + dy;
|
|
if (!inside(nx, ny)) { touchesOutside = true; continue; }
|
|
const ni = indexOf(nx, ny);
|
|
if (!prefectureMask[ni] || sea[ni]) { touchesOutside = true; continue; }
|
|
const nid = prefId[ni];
|
|
if (nid === id) {
|
|
if (!seen[ni]) { seen[ni] = 1; queue.push(ni); }
|
|
} else if (nid >= 0) {
|
|
boundaryCounts.set(nid, (boundaryCounts.get(nid) || 0) + 1);
|
|
}
|
|
}
|
|
}
|
|
if (touchesOutside || boundaryCounts.size !== 1) continue;
|
|
const [targetPref] = boundaryCounts.keys();
|
|
if (targetPref < 0 || targetPref === id) continue;
|
|
for (const aid of adminCounts.keys()) {
|
|
if (owner.get(aid) !== targetPref) { owner.set(aid, targetPref); passChanged++; }
|
|
}
|
|
}
|
|
changed += passChanged;
|
|
if (!passChanged) break;
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, maxPasses = 5) {
|
|
let changed = 0;
|
|
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
|
|
for (let pass = 0; pass < maxPasses; pass++) {
|
|
const seen = new Uint8Array(SIZE);
|
|
let passChanged = 0;
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (seen[i] || !prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
|
|
const id = adminId[i];
|
|
const queue = [i];
|
|
const comp = [];
|
|
seen[i] = 1;
|
|
let touchesOutside = false;
|
|
const boundaryCounts = new Map();
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
comp.push(cur);
|
|
const [x, y] = xyOf(cur);
|
|
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesOutside = true;
|
|
for (const [dx, dy] of dirs) {
|
|
const nx = x + dx, ny = y + dy;
|
|
if (!inside(nx, ny)) { touchesOutside = true; continue; }
|
|
const ni = indexOf(nx, ny);
|
|
if (!prefectureMask[ni] || sea[ni]) { touchesOutside = true; continue; }
|
|
const nid = adminId[ni];
|
|
if (nid === id) {
|
|
if (!seen[ni]) { seen[ni] = 1; queue.push(ni); }
|
|
} else if (nid >= 0) {
|
|
boundaryCounts.set(nid, (boundaryCounts.get(nid) || 0) + 1);
|
|
}
|
|
}
|
|
}
|
|
if (touchesOutside || boundaryCounts.size !== 1) continue;
|
|
const [targetId] = boundaryCounts.keys();
|
|
if (targetId < 0 || targetId === id) continue;
|
|
for (const ci of comp) adminId[ci] = targetId;
|
|
passChanged += comp.length;
|
|
}
|
|
changed += passChanged;
|
|
if (!passChanged) break;
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function lockCompactUrbanAreasToDominantAdmin(adminId, prefectureMask, sea, landuse, populationDensity, maxCells = 950) {
|
|
if (!landuse || !populationDensity) return 0;
|
|
const seen = new Uint8Array(SIZE);
|
|
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
|
|
let changed = 0;
|
|
const isUrban = (i) => !sea[i] && prefectureMask[i] && (populationDensity[i] > 0.34 || [2, 3, 4, 7, 8].includes(landuse[i]));
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (seen[i] || !isUrban(i) || adminId[i] < 0) continue;
|
|
const queue = [i];
|
|
const comp = [];
|
|
seen[i] = 1;
|
|
const counts = new Map();
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
comp.push(cur);
|
|
const id = adminId[cur];
|
|
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1 + (populationDensity[cur] || 0) * 2.0);
|
|
const [x, y] = xyOf(cur);
|
|
for (const [dx, dy] of dirs) {
|
|
const nx = x + dx, ny = y + dy;
|
|
if (!inside(nx, ny)) continue;
|
|
const ni = indexOf(nx, ny);
|
|
if (seen[ni] || !isUrban(ni)) continue;
|
|
seen[ni] = 1;
|
|
queue.push(ni);
|
|
}
|
|
}
|
|
if (comp.length < 8 || comp.length > maxCells || counts.size <= 1 || counts.size > 7) continue;
|
|
let best = -1, bestScore = -INF;
|
|
let total = 0;
|
|
for (const [id, score] of counts) {
|
|
total += score;
|
|
if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; }
|
|
}
|
|
if (best < 0 || bestScore / Math.max(1, total) < 0.38) continue;
|
|
for (const ci of comp) {
|
|
if (adminId[ci] !== best) { adminId[ci] = best; changed++; }
|
|
}
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function mergeTinyMunicipalityPrefectures(nodes, owner) {
|
|
let changed = 0;
|
|
for (let pass = 0; pass < 6; pass++) {
|
|
const areaByPref = new Map();
|
|
for (const node of nodes.values()) {
|
|
const pref = owner.get(node.id);
|
|
areaByPref.set(pref, (areaByPref.get(pref) || 0) + node.area);
|
|
}
|
|
const totalArea = [...areaByPref.values()].reduce((sum, value) => sum + value, 0);
|
|
const minArea = Math.max(520, totalArea / Math.max(1, areaByPref.size) * 0.34);
|
|
const tiny = [...areaByPref.entries()]
|
|
.filter(([, area]) => area > 0 && area < minArea && areaByPref.size > 4)
|
|
.sort((a, b) => a[1] - b[1] || a[0] - b[0])[0];
|
|
if (!tiny) break;
|
|
const [tinyPref] = tiny;
|
|
const neighborScores = new Map();
|
|
for (const node of nodes.values()) {
|
|
if (owner.get(node.id) !== tinyPref) continue;
|
|
for (const [nextId, edge] of node.adjacent) {
|
|
const nextPref = owner.get(nextId);
|
|
if (nextPref === tinyPref || nextPref < 0) continue;
|
|
const score = (neighborScores.get(nextPref) || 0) + edge.count * 0.8 - (edge.crossingCost ?? (1.0 + edge.barrier * 8.5)) * 0.65;
|
|
neighborScores.set(nextPref, score);
|
|
}
|
|
}
|
|
let best = -1, bestScore = -INF;
|
|
for (const [pref, score] of neighborScores) {
|
|
if (score > bestScore || (score === bestScore && pref < best)) { best = pref; bestScore = score; }
|
|
}
|
|
if (best < 0) break;
|
|
for (const node of nodes.values()) if (owner.get(node.id) === tinyPref) { owner.set(node.id, best); changed++; }
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
|
|
function splitOversizedCompartmentMunicipalities(adminId, centers, compartments, prefectureMask, sea, fields = {}, seed = 0) {
|
|
if (!compartments?.length) return { changedCells: 0, splitMunicipalities: 0, addedCenters: 0 };
|
|
const compOwner = new Int16Array(compartments.length);
|
|
compOwner.fill(-1);
|
|
for (const comp of compartments) {
|
|
if (!comp || !comp.cells?.length) continue;
|
|
const counts = new Map();
|
|
for (const i of comp.cells) {
|
|
if (!prefectureMask[i] || sea[i]) continue;
|
|
const id = adminId[i];
|
|
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
|
|
}
|
|
let best = -1, bestCount = -1;
|
|
for (const [id, count] of counts) if (count > bestCount || (count === bestCount && id < best)) { best = id; bestCount = count; }
|
|
compOwner[comp.id] = best;
|
|
}
|
|
const byOwner = new Map();
|
|
for (const comp of compartments) {
|
|
if (!comp || !comp.cells?.length) continue;
|
|
const owner = compOwner[comp.id];
|
|
if (owner < 0) continue;
|
|
if (!byOwner.has(owner)) byOwner.set(owner, []);
|
|
byOwner.get(owner).push(comp);
|
|
}
|
|
const areas = [...byOwner.values()].map((list) => list.reduce((sum, comp) => sum + comp.area, 0)).sort((a, b) => a - b);
|
|
if (!areas.length) return { changedCells: 0, splitMunicipalities: 0, addedCenters: 0 };
|
|
const median = areas[Math.floor(areas.length / 2)] || 1;
|
|
const total = areas.reduce((sum, value) => sum + value, 0);
|
|
const maxArea = Math.max(360, Math.min(total * 0.11, Math.max(median * 2.65, total / Math.max(12, Math.round(total / 520)))));
|
|
let changedCells = 0;
|
|
let splitMunicipalities = 0;
|
|
let addedCenters = 0;
|
|
const elevation = fields.elevation;
|
|
const slope = fields.slope;
|
|
const ridgeField = fields.ridgeField;
|
|
const plain = fields.plain;
|
|
const agriculture = fields.agriculture;
|
|
const basinField = fields.basinField;
|
|
const coastalLowland = fields.coastalLowland;
|
|
const populationDensity = fields.populationDensity;
|
|
for (const [owner, list] of [...byOwner.entries()].sort((a, b) => a[0] - b[0])) {
|
|
const area = list.reduce((sum, comp) => sum + comp.area, 0);
|
|
if (area <= maxArea || list.length < 4) continue;
|
|
const desiredParts = clamp(Math.ceil(area / Math.max(1, maxArea)), 2, 9);
|
|
const splitCount = desiredParts - 1;
|
|
if (splitCount <= 0) continue;
|
|
const candidates = list.map((comp) => {
|
|
let sx = 0, sy = 0, n = 0, score = 0, bestI = -1, bestScore = -INF;
|
|
for (const i of comp.cells) {
|
|
if (!prefectureMask[i] || sea[i]) continue;
|
|
const [x, y] = xyOf(i);
|
|
sx += x; sy += y; n++;
|
|
const cellScore =
|
|
(plain?.[i] || 0) * 0.22 +
|
|
(agriculture?.[i] || 0) * 0.24 +
|
|
(basinField?.[i] || 0) * 0.14 +
|
|
(coastalLowland?.[i] || 0) * 0.10 +
|
|
(populationDensity?.[i] || 0) * 0.24 -
|
|
(slope?.[i] || 0) * 0.20 -
|
|
(ridgeField?.[i] || 0) * 0.18 -
|
|
Math.max(0, (elevation?.[i] || 0) - 0.62) * 0.38;
|
|
score += cellScore;
|
|
if (cellScore > bestScore) { bestScore = cellScore; bestI = i; }
|
|
}
|
|
const [x, y] = bestI >= 0 ? xyOf(bestI) : [Math.round(sx / Math.max(1, n)), Math.round(sy / Math.max(1, n))];
|
|
return { comp, x, y, score: score / Math.max(1, n) + Math.sqrt(comp.area) * 0.025 + hash2(seed + owner, comp.id) * 0.03 };
|
|
}).sort((a, b) => b.score - a.score || a.comp.id - b.comp.id);
|
|
const newSeeds = [];
|
|
for (const cand of candidates) {
|
|
if (newSeeds.length >= splitCount) break;
|
|
if (newSeeds.every((s) => Math.hypot(s.x - cand.x, s.y - cand.y) >= 9)) newSeeds.push(cand);
|
|
}
|
|
if (!newSeeds.length) continue;
|
|
const seedIds = newSeeds.map((cand) => {
|
|
const id = centers.length;
|
|
centers.push({ x: cand.x, y: cand.y, score: cand.score, invisibleLowlandAdminSeed: true, seedKind: "oversizedMunicipalitySplit", splitFromAdminId: owner });
|
|
addedCenters++;
|
|
return id;
|
|
});
|
|
const oldCenter = centers[owner] || candidates[0] || { x: list[0].x || 0, y: list[0].y || 0, score: 0 };
|
|
const owners = [{ id: owner, x: oldCenter.x, y: oldCenter.y, score: oldCenter.score || 0 }, ...newSeeds.map((cand, k) => ({ id: seedIds[k], x: cand.x, y: cand.y, score: cand.score }))];
|
|
const targetArea = area / Math.max(1, owners.length);
|
|
const claimedArea = new Map(owners.map((entry) => [entry.id, 0]));
|
|
for (const cand of candidates) {
|
|
let bestSeed = owner;
|
|
let bestCost = INF;
|
|
for (const entry of owners) {
|
|
const d = Math.hypot(cand.x - entry.x, cand.y - entry.y);
|
|
const pressure = Math.max(0, ((claimedArea.get(entry.id) || 0) + cand.comp.area - targetArea * 1.25) / Math.max(1, targetArea));
|
|
const cost = d + pressure * 18 - cand.score * 2.5 + hash2(entry.id, cand.comp.id) * 0.05;
|
|
if (cost < bestCost) { bestCost = cost; bestSeed = entry.id; }
|
|
}
|
|
claimedArea.set(bestSeed, (claimedArea.get(bestSeed) || 0) + cand.comp.area);
|
|
if (bestSeed === owner) continue;
|
|
for (const i of cand.comp.cells) {
|
|
if (!prefectureMask[i] || sea[i]) continue;
|
|
if (adminId[i] !== bestSeed) { adminId[i] = bestSeed; changedCells++; }
|
|
}
|
|
}
|
|
splitMunicipalities++;
|
|
}
|
|
return { changedCells, splitMunicipalities, addedCenters, maxArea };
|
|
}
|
|
|
|
function extractPrefectureBordersFromMunicipalities(adminId, municipalityToPrefectureId, prefectureMask, sea) {
|
|
const segments = [];
|
|
for (let y = 0; y < MAP_H; y++) {
|
|
for (let x = 0; x < MAP_W; x++) {
|
|
const i = indexOf(x, y);
|
|
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
|
|
const aPref = municipalityToPrefectureId[adminId[i]] ?? -1;
|
|
if (x + 1 < MAP_W) {
|
|
const ni = indexOf(x + 1, y);
|
|
const bPref = !sea[ni] && adminId[ni] >= 0 ? municipalityToPrefectureId[adminId[ni]] ?? -1 : -1;
|
|
if (prefectureMask[ni] && bPref >= 0 && aPref >= 0 && bPref !== aPref && adminId[ni] !== adminId[i]) segments.push([[x + 1, y], [x + 1, y + 1]]);
|
|
}
|
|
if (y + 1 < MAP_H) {
|
|
const ni = indexOf(x, y + 1);
|
|
const bPref = !sea[ni] && adminId[ni] >= 0 ? municipalityToPrefectureId[adminId[ni]] ?? -1 : -1;
|
|
if (prefectureMask[ni] && bPref >= 0 && aPref >= 0 && bPref !== aPref && adminId[ni] !== adminId[i]) segments.push([[x, y + 1], [x + 1, y + 1]]);
|
|
}
|
|
}
|
|
}
|
|
return segments;
|
|
}
|
|
|
|
|
|
function prefectureMunicipalityCounts(owner) {
|
|
const counts = new Map();
|
|
for (const pref of owner.values()) counts.set(pref, (counts.get(pref) || 0) + 1);
|
|
return counts;
|
|
}
|
|
|
|
function ownerMembersByPref(owner) {
|
|
const by = new Map();
|
|
for (const [id, pref] of owner) {
|
|
if (!by.has(pref)) by.set(pref, []);
|
|
by.get(pref).push(id);
|
|
}
|
|
return by;
|
|
}
|
|
|
|
function wouldRemainConnectedAfterRemoval(nodes, owner, adminId, prefId) {
|
|
const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId && id !== adminId);
|
|
if (members.length <= 1) return true;
|
|
const memberSet = new Set(members);
|
|
const seen = new Set([members[0]]);
|
|
const queue = [members[0]];
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
for (const next of nodes.get(cur)?.adjacent.keys() || []) {
|
|
if (!memberSet.has(next) || seen.has(next)) continue;
|
|
seen.add(next);
|
|
queue.push(next);
|
|
}
|
|
}
|
|
return seen.size === members.length;
|
|
}
|
|
|
|
function rebalanceSmallPrefecturesByMunicipalityCount(nodes, owner, minCount = 14, maxPasses = 96) {
|
|
let changed = 0;
|
|
for (let pass = 0; pass < maxPasses; pass++) {
|
|
const counts = prefectureMunicipalityCounts(owner);
|
|
const small = [...counts.entries()].filter(([, count]) => count > 0 && count < minCount).sort((a, b) => a[1] - b[1] || a[0] - b[0])[0];
|
|
if (!small) break;
|
|
const [smallPref, smallCount] = small;
|
|
let best = null, bestScore = -INF;
|
|
for (const [id, pref] of owner) {
|
|
if (pref === smallPref) continue;
|
|
const donorCount = counts.get(pref) || 0;
|
|
if (donorCount <= minCount + 1) continue;
|
|
const node = nodes.get(id);
|
|
if (!node) continue;
|
|
let edgeToSmall = null;
|
|
for (const [nextId, edge] of node.adjacent || []) {
|
|
if (owner.get(nextId) === smallPref) {
|
|
edgeToSmall = edge;
|
|
break;
|
|
}
|
|
}
|
|
if (!edgeToSmall) continue;
|
|
if (!wouldRemainConnectedAfterRemoval(nodes, owner, id, pref)) continue;
|
|
const capitalPenalty = (node.cityPopulation || 0) >= 150000 ? 18 : 0;
|
|
const donorSurplus = donorCount - minCount;
|
|
const score = (edgeToSmall.count || 1) * 2.5 - (edgeToSmall.crossingCost ?? (1.0 + (edgeToSmall.barrier || 0) * 8.5)) * 1.15 + donorSurplus * 1.6 - Math.sqrt(node.area || 1) * 0.03 - capitalPenalty;
|
|
if (score > bestScore || (score === bestScore && id < best?.id)) best = { id, pref, score };
|
|
}
|
|
if (!best) break;
|
|
owner.set(best.id, smallPref);
|
|
changed++;
|
|
repairPrefectureMunicipalityConnectivity(nodes, owner);
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function mergePersistentlyTinyPrefecturesByCount(nodes, owner, minCount = 12, minRemainingPrefectures = 2) {
|
|
let changed = 0;
|
|
for (let pass = 0; pass < 16; pass++) {
|
|
const counts = prefectureMunicipalityCounts(owner);
|
|
if (counts.size <= minRemainingPrefectures) break;
|
|
const tiny = [...counts.entries()].filter(([, count]) => count > 0 && count < minCount).sort((a, b) => a[1] - b[1] || a[0] - b[0])[0];
|
|
if (!tiny) break;
|
|
const [tinyPref] = tiny;
|
|
const neighborScores = new Map();
|
|
for (const [id, pref] of owner) {
|
|
if (pref !== tinyPref) continue;
|
|
const node = nodes.get(id);
|
|
for (const [nextId, edge] of node?.adjacent || []) {
|
|
const other = owner.get(nextId);
|
|
if (other === undefined || other === tinyPref) continue;
|
|
const score = (edge.count || 1) * 2.4 - (edge.crossingCost ?? (1.0 + (edge.barrier || 0) * 8.5)) * 1.0 + Math.min(18, counts.get(other) || 0) * 0.20;
|
|
neighborScores.set(other, (neighborScores.get(other) || 0) + score);
|
|
}
|
|
}
|
|
let best = -1, bestScore = -INF;
|
|
for (const [candidate, score] of neighborScores) {
|
|
if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; }
|
|
}
|
|
if (best < 0) {
|
|
const tinyNodes = [...owner.keys()].filter((id) => owner.get(id) === tinyPref).map((id) => nodes.get(id)).filter(Boolean);
|
|
const tx = tinyNodes.reduce((sum, node) => sum + node.x * node.area, 0) / Math.max(1, tinyNodes.reduce((sum, node) => sum + node.area, 0));
|
|
const ty = tinyNodes.reduce((sum, node) => sum + node.y * node.area, 0) / Math.max(1, tinyNodes.reduce((sum, node) => sum + node.area, 0));
|
|
let bestDist = INF;
|
|
for (const [candidate, count] of counts) {
|
|
if (candidate === tinyPref || count <= 0) continue;
|
|
const candidateNodes = [...owner.keys()].filter((id) => owner.get(id) === candidate).map((id) => nodes.get(id)).filter(Boolean);
|
|
for (const node of candidateNodes) {
|
|
const d = Math.hypot(node.x - tx, node.y - ty);
|
|
if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; }
|
|
}
|
|
}
|
|
}
|
|
if (best < 0) break;
|
|
for (const [id, pref] of owner) if (pref === tinyPref) { owner.set(id, best); changed++; }
|
|
repairPrefectureMunicipalityConnectivity(nodes, owner);
|
|
}
|
|
// Renumber compactly so labels/debug do not expose deleted prefecture IDs.
|
|
const active = [...new Set(owner.values())].sort((a, b) => a - b);
|
|
const remap = new Map(active.map((id, n) => [id, n]));
|
|
for (const [id, pref] of owner) owner.set(id, remap.get(pref));
|
|
return changed;
|
|
}
|
|
|
|
|
|
function splitOversizedPrefecturesByMunicipalityCount(nodes, owner, maxCount = 88, maxPrefectures = 8) {
|
|
let changed = 0;
|
|
for (let pass = 0; pass < 10; pass++) {
|
|
const counts = prefectureMunicipalityCounts(owner);
|
|
const oversized = [...counts.entries()].filter(([, count]) => count > maxCount).sort((a, b) => b[1] - a[1] || a[0] - b[0])[0];
|
|
if (!oversized || counts.size >= maxPrefectures) break;
|
|
const [prefId, count] = oversized;
|
|
const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId);
|
|
if (members.length <= maxCount) break;
|
|
let sx = 0, sy = 0, area = 0;
|
|
for (const id of members) {
|
|
const node = nodes.get(id);
|
|
if (!node) continue;
|
|
sx += node.x * Math.max(1, node.area || 1);
|
|
sy += node.y * Math.max(1, node.area || 1);
|
|
area += Math.max(1, node.area || 1);
|
|
}
|
|
const cx = sx / Math.max(1, area);
|
|
const cy = sy / Math.max(1, area);
|
|
const seedNode = members
|
|
.map((id) => nodes.get(id))
|
|
.filter(Boolean)
|
|
.sort((a, b) => Math.hypot(b.x - cx, b.y - cy) - Math.hypot(a.x - cx, a.y - cy) || a.id - b.id)[0];
|
|
if (!seedNode) break;
|
|
const newPref = Math.max(-1, ...counts.keys()) + 1;
|
|
const target = Math.max(count - maxCount, Math.floor(count * 0.42));
|
|
const queue = [seedNode.id];
|
|
const picked = new Set([seedNode.id]);
|
|
for (let q = 0; q < queue.length && picked.size < target; q++) {
|
|
const cur = queue[q];
|
|
const nexts = [...(nodes.get(cur)?.adjacent.keys() || [])]
|
|
.filter((id) => owner.get(id) === prefId && !picked.has(id))
|
|
.map((id) => nodes.get(id))
|
|
.filter(Boolean)
|
|
.sort((a, b) => Math.hypot(b.x - cx, b.y - cy) - Math.hypot(a.x - cx, a.y - cy) || a.id - b.id);
|
|
for (const next of nexts) {
|
|
picked.add(next.id);
|
|
queue.push(next.id);
|
|
if (picked.size >= target) break;
|
|
}
|
|
}
|
|
if (picked.size < Math.max(8, target * 0.55)) break;
|
|
for (const id of picked) owner.set(id, newPref);
|
|
changed += picked.size;
|
|
repairPrefectureMunicipalityConnectivity(nodes, owner);
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function generatePrefecturesFromMunicipalities(context, adminResult) {
|
|
const { adminId } = adminResult;
|
|
const { prefectureMask, sea, naturalBarrierScore, populationDensity, landuse, modernCities, markets, seed } = context;
|
|
const graph = buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity, [...(modernCities || []), ...(markets || [])]);
|
|
const seeds = choosePrefectureMunicipalitySeeds(graph.nodes, seed + 91001);
|
|
const owner = assignMunicipalitiesToPrefectures(graph.nodes, seeds);
|
|
const changedForTinyMerge = mergeTinyMunicipalityPrefectures(graph.nodes, owner);
|
|
let changedForMetroUnification = lockCompactUrbanMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity }, 2600);
|
|
changedForMetroUnification += lockCityMetroMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity, modernCities });
|
|
let changedForConnectivity = repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
|
let changedForEnclaveRepair = repairPrefectureMunicipalityEnclaves(graph.nodes, owner);
|
|
changedForMetroUnification += lockCompactUrbanMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity }, 2600);
|
|
changedForMetroUnification += lockCityMetroMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity, modernCities });
|
|
const changedForMunicipalityCountRebalance = rebalanceSmallPrefecturesByMunicipalityCount(graph.nodes, owner, seeds.minMunicipalitiesPerPrefecture || 14);
|
|
const changedForTinyPrefectureCountMerge = mergePersistentlyTinyPrefecturesByCount(graph.nodes, owner, Math.max(13, (seeds.minMunicipalitiesPerPrefecture || 14) - 1));
|
|
const changedForPostMergeMunicipalityCountRebalance = rebalanceSmallPrefecturesByMunicipalityCount(graph.nodes, owner, Math.max(12, (seeds.minMunicipalitiesPerPrefecture || 14) - 1), 64);
|
|
const changedForOversizedPrefectureSplit = splitOversizedPrefecturesByMunicipalityCount(graph.nodes, owner, 88, 8);
|
|
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
|
// Keep prefectures as connected groups of municipalities; do not perform cell-level prefecture repair here.
|
|
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
|
const maxAdminId = Math.max(-1, ...[...graph.nodes.keys()]);
|
|
const municipalityToPrefectureId = new Int16Array(maxAdminId + 1);
|
|
municipalityToPrefectureId.fill(-1);
|
|
for (const [admin, pref] of owner) municipalityToPrefectureId[admin] = pref;
|
|
const prefectureRegionId = new Int16Array(SIZE);
|
|
prefectureRegionId.fill(-1);
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
|
|
prefectureRegionId[i] = municipalityToPrefectureId[adminId[i]] ?? -1;
|
|
}
|
|
const regionalPrefectureBorders = extractPrefectureBordersFromMunicipalities(adminId, municipalityToPrefectureId, prefectureMask, sea);
|
|
const areaByPref = new Map();
|
|
const popByPref = new Map();
|
|
for (const node of graph.nodes.values()) {
|
|
const pref = owner.get(node.id);
|
|
areaByPref.set(pref, (areaByPref.get(pref) || 0) + node.area);
|
|
popByPref.set(pref, (popByPref.get(pref) || 0) + node.population);
|
|
}
|
|
const totalArea = [...areaByPref.values()].reduce((sum, value) => sum + value, 0);
|
|
return {
|
|
prefectureRegionId,
|
|
municipalityToPrefectureId,
|
|
regionalPrefectureBorders,
|
|
regionalDebug: {
|
|
prefecturesGeneratedAfterMunicipalities: true,
|
|
prefectureSource: "municipality-boundary-union",
|
|
municipalityGraphNodeCount: graph.nodes.size,
|
|
municipalityGraphEdgeCount: graph.edges.size,
|
|
prefectureMunicipalitySeedCount: seeds.length,
|
|
prefectureTinyMergeChangedMunicipalities: changedForTinyMerge,
|
|
prefectureConnectivityRepairChangedMunicipalities: changedForConnectivity,
|
|
prefectureEnclaveRepairChangedMunicipalities: changedForEnclaveRepair,
|
|
prefectureUrbanMetroUnificationChangedMunicipalities: changedForMetroUnification,
|
|
prefectureMunicipalityCountRebalanceChangedMunicipalities: (changedForMunicipalityCountRebalance || 0) + (changedForPostMergeMunicipalityCountRebalance || 0),
|
|
prefectureTinyMunicipalityCountMergeChangedMunicipalities: changedForTinyPrefectureCountMerge || 0,
|
|
prefectureOversizedCountSplitChangedMunicipalities: changedForOversizedPrefectureSplit || 0,
|
|
finalRegionalMaxMunicipalityCount: Math.max(...prefectureMunicipalityCounts(owner).values()),
|
|
finalRegionalMunicipalityCountCap: 88,
|
|
finalRegionalMinMunicipalityCount: Math.min(...prefectureMunicipalityCounts(owner).values()),
|
|
finalRegionalMaxAreaShare: totalArea ? Math.max(0, ...areaByPref.values()) / totalArea : 0,
|
|
finalRegionalTinyPrefectureCount: [...areaByPref.values()].filter((area) => area < 520).length,
|
|
finalRegionalPrefectureBorderCount: regionalPrefectureBorders.length,
|
|
regionalPrefectureBordersRebuiltFromFinalId: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
function isProtectedAdminSeed(seed) {
|
|
if (!seed) return false;
|
|
if (seed.seedKind === "capital" || seed.protectedCity?.isPrefecturalCapital) return true;
|
|
if (seed.seedKind === "modernCity" && (seed.protectedCity?.population || seed.population || 0) >= 180000) return true;
|
|
if (seed.seedKind === "port" && seed.portClass === "major") return true;
|
|
if (seed.seedKind === "satelliteCity" && (seed.protectedSatellite?.population || seed.population || 0) >= 60000) return true;
|
|
return false;
|
|
}
|
|
|
|
function buildSeedLifecycle(adminCenters, adminId, prefectureMask, sea, minArea = 80) {
|
|
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
|
|
const lifecycle = adminCenters.map((center, id) => {
|
|
const protectedSeed = isProtectedAdminSeed(center);
|
|
const area = areaById.get(id) || 0;
|
|
const enoughArea = area >= (protectedSeed ? 28 : minArea);
|
|
return {
|
|
id,
|
|
protected: protectedSeed,
|
|
area,
|
|
state: enoughArea || protectedSeed ? "survived" : "pending",
|
|
};
|
|
});
|
|
return lifecycle;
|
|
}
|
|
|
|
function activeSeedIds(seedLifecycle) {
|
|
return new Set(seedLifecycle.filter((seed) => seed.state === "survived" || seed.protected).map((seed) => seed.id));
|
|
}
|
|
|
|
function dominantCompartmentOwners(compartments, adminId) {
|
|
const owner = new Int16Array(compartments.length);
|
|
owner.fill(-1);
|
|
for (const unit of compartments) {
|
|
if (!unit || unit.area === 0) continue;
|
|
const counts = new Map();
|
|
for (const i of unit.cells) {
|
|
const id = adminId[i];
|
|
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
|
|
}
|
|
let bestId = -1, best = -1;
|
|
for (const [id, count] of counts) if (count > best) { best = count; bestId = id; }
|
|
owner[unit.id] = bestId;
|
|
}
|
|
return owner;
|
|
}
|
|
|
|
function applyCompartmentOwners(adminId, compartments, owner) {
|
|
for (const unit of compartments) {
|
|
if (!unit || unit.area === 0) continue;
|
|
const id = owner[unit.id];
|
|
if (id < 0) continue;
|
|
for (const i of unit.cells) adminId[i] = id;
|
|
}
|
|
}
|
|
|
|
function absorbSeedCompartments(adminId, compartments, seedLifecycle) {
|
|
const owner = dominantCompartmentOwners(compartments, adminId);
|
|
const absorbed = new Set(seedLifecycle.filter((seed) => seed.state === "absorbed").map((seed) => seed.id));
|
|
let changed = 0;
|
|
for (const unit of compartments) {
|
|
if (!unit || unit.area === 0 || !absorbed.has(owner[unit.id])) continue;
|
|
let bestId = -1, bestScore = -INF;
|
|
for (const [neighborId, edge] of unit.adjacent) {
|
|
const candidate = owner[neighborId];
|
|
if (candidate < 0 || absorbed.has(candidate)) continue;
|
|
const neighbor = compartments[neighborId];
|
|
const score = edge.count * 2 - (edge.target / Math.max(1, edge.count)) * 2 + (neighbor?.area || 0) * 0.002;
|
|
if (score > bestScore) { bestScore = score; bestId = candidate; }
|
|
}
|
|
if (bestId < 0) continue;
|
|
owner[unit.id] = bestId;
|
|
changed += unit.area;
|
|
}
|
|
applyCompartmentOwners(adminId, compartments, owner);
|
|
return changed;
|
|
}
|
|
|
|
function splitOversizedLowlandsWithPendingSeeds(adminId, prefectureMask, sea, fields, compartments, adminCenters, seedLifecycle, settlements = []) {
|
|
const { plain, agriculture, basinField, coastalLowland, valleyField, ridgeField, slope, elevation } = fields;
|
|
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
|
|
const areas = [...areaById.values()].sort((a, b) => a - b);
|
|
const median = areas.length ? areas[Math.floor(areas.length / 2)] : 0;
|
|
if (!median) return { changedCells: 0, splitMunicipalities: 0, pendingSeedsUsed: 0 };
|
|
const owner = dominantCompartmentOwners(compartments, adminId);
|
|
const unitsByOwner = new Map();
|
|
for (const unit of compartments) {
|
|
if (!unit || unit.area === 0 || owner[unit.id] < 0) continue;
|
|
if (!unitsByOwner.has(owner[unit.id])) unitsByOwner.set(owner[unit.id], []);
|
|
unitsByOwner.get(owner[unit.id]).push(unit);
|
|
}
|
|
const pending = seedLifecycle.filter((seed) => seed.state === "pending" && !seed.protected);
|
|
let changedCells = 0;
|
|
let splitMunicipalities = 0;
|
|
let pendingSeedsUsed = 0;
|
|
for (const [id, units] of unitsByOwner) {
|
|
const area = areaById.get(id) || 0;
|
|
if (area < Math.max(260, median * 1.45) || units.length < 6) continue;
|
|
let lowland = 0, rough = 0;
|
|
for (const unit of units) {
|
|
for (const i of unit.cells) {
|
|
lowland += (plain[i] || 0) * 0.38 + (agriculture[i] || 0) * 0.20 + basinField[i] * 0.22 + coastalLowland[i] * 0.22 + valleyField[i] * 0.10;
|
|
rough += ridgeField[i] * 0.48 + slope[i] * 0.34 + Math.max(0, elevation[i] - 0.58) * 0.30;
|
|
}
|
|
}
|
|
if (lowland / area < 0.26 || rough / area > 0.48) continue;
|
|
const localPending = pending.filter((seed) => {
|
|
const center = adminCenters[seed.id];
|
|
if (!center || !inside(center.x, center.y)) return false;
|
|
const centerOwner = adminId[indexOf(center.x, center.y)];
|
|
return centerOwner === id || Math.hypot(center.x - (adminCenters[id]?.x || center.x), center.y - (adminCenters[id]?.y || center.y)) < 28;
|
|
});
|
|
const localSettlements = settlements.filter((p) => p && inside(p.x, p.y) && adminId[indexOf(p.x, p.y)] === id);
|
|
if (localPending.length === 0 || localPending.length + localSettlements.length < 2) continue;
|
|
let municipalitySplit = false;
|
|
for (const seed of localPending.slice(0, 3)) {
|
|
const center = adminCenters[seed.id];
|
|
if (!center) continue;
|
|
const targetArea = clamp(95 + (center.score || 0.5) * 60, 90, 180);
|
|
let claimed = 0;
|
|
const candidates = units
|
|
.filter((unit) => owner[unit.id] === id && unit.classId !== 8 && unit.classId !== 9)
|
|
.map((unit) => ({
|
|
unit,
|
|
score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 8 - (unit.urbanWeight || 0) * 3,
|
|
}))
|
|
.sort((a, b) => a.score - b.score);
|
|
if (candidates.length < 2) continue;
|
|
for (const { unit } of candidates) {
|
|
if (claimed >= targetArea && claimed >= 2) break;
|
|
owner[unit.id] = seed.id;
|
|
claimed += unit.area;
|
|
changedCells += unit.area;
|
|
}
|
|
if (claimed >= 45) {
|
|
seed.state = "survived";
|
|
seed.area = claimed;
|
|
pendingSeedsUsed++;
|
|
municipalitySplit = true;
|
|
}
|
|
}
|
|
if (municipalitySplit) splitMunicipalities++;
|
|
}
|
|
applyCompartmentOwners(adminId, compartments, owner);
|
|
return { changedCells, splitMunicipalities, pendingSeedsUsed };
|
|
}
|
|
|
|
function promotePendingSeedsForMunicipalityCount(adminId, prefectureMask, sea, fields, compartments, adminCenters, seedLifecycle, targetMinCount = 20) {
|
|
const { plain, agriculture, basinField, coastalLowland, valleyField, ridgeField, slope, elevation } = fields;
|
|
let areaById = municipalityAreaById(adminId, prefectureMask, sea);
|
|
let currentCount = areaById.size;
|
|
if (currentCount >= targetMinCount) return { changedCells: 0, promotedSeeds: 0 };
|
|
const owner = dominantCompartmentOwners(compartments, adminId);
|
|
let changedCells = 0;
|
|
let promotedSeeds = 0;
|
|
const pending = seedLifecycle
|
|
.filter((seed) => seed.state === "pending" && !seed.protected)
|
|
.sort((a, b) => (adminCenters[b.id]?.score || 0) - (adminCenters[a.id]?.score || 0));
|
|
for (const seed of pending) {
|
|
if (currentCount >= targetMinCount) break;
|
|
const center = adminCenters[seed.id];
|
|
if (!center || !inside(center.x, center.y)) continue;
|
|
const existingArea = areaById.get(seed.id) || 0;
|
|
if (existingArea >= 12) {
|
|
seed.state = "survived";
|
|
seed.area = existingArea;
|
|
promotedSeeds++;
|
|
continue;
|
|
}
|
|
const candidates = compartments
|
|
.filter((unit) => {
|
|
if (!unit || unit.area === 0) return false;
|
|
const currentOwner = owner[unit.id];
|
|
if (currentOwner < 0 || currentOwner === seed.id) return false;
|
|
const ownerArea = areaById.get(currentOwner) || 0;
|
|
if (ownerArea < 90) return false;
|
|
const lowlandFit = (unit.lowlandFitness || 0) + (unit.basinIdentity || 0) * 0.15 + (unit.coastalExposure || 0) * 0.15;
|
|
if (lowlandFit < 0.26) return false;
|
|
return Math.hypot(unit.x - center.x, unit.y - center.y) < 36;
|
|
})
|
|
.map((unit) => ({
|
|
unit,
|
|
score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 6 - (unit.urbanWeight || 0) * 2,
|
|
}))
|
|
.sort((a, b) => a.score - b.score);
|
|
if (candidates.length === 0) continue;
|
|
let claimed = 0;
|
|
for (const { unit } of candidates) {
|
|
const currentOwner = owner[unit.id];
|
|
if ((areaById.get(currentOwner) || 0) - unit.area < 70) continue;
|
|
owner[unit.id] = seed.id;
|
|
areaById.set(currentOwner, (areaById.get(currentOwner) || 0) - unit.area);
|
|
areaById.set(seed.id, (areaById.get(seed.id) || 0) + unit.area);
|
|
claimed += unit.area;
|
|
changedCells += unit.area;
|
|
if (claimed >= 55) break;
|
|
}
|
|
if (claimed >= 25) {
|
|
seed.state = "survived";
|
|
seed.area = areaById.get(seed.id) || claimed;
|
|
promotedSeeds++;
|
|
currentCount++;
|
|
}
|
|
}
|
|
applyCompartmentOwners(adminId, compartments, owner);
|
|
return { changedCells, promotedSeeds };
|
|
}
|
|
|
|
function restoreSurvivedSeedsByCompartment(adminId, prefectureMask, sea, compartments, adminCenters, seedLifecycle, targetMinCount = 20) {
|
|
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
|
|
let currentCount = areaById.size;
|
|
if (currentCount >= targetMinCount) return { changedCells: 0, restoredSeeds: 0 };
|
|
const owner = dominantCompartmentOwners(compartments, adminId);
|
|
let changedCells = 0;
|
|
let restoredSeeds = 0;
|
|
const missing = seedLifecycle
|
|
.filter((seed) => (seed.state === "survived" || seed.protected) && (areaById.get(seed.id) || 0) === 0)
|
|
.sort((a, b) => (b.protected ? 1 : 0) - (a.protected ? 1 : 0));
|
|
for (const seed of missing) {
|
|
if (currentCount >= targetMinCount) break;
|
|
const center = adminCenters[seed.id];
|
|
if (!center || !inside(center.x, center.y)) continue;
|
|
const candidates = compartments
|
|
.filter((unit) => {
|
|
if (!unit || unit.area === 0 || unit.classId === 8 || unit.classId === 9) return false;
|
|
const currentOwner = owner[unit.id];
|
|
if (currentOwner < 0 || currentOwner === seed.id) return false;
|
|
if ((areaById.get(currentOwner) || 0) - unit.area < 25) return false;
|
|
return Math.hypot(unit.x - center.x, unit.y - center.y) < 90 && ((unit.lowlandFitness || 0) > 0.08 || seed.protected);
|
|
})
|
|
.map((unit) => ({
|
|
unit,
|
|
score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 6 - (unit.urbanWeight || 0) * 2 + (unit.classId === 8 || unit.classId === 9 ? 20 : 0),
|
|
}))
|
|
.sort((a, b) => a.score - b.score);
|
|
if (candidates.length === 0) continue;
|
|
const unit = candidates[0].unit;
|
|
const oldOwner = owner[unit.id];
|
|
if ((areaById.get(oldOwner) || 0) - unit.area < 25) continue;
|
|
owner[unit.id] = seed.id;
|
|
const claimed = unit.area;
|
|
areaById.set(oldOwner, (areaById.get(oldOwner) || 0) - claimed);
|
|
changedCells += claimed;
|
|
areaById.set(seed.id, claimed);
|
|
seed.area = claimed;
|
|
restoredSeeds++;
|
|
currentCount++;
|
|
}
|
|
applyCompartmentOwners(adminId, compartments, owner);
|
|
return { changedCells, restoredSeeds };
|
|
}
|
|
|
|
function municipalityCountBoundsForRegion(landCells, meta = {}) {
|
|
// Use the same administrative density curve for the highlighted prefecture
|
|
// and neighboring prefectures. Only clipped slivers get a low floor.
|
|
let min = 1;
|
|
if (landCells >= 360) min = 2;
|
|
if (landCells >= 750) min = 4;
|
|
if (landCells >= 1400) min = 7;
|
|
if (landCells >= 2400) min = 11;
|
|
if (landCells >= 3800) min = 16;
|
|
if (landCells >= 5600) min = 22;
|
|
const max = clamp(Math.round(landCells / 160 + 6), Math.max(min, 4), 72);
|
|
return { min, max };
|
|
}
|
|
|
|
function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages, adminRegionMeta = {} }) {
|
|
let landCells = 0;
|
|
let habitableCells = 0;
|
|
let lowlandCells = 0;
|
|
let coastlineComplexity = 0;
|
|
let mountainCells = 0;
|
|
for (let y = 1; y < MAP_H - 1; y++) {
|
|
for (let x = 1; x < MAP_W - 1; x++) {
|
|
const i = indexOf(x, y);
|
|
if (!prefectureMask[i] || sea[i]) continue;
|
|
landCells++;
|
|
if (slope[i] < 0.42 && ridgeField[i] < 0.55 && (!elevation || elevation[i] < 0.72)) habitableCells++;
|
|
if ((coastalLowland[i] > 0.20 || basinField[i] > 0.24) && slope[i] < 0.36 && ridgeField[i] < 0.52) lowlandCells++;
|
|
if (ridgeField[i] > 0.52 || slope[i] > 0.48) mountainCells++;
|
|
for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) {
|
|
const ni = indexOf(nx, ny);
|
|
if (sea[ni]) {
|
|
coastlineComplexity += 1 + coastalLowland[i] * 0.8;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const independentSatellites = (satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality").length;
|
|
const settlementWeight = modernCities.length * 1.7 + markets.length * 1.15 + ports.length * 0.8 + independentSatellites * 0.7 + villages.length * 0.32;
|
|
const basinBonus = Math.min(8, [...basinField].filter((v, i) => prefectureMask[i] && !sea[i] && v > 0.34).length / 520);
|
|
const mountainRatio = landCells ? mountainCells / landCells : 0;
|
|
const lowlandBonus = Math.min(7, lowlandCells / 430);
|
|
const rawTarget = Math.round(habitableCells / 150 + settlementWeight * 1.08 + coastlineComplexity * 0.035 + basinBonus * 0.75 + lowlandBonus * 1.25 - mountainRatio * 1.45);
|
|
const { min, max } = municipalityCountBoundsForRegion(landCells, adminRegionMeta);
|
|
return clamp(rawTarget, min, max);
|
|
}
|
|
|
|
function lowlandAdminSeedScore(i, { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse }) {
|
|
const lowRelief = clamp((0.70 - elevation[i]) * 1.35) + clamp((0.34 - slope[i]) * 1.55) + clamp((0.48 - ridgeField[i]) * 1.10);
|
|
const landuseFit = [1, 2, 3, 4, 7, 8].includes(landuse[i]) ? 0.40 : landuse[i] === 5 || landuse[i] === 6 ? 0.12 : 0;
|
|
return clamp(
|
|
lowRelief * 0.25 +
|
|
plain[i] * 0.28 +
|
|
basinField[i] * 0.24 +
|
|
coastalLowland[i] * 0.24 +
|
|
settlementScore[i] * 0.30 +
|
|
populationDensity[i] * 0.32 +
|
|
roadInfluence[i] * 0.16 +
|
|
railInfluence2[i] * 0.16 +
|
|
(stationInfluence?.[i] || 0) * 0.18 +
|
|
landuseFit -
|
|
Math.max(0, elevation[i] - 0.62) * 1.2 -
|
|
Math.max(0, ridgeField[i] - 0.54) * 0.9
|
|
);
|
|
}
|
|
|
|
function buildLowlandAdminSeeds({
|
|
seed,
|
|
targetMunicipalityCount,
|
|
prefectureMask,
|
|
sea,
|
|
elevation,
|
|
slope,
|
|
ridgeField,
|
|
plain,
|
|
basinField,
|
|
coastalLowland,
|
|
settlementScore,
|
|
populationDensity,
|
|
roadInfluence,
|
|
railInfluence2,
|
|
stationInfluence,
|
|
landuse,
|
|
modernCities,
|
|
satelliteCities,
|
|
markets,
|
|
ports,
|
|
newTowns,
|
|
stations,
|
|
}) {
|
|
const fields = { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse };
|
|
function validLowlandPoint(p, strict = true) {
|
|
if (!p || !inside(p.x, p.y)) return false;
|
|
const i = indexOf(p.x, p.y);
|
|
if (!prefectureMask[i] || sea[i]) return false;
|
|
const score = lowlandAdminSeedScore(i, fields);
|
|
const mountain = elevation[i] > 0.70 || slope[i] > 0.52 || ridgeField[i] > 0.62;
|
|
return score >= (strict ? 0.34 : 0.24) && (!mountain || p.isPrefecturalCapital || (p.population || 0) >= 220000 || p.portClass === "major");
|
|
}
|
|
const realSeeds = [];
|
|
for (const city of modernCities || []) {
|
|
if (!validLowlandPoint(city, !city.isPrefecturalCapital)) continue;
|
|
if ((city.population || 0) < 45000) continue;
|
|
const i = indexOf(city.x, city.y);
|
|
realSeeds.push({ x: city.x, y: city.y, score: 1.35 + (city.population || 0) / 650000 + lowlandAdminSeedScore(i, fields), population: city.population || 0, protectedCity: city, seedKind: city.isPrefecturalCapital ? "capital" : "modernCity" });
|
|
}
|
|
for (const city of satelliteCities || []) {
|
|
if (city.municipalityClass !== "independentSatelliteMunicipality" || !validLowlandPoint(city, false)) continue;
|
|
const i = indexOf(city.x, city.y);
|
|
realSeeds.push({ x: city.x, y: city.y, score: 0.92 + (city.population || 0) / 260000 + lowlandAdminSeedScore(i, fields), population: city.population || 0, protectedSatellite: city, seedKind: "satelliteCity" });
|
|
}
|
|
for (const p of [...(markets || []), ...(ports || []), ...(newTowns || [])]) {
|
|
if (!validLowlandPoint(p, true)) continue;
|
|
const i = indexOf(p.x, p.y);
|
|
const portBonus = p.portClass === "major" ? 0.45 : p.portClass === "regional" ? 0.26 : 0;
|
|
realSeeds.push({ x: p.x, y: p.y, score: 0.74 + lowlandAdminSeedScore(i, fields) + portBonus + (p.population || 0) / 420000, population: p.population || 0, portClass: p.portClass, source: p, seedKind: p.portClass ? "port" : "marketTown" });
|
|
}
|
|
const picked = pickEntities(realSeeds, {
|
|
max: targetMunicipalityCount,
|
|
minDistance: 5 + Math.floor(rand(seed, 1302) * 3),
|
|
threshold: 0.62,
|
|
seed: seed + 1300,
|
|
jitter: 0.025,
|
|
});
|
|
const invisibleCandidates = [];
|
|
for (let y = 2; y < MAP_H - 2; y++) {
|
|
for (let x = 2; x < MAP_W - 2; x++) {
|
|
const i = indexOf(x, y);
|
|
if (!prefectureMask[i] || sea[i]) continue;
|
|
const score = lowlandAdminSeedScore(i, fields) + hash2(x, y, seed + 1311) * 0.045;
|
|
if (score < 0.48) continue;
|
|
const insideDenseCore = modernCities.some((city) => (city.population || 0) >= 180000 && Math.hypot(city.x - x, city.y - y) < Math.max(5, (city.coreRadius || 4) * 1.7));
|
|
if (insideDenseCore) continue;
|
|
invisibleCandidates.push({ x, y, score, invisibleLowlandAdminSeed: true, seedKind: "invisibleLowland" });
|
|
}
|
|
}
|
|
if (picked.length < targetMunicipalityCount) {
|
|
const extra = pickEntities(invisibleCandidates, {
|
|
max: targetMunicipalityCount - picked.length,
|
|
minDistance: 5,
|
|
threshold: 0.48,
|
|
seed: seed + 1304,
|
|
jitter: 0.02,
|
|
});
|
|
for (const p of extra) if (picked.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 4)) picked.push(p);
|
|
}
|
|
if (picked.length < Math.min(targetMunicipalityCount, 20)) {
|
|
const relaxed = pickEntities(invisibleCandidates, {
|
|
max: Math.min(targetMunicipalityCount, 20) - picked.length,
|
|
minDistance: 4,
|
|
threshold: 0.38,
|
|
seed: seed + 1305,
|
|
jitter: 0.02,
|
|
});
|
|
for (const p of relaxed) if (picked.length < targetMunicipalityCount && picked.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 3.5)) picked.push(p);
|
|
}
|
|
return picked.slice(0, targetMunicipalityCount);
|
|
}
|
|
|
|
function estimateUrbanComponentArea(city, prefectureMask, sea, landuse, populationDensity) {
|
|
if (!city || !inside(city.x, city.y)) return 0;
|
|
const start = indexOf(city.x, city.y);
|
|
if (!prefectureMask[start] || sea[start]) return 0;
|
|
const radius = Math.ceil(Math.max(7, (city.urbanRadius || 6) * 1.7));
|
|
const seen = new Uint8Array(SIZE);
|
|
const queue = [start];
|
|
seen[start] = 1;
|
|
let area = 0;
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
const [x, y] = xyOf(cur);
|
|
const d = Math.hypot(x - city.x, y - city.y);
|
|
if (d > radius) continue;
|
|
const urban = (landuse[cur] >= 2 && landuse[cur] <= 4) || landuse[cur] === 7 || landuse[cur] === 8 || populationDensity[cur] > 0.18;
|
|
if (!urban) continue;
|
|
area++;
|
|
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
|
const nx = x + dx, ny = y + dy;
|
|
if (!inside(nx, ny)) continue;
|
|
const ni = indexOf(nx, ny);
|
|
if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue;
|
|
seen[ni] = 1;
|
|
queue.push(ni);
|
|
}
|
|
}
|
|
return area;
|
|
}
|
|
|
|
function terrainSeparationBetween(a, b, ridgeField, river, flowAccum, populationDensity, landuse) {
|
|
if (!a || !b) return { separatedByBarrier: false, ruralGap: false, averageDensity: 0, maxBarrier: 0 };
|
|
const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y)));
|
|
let maxBarrier = 0;
|
|
let lowUrbanRun = 0;
|
|
let bestLowUrbanRun = 0;
|
|
let densitySum = 0;
|
|
for (let s = 0; s <= steps; s++) {
|
|
const t = s / steps;
|
|
const x = Math.round(a.x + (b.x - a.x) * t);
|
|
const y = Math.round(a.y + (b.y - a.y) * t);
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
const barrier = Math.max(ridgeField[i] * 0.95, river[i] * 0.85, flowAccum[i] * 0.42);
|
|
maxBarrier = Math.max(maxBarrier, barrier);
|
|
densitySum += populationDensity[i];
|
|
const urban = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.20;
|
|
if (urban) lowUrbanRun = 0;
|
|
else {
|
|
lowUrbanRun++;
|
|
bestLowUrbanRun = Math.max(bestLowUrbanRun, lowUrbanRun);
|
|
}
|
|
}
|
|
return {
|
|
separatedByBarrier: maxBarrier > 0.56,
|
|
ruralGap: bestLowUrbanRun >= 4,
|
|
averageDensity: densitySum / (steps + 1),
|
|
maxBarrier,
|
|
};
|
|
}
|
|
|
|
function classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, ridgeField, river, flowAccum) {
|
|
let independent = 0;
|
|
let attached = 0;
|
|
for (const sat of satelliteCities || []) {
|
|
if (!sat || !inside(sat.x, sat.y) || !prefectureMask[indexOf(sat.x, sat.y)] || sea[indexOf(sat.x, sat.y)]) continue;
|
|
const parent = modernCities[sat.parentCityIndex] || modernCities.slice().sort((a, b) => Math.hypot(a.x - sat.x, a.y - sat.y) - Math.hypot(b.x - sat.x, b.y - sat.y))[0];
|
|
const parentDistance = parent ? Math.hypot(parent.x - sat.x, parent.y - sat.y) : 99;
|
|
const separation = terrainSeparationBetween(sat, parent, ridgeField, river, flowAccum, populationDensity, landuse);
|
|
const urbanArea = estimateUrbanComponentArea(sat, prefectureMask, sea, landuse, populationDensity);
|
|
const i = indexOf(sat.x, sat.y);
|
|
const continuousUrban = parent && parentDistance < Math.max(10, (parent.urbanRadius || 12) + (sat.urbanRadius || 5) + 5) && separation.averageDensity > 0.14 && !separation.ruralGap && !separation.separatedByBarrier;
|
|
const newTownLike = landuse[i] === 7 || (railInfluence2[i] > 0.22 && roadInfluence[i] > 0.12 && (sat.population || 0) < 70000);
|
|
let municipalityClass = "independentSatelliteMunicipality";
|
|
if (continuousUrban && (sat.population || 0) < 90000) municipalityClass = "suburbanDistrictMergedWithParent";
|
|
else if (newTownLike && (sat.population || 0) < 85000 && !separation.separatedByBarrier) municipalityClass = "newTownDistrict";
|
|
else if ((sat.population || 0) < 42000 && urbanArea < 55 && !separation.separatedByBarrier) municipalityClass = "smallTownAttachedToRuralMunicipality";
|
|
else if ((sat.population || 0) >= 60000 && urbanArea >= 42 && (separation.separatedByBarrier || separation.ruralGap || parentDistance > 15)) municipalityClass = "independentSatelliteMunicipality";
|
|
|
|
sat.municipalityClass = municipalityClass;
|
|
sat.parentX = parent?.x;
|
|
sat.parentY = parent?.y;
|
|
sat.parentAdminHint = -1;
|
|
sat.distinctUrbanComponentArea = urbanArea;
|
|
sat.separatedByBarrier = separation.separatedByBarrier || separation.ruralGap;
|
|
sat.satelliteMinArea = clamp(90 + Math.sqrt(sat.population || 24000) * 0.62 + (sat.urbanRadius || 5) * 12, 80, 360);
|
|
if (municipalityClass === "independentSatelliteMunicipality") independent++;
|
|
else attached++;
|
|
}
|
|
return { independent, attached };
|
|
}
|
|
|
|
function expandSatelliteMunicipalityCatchment(adminId, satellite, targetAdmin, context) {
|
|
const { prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities } = context;
|
|
if (!satellite || targetAdmin < 0 || !inside(satellite.x, satellite.y)) return 0;
|
|
const start = indexOf(satellite.x, satellite.y);
|
|
if (!prefectureMask[start] || sea[start]) return 0;
|
|
const targetAreaBase = clamp(90 + Math.sqrt(satellite.population || 24000) * 0.8 + (satellite.urbanRadius || 5) * 18, 120, 520);
|
|
const targetArea = satellite.municipalityClass === "smallTownAttachedToRuralMunicipality"
|
|
? Math.min(130, targetAreaBase * 0.55)
|
|
: satellite.municipalityClass === "suburbanDistrictMergedWithParent" || satellite.municipalityClass === "newTownDistrict"
|
|
? Math.min(190, targetAreaBase * 0.62)
|
|
: targetAreaBase;
|
|
const maxCost = satellite.municipalityClass === "independentSatelliteMunicipality" ? 46 : 32;
|
|
const heap = new MinHeap();
|
|
const best = new Float32Array(SIZE);
|
|
best.fill(INF);
|
|
heap.push({ i: start, f: 0 });
|
|
best[start] = 0;
|
|
const claimed = [];
|
|
while (heap.length > 0 && claimed.length < targetArea) {
|
|
const cur = heap.pop();
|
|
if (!cur || cur.f > best[cur.i] + 1e-5 || cur.f > maxCost) continue;
|
|
const [x, y] = xyOf(cur.i);
|
|
const d = Math.hypot(x - satellite.x, y - satellite.y);
|
|
if (!prefectureMask[cur.i] || sea[cur.i]) continue;
|
|
let invadesOtherCore = false;
|
|
for (const city of modernCities || []) {
|
|
if (!city || (city.population || 0) < 140000) continue;
|
|
if (Math.hypot(city.x - satellite.x, city.y - satellite.y) < 4) continue;
|
|
if (Math.hypot(city.x - x, city.y - y) <= Math.max(3.5, (city.coreRadius || 4) * 1.25)) {
|
|
invadesOtherCore = true;
|
|
break;
|
|
}
|
|
}
|
|
if (invadesOtherCore) continue;
|
|
const compatible = d <= (satellite.urbanRadius || 5) * 1.25 ||
|
|
[2, 3, 4, 7, 8].includes(landuse[cur.i]) ||
|
|
populationDensity[cur.i] > 0.12 ||
|
|
roadInfluence[cur.i] > 0.12 ||
|
|
railInfluence2[cur.i] > 0.10 ||
|
|
stationInfluence?.[cur.i] > 0.10 ||
|
|
basinField[cur.i] > 0.22 ||
|
|
valleyField[cur.i] > 0.24 ||
|
|
coastalLowland[cur.i] > 0.20;
|
|
if (!compatible && claimed.length > targetArea * 0.55) continue;
|
|
claimed.push(cur.i);
|
|
for (const [dx, dy, step] of [[1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1], [1, 1, 1.41], [-1, 1, 1.41], [1, -1, 1.41], [-1, -1, 1.41]]) {
|
|
const nx = x + dx, ny = y + dy;
|
|
if (!inside(nx, ny)) continue;
|
|
const ni = indexOf(nx, ny);
|
|
if (!prefectureMask[ni] || sea[ni]) continue;
|
|
const barrier = ridgeField[ni] * 5.2 + Math.max(0, elevation[ni] - 0.58) * 4.0 + slope[ni] * 2.2 + (river[ni] > 0.55 || flowAccum[ni] > 0.70 ? 7.5 : river[ni] > 0.30 ? 2.8 : 0);
|
|
const living = ([2, 3, 4, 7, 8].includes(landuse[ni]) ? 2.2 : 0) + populationDensity[ni] * 2.0 + roadInfluence[ni] * 0.85 + railInfluence2[ni] * 0.95 + (stationInfluence?.[ni] || 0) * 1.2 + basinField[ni] * 0.42 + valleyField[ni] * 0.48 + coastalLowland[ni] * 0.32;
|
|
const distanceCost = Math.hypot(nx - satellite.x, ny - satellite.y) / Math.max(7, (satellite.urbanRadius || 5) * 1.9);
|
|
const nd = cur.f + Math.max(0.28, 1.05 + barrier - living + distanceCost) * step;
|
|
if (nd < best[ni]) {
|
|
best[ni] = nd;
|
|
heap.push({ i: ni, f: nd });
|
|
}
|
|
}
|
|
}
|
|
let changed = 0;
|
|
for (const i of claimed) {
|
|
if (adminId[i] !== targetAdmin) changed++;
|
|
adminId[i] = targetAdmin;
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function cityMinimumMunicipalityArea(city) {
|
|
const populationArea = Math.sqrt(city.population || 0) * 0.72;
|
|
const footprintArea = (city.urbanFootprintCells || 0) * 0.42;
|
|
return clamp(95 + populationArea + footprintArea, 130, (city.population || 0) >= 450000 ? 780 : 520);
|
|
}
|
|
|
|
function enforceCityMunicipalityCatchments(adminId, cities, context) {
|
|
const { prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence } = context;
|
|
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
|
|
let changed = 0;
|
|
let protectedCities = 0;
|
|
let tooSmall = 0;
|
|
for (const city of cities || []) {
|
|
if (!city || (city.population || 0) < 95000 || !inside(city.x, city.y)) continue;
|
|
const start = indexOf(city.x, city.y);
|
|
if (!prefectureMask[start] || sea[start]) continue;
|
|
const targetAdmin = adminId[start];
|
|
if (targetAdmin < 0) continue;
|
|
protectedCities++;
|
|
const minArea = cityMinimumMunicipalityArea(city);
|
|
if ((areaById.get(targetAdmin) || 0) >= minArea) continue;
|
|
tooSmall++;
|
|
const heap = new MinHeap();
|
|
const best = new Float32Array(SIZE);
|
|
best.fill(INF);
|
|
heap.push({ i: start, f: 0 });
|
|
best[start] = 0;
|
|
const claimed = [];
|
|
const maxCost = (city.population || 0) >= 450000 ? 78 : 56;
|
|
let projectedArea = areaById.get(targetAdmin) || 0;
|
|
while (heap.length > 0 && projectedArea < minArea) {
|
|
const cur = heap.pop();
|
|
if (!cur || cur.f > best[cur.i] + 1e-5 || cur.f > maxCost) continue;
|
|
const [x, y] = xyOf(cur.i);
|
|
if (!prefectureMask[cur.i] || sea[cur.i]) continue;
|
|
const d = Math.hypot(x - city.x, y - city.y);
|
|
const compatible = d <= Math.max(5, (city.coreRadius || 3) * 2.0) ||
|
|
[2, 3, 4, 7, 8].includes(landuse[cur.i]) ||
|
|
populationDensity[cur.i] > 0.10 ||
|
|
roadInfluence[cur.i] > 0.10 ||
|
|
railInfluence2[cur.i] > 0.10 ||
|
|
(stationInfluence?.[cur.i] || 0) > 0.10 ||
|
|
valleyField[cur.i] > 0.22 ||
|
|
basinField[cur.i] > 0.20 ||
|
|
coastalLowland[cur.i] > 0.18;
|
|
if (!compatible && claimed.length > minArea * 0.55) continue;
|
|
claimed.push(cur.i);
|
|
if (adminId[cur.i] !== targetAdmin) projectedArea++;
|
|
for (const [dx, dy, step] of [[1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1], [1, 1, 1.41], [-1, 1, 1.41], [1, -1, 1.41], [-1, -1, 1.41]]) {
|
|
const nx = x + dx, ny = y + dy;
|
|
if (!inside(nx, ny)) continue;
|
|
const ni = indexOf(nx, ny);
|
|
if (!prefectureMask[ni] || sea[ni]) continue;
|
|
const majorBarrier = river[ni] > 0.62 || flowAccum[ni] > 0.74 || ridgeField[ni] > 0.70;
|
|
const barrier = ridgeField[ni] * 5.4 + Math.max(0, elevation[ni] - 0.60) * 4.4 + slope[ni] * 2.6 + (majorBarrier ? 8.5 : river[ni] > 0.34 ? 2.6 : 0);
|
|
const fit = ([2, 3, 4, 7, 8].includes(landuse[ni]) ? 2.4 : 0) + populationDensity[ni] * 2.2 + roadInfluence[ni] * 0.88 + railInfluence2[ni] * 0.92 + (stationInfluence?.[ni] || 0) * 1.15 + valleyField[ni] * 0.56 + basinField[ni] * 0.42 + coastalLowland[ni] * 0.34;
|
|
const nd = cur.f + Math.max(0.30, 1.05 + barrier - fit + Math.hypot(nx - city.x, ny - city.y) / Math.max(8, (city.urbanRadius || 7) * 2.1)) * step;
|
|
if (nd < best[ni]) {
|
|
best[ni] = nd;
|
|
heap.push({ i: ni, f: nd });
|
|
}
|
|
}
|
|
}
|
|
for (const i of claimed) {
|
|
const old = adminId[i];
|
|
if (old === targetAdmin) continue;
|
|
if (old >= 0) areaById.set(old, Math.max(0, (areaById.get(old) || 0) - 1));
|
|
adminId[i] = targetAdmin;
|
|
areaById.set(targetAdmin, (areaById.get(targetAdmin) || 0) + 1);
|
|
changed++;
|
|
}
|
|
city.municipalityMinArea = minArea;
|
|
}
|
|
return { changed, protectedCities, tooSmall };
|
|
}
|
|
|
|
function generateAdminLayoutForMask({
|
|
seed,
|
|
prefectureMask,
|
|
sea,
|
|
elevation,
|
|
slope,
|
|
river,
|
|
ridgeField,
|
|
naturalBarrierScore,
|
|
valleyField,
|
|
basinField,
|
|
coastalLowland,
|
|
flowAccum,
|
|
plain,
|
|
agriculture,
|
|
settlementScore,
|
|
populationDensity,
|
|
stationInfluence,
|
|
roadInfluence,
|
|
railInfluence2,
|
|
villageInfluence,
|
|
landuse,
|
|
modernCities,
|
|
satelliteCities,
|
|
newTowns,
|
|
markets,
|
|
villages,
|
|
ports,
|
|
stations,
|
|
industrialZones,
|
|
logisticsParks,
|
|
naturalCompartmentId,
|
|
naturalCompartments,
|
|
adminRegionMeta = {},
|
|
adminProgress = null,
|
|
}) {
|
|
const boundaryRidgeField = naturalBarrierScore
|
|
? Float32Array.from(ridgeField, (value, i) => clamp(value * 0.72 + naturalBarrierScore[i] * 0.46))
|
|
: ridgeField;
|
|
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "classify satellites" });
|
|
const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, boundaryRidgeField, river, flowAccum);
|
|
const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages, adminRegionMeta });
|
|
const compartmentMultiplier = clamp(4.6 + rand(seed, 1320) * 2.4, 4.6, 7.0);
|
|
const regionLandArea = adminRegionMeta.landArea || maskLandArea(prefectureMask, sea);
|
|
const minCompartmentTarget = clamp(Math.round(Math.max(targetMunicipalityCount * 3.4, regionLandArea / 78)), 22, 150);
|
|
const maxCompartmentTarget = clamp(Math.round(Math.max(targetMunicipalityCount * 6.0, regionLandArea / 36)), minCompartmentTarget, 320);
|
|
let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), minCompartmentTarget, maxCompartmentTarget);
|
|
let adminCentersRaw = buildLowlandAdminSeeds({
|
|
seed,
|
|
targetMunicipalityCount,
|
|
prefectureMask,
|
|
sea,
|
|
elevation,
|
|
slope,
|
|
ridgeField: boundaryRidgeField,
|
|
plain,
|
|
basinField,
|
|
coastalLowland,
|
|
settlementScore,
|
|
populationDensity,
|
|
roadInfluence,
|
|
railInfluence2,
|
|
stationInfluence,
|
|
landuse,
|
|
modernCities,
|
|
satelliteCities,
|
|
markets,
|
|
ports,
|
|
newTowns,
|
|
stations,
|
|
});
|
|
if (adminCentersRaw.length < targetMunicipalityCount) targetCompartmentCount = Math.max(targetCompartmentCount, minCompartmentTarget);
|
|
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "natural compartments", targetMunicipalityCount, targetCompartmentCount, seedCount: adminCentersRaw.length });
|
|
const compartmentAssignment = assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, {
|
|
seed,
|
|
targetMunicipalityCount,
|
|
targetCompartmentCount,
|
|
maxNaturalCompartmentArea: Math.max(32, Math.round(maskLandArea(prefectureMask, sea) / Math.max(1, targetCompartmentCount) * 1.65)),
|
|
naturalCompartmentId,
|
|
naturalCompartments,
|
|
naturalBarrierScore,
|
|
progress: (step) => adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step }),
|
|
});
|
|
const adminId = compartmentAssignment.adminId;
|
|
if (naturalCompartmentId && naturalCompartments) {
|
|
const oversizedCompartmentSplit = splitOversizedCompartmentMunicipalities(adminId, adminCentersRaw, compartmentAssignment.compartments, prefectureMask, sea, {
|
|
elevation, slope, ridgeField: boundaryRidgeField, plain, agriculture, basinField, coastalLowland, populationDensity,
|
|
}, seed + 21900);
|
|
const changedAfterInitialCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
|
|
const hierarchyRepair = enforceSimpleAdministrativeHierarchy(adminId, compartmentAssignment.compartments, prefectureMask, sea, {
|
|
minCompartmentsPerMunicipality: 2,
|
|
maxUrbanClusterCells: 1600,
|
|
modernCities,
|
|
});
|
|
const changedAfterFinalCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
|
|
const compacted = compactWholeCompartmentMunicipalities(adminId, adminCentersRaw, prefectureMask, sea, { populationDensity, plain, slope });
|
|
const adminBorders = extractAdminBorderSegments(compacted.adminId, prefectureMask);
|
|
const actualMunicipalityCount = compacted.activeMunicipalityCount;
|
|
const naturalCompartmentCount = compartmentAssignment.debug?.naturalCompartmentCount || naturalCompartments.filter((unit) => unit && unit.area > 0).length;
|
|
const adminDebug = {
|
|
...compartmentAssignment.debug,
|
|
simpleHierarchyPrototype: true,
|
|
administrativeHierarchySpec: "natural-compartments->municipalities->prefectures",
|
|
naturalCompartmentsImmutable: true,
|
|
municipalitiesAreCompartmentGroups: true,
|
|
prefecturesAreMunicipalityGroups: true,
|
|
cellLevelAdminSmoothingDisabled: true,
|
|
sharedNaturalCompartmentLayer: true,
|
|
skippedLegacyCellCleanupForHierarchy: true,
|
|
targetMunicipalityCount,
|
|
actualMunicipalityCount,
|
|
finalMunicipalityCount: actualMunicipalityCount,
|
|
candidateSeedCount: adminCentersRaw.length,
|
|
municipalOfficePointCount: compacted.adminCentersRaw.length,
|
|
seedCellRevivalCount: 0,
|
|
survivedSeedCount: compacted.activeMunicipalityCount,
|
|
pendingSeedCount: 0,
|
|
absorbedSeedCount: Math.max(0, adminCentersRaw.length - compacted.activeMunicipalityCount),
|
|
targetNaturalCompartmentCount: targetCompartmentCount,
|
|
naturalCompartmentCount,
|
|
compartmentCount: naturalCompartmentCount,
|
|
changedAfterCompartmentAssignment: naturalCompartmentCount,
|
|
changedAfterInitialCompartmentOwnership,
|
|
changedAfterFinalCompartmentOwnership,
|
|
changedAfterUrbanUnification: hierarchyRepair.changedAfterUrbanUnification,
|
|
urbanComponentsUnified: hierarchyRepair.urbanComponentsUnified,
|
|
changedAfterCityMetroMunicipalityUnification: hierarchyRepair.changedAfterCityMetroMunicipalityUnification,
|
|
cityMetroMunicipalitiesUnified: hierarchyRepair.cityMetroMunicipalitiesUnified,
|
|
changedAfterCompartmentConnectivity: hierarchyRepair.changedAfterCompartmentConnectivity,
|
|
disconnectedCompartmentComponentsMerged: hierarchyRepair.disconnectedCompartmentComponentsMerged,
|
|
changedAfterAdminEnclaveRepair: hierarchyRepair.changedAfterCompartmentEnclaveRepair,
|
|
compartmentEnclaveComponentsMerged: hierarchyRepair.compartmentEnclaveComponentsMerged,
|
|
changedAfterSingleCompartmentMunicipalityMerge: hierarchyRepair.changedAfterSingleCompartmentMunicipalityMerge,
|
|
singleCompartmentMunicipalitiesMerged: hierarchyRepair.singleCompartmentMunicipalitiesMerged,
|
|
remainingSingleCompartmentMunicipalities: hierarchyRepair.remainingSingleCompartmentMunicipalities,
|
|
changedAfterPostMergeCompartmentOwnership: changedAfterFinalCompartmentOwnership,
|
|
changedAfterOversizedCompartmentSplit: oversizedCompartmentSplit.changedCells,
|
|
oversizedCompartmentMunicipalitiesSplit: oversizedCompartmentSplit.splitMunicipalities,
|
|
oversizedCompartmentSplitAddedCenters: oversizedCompartmentSplit.addedCenters,
|
|
oversizedCompartmentSplitMaxArea: oversizedCompartmentSplit.maxArea || 0,
|
|
finalTinyMunicipalityCount: [...municipalityAreaById(compacted.adminId, prefectureMask, sea).values()].filter((area) => area > 0 && area < 8).length,
|
|
compartmentBorders: compartmentAssignment.debug?.compartmentBorders || [],
|
|
borderNaturalBarrierAverage: compartmentAssignment.debug?.finalBorderNaturalBarrierAverage || 0,
|
|
voronoiLikeRate: compartmentAssignment.debug?.voronoiLikeRateAfter || 0,
|
|
};
|
|
return {
|
|
adminCentersRaw: compacted.adminCentersRaw,
|
|
adminId: compacted.adminId,
|
|
adminBorders,
|
|
adminDebug,
|
|
naturalCompartmentId: compartmentAssignment.compartmentId,
|
|
naturalCompartments: compartmentAssignment.compartments,
|
|
};
|
|
}
|
|
let previousSnapshot = new Int16Array(adminId);
|
|
const adminDebug = {
|
|
changedAfterSmooth: 0,
|
|
changedAfterUrbanLock: 0,
|
|
changedAfterSmallUrbanLock: 0,
|
|
changedAfterInitialMerge: 0,
|
|
changedAfterInitialExclaveRemoval: 0,
|
|
changedAfterLandscapePartition: 0,
|
|
changedAfterSnap: 0,
|
|
changedAfterOversizedRuralSplit: 0,
|
|
changedAfterFinalExclaveRemoval: 0,
|
|
changedAfterFinalMerge: 0,
|
|
targetMunicipalityCount,
|
|
actualMunicipalityCount: 0,
|
|
municipalityCountReason: "habitable cells, settlement weight, coastline complexity, basin/lowland bonus, and mountain-ratio adjustment",
|
|
changedAfterCompartmentAssignment: compartmentAssignment.debug?.naturalCompartmentCount || 0,
|
|
oversizedRuralSplits: 0,
|
|
oversizedLowlandSplits: 0,
|
|
ruralSplitsAccepted: 0,
|
|
ruralSplitsRejected: 0,
|
|
targetNaturalCompartmentCount: targetCompartmentCount,
|
|
compartmentMultiplier,
|
|
lowlandAdminSeedCount: adminCentersRaw.filter((p) => p.invisibleLowlandAdminSeed).length,
|
|
lowlandAdminSeeds: adminCentersRaw.filter((p) => p.invisibleLowlandAdminSeed).map((p) => ({ x: p.x, y: p.y })),
|
|
realAdminSeedCount: adminCentersRaw.filter((p) => !p.invisibleLowlandAdminSeed).length,
|
|
highMountainAdminSeedCount: adminCentersRaw.filter((p) => {
|
|
const i = indexOf(p.x, p.y);
|
|
return elevation[i] > 0.70 || slope[i] > 0.52 || boundaryRidgeField[i] > 0.62;
|
|
}).length,
|
|
candidateSeedCount: adminCentersRaw.length,
|
|
protectedSeedCount: adminCentersRaw.filter(isProtectedAdminSeed).length,
|
|
survivedSeedCount: 0,
|
|
pendingSeedCount: 0,
|
|
absorbedSeedCount: 0,
|
|
pendingSeedsUsedForLowlandSplit: 0,
|
|
finalMunicipalityCount: 0,
|
|
finalTinyMunicipalityCount: 0,
|
|
seedCellRevivalCount: 0,
|
|
satelliteMunicipalitiesCreated: adminCentersRaw.filter((p) => p.protectedSatellite).length,
|
|
satelliteMunicipalitiesMerged: 0,
|
|
satelliteMunicipalitiesExpanded: 0,
|
|
satelliteMunicipalitiesTooSmall: 0,
|
|
averageSatelliteMunicipalityArea: 0,
|
|
minSatelliteMunicipalityArea: 0,
|
|
satelliteMunicipalityAreaByNameOrIndex: {},
|
|
independentSatelliteMunicipalities: satelliteClassificationDebug.independent,
|
|
attachedSatelliteDistricts: satelliteClassificationDebug.attached,
|
|
satelliteMunicipalityStats: satelliteClassificationDebug,
|
|
...compartmentAssignment.debug,
|
|
};
|
|
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "seed lifecycle" });
|
|
const seedLifecycle = buildSeedLifecycle(adminCentersRaw, adminId, prefectureMask, sea, 35);
|
|
const pendingSplitDebug = splitOversizedLowlandsWithPendingSeeds(adminId, prefectureMask, sea, {
|
|
plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation,
|
|
}, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, [...(satelliteCities || []), ...newTowns, ...markets, ...villages, ...ports]);
|
|
adminDebug.changedAfterPendingSeedLowlandSplit = pendingSplitDebug.changedCells;
|
|
adminDebug.pendingSeedsUsedForLowlandSplit = pendingSplitDebug.pendingSeedsUsed;
|
|
adminDebug.oversizedLowlandSplits += pendingSplitDebug.splitMunicipalities;
|
|
const pendingPromotionDebug = promotePendingSeedsForMunicipalityCount(adminId, prefectureMask, sea, {
|
|
plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation,
|
|
}, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, Math.min(24, targetMunicipalityCount));
|
|
adminDebug.changedAfterPendingSeedCountRepair = pendingPromotionDebug.changedCells;
|
|
adminDebug.pendingSeedsPromotedForCount = pendingPromotionDebug.promotedSeeds;
|
|
let areaAfterPendingSplit = municipalityAreaById(adminId, prefectureMask, sea);
|
|
for (const seedState of seedLifecycle) {
|
|
if (seedState.state !== "pending") continue;
|
|
seedState.area = areaAfterPendingSplit.get(seedState.id) || 0;
|
|
if (seedState.area >= 35) seedState.state = "survived";
|
|
else seedState.state = "absorbed";
|
|
}
|
|
adminDebug.changedAfterAbsorbingSeeds = absorbSeedCompartments(adminId, compartmentAssignment.compartments, seedLifecycle);
|
|
let activeAdminIds = activeSeedIds(seedLifecycle);
|
|
function markChanged(field) {
|
|
adminDebug[field] = changedCellsSince(previousSnapshot, adminId, prefectureMask, sea);
|
|
previousSnapshot = new Int16Array(adminId);
|
|
}
|
|
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "smooth boundaries" });
|
|
smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, populationDensity, landuse, 2);
|
|
markChanged("changedAfterSmooth");
|
|
|
|
function lockUrbanClusterToMunicipality(city, radius, allowSuburban = true) {
|
|
if (!city || !prefectureMask[indexOf(city.x, city.y)]) return;
|
|
let bestAdmin = -1;
|
|
let bestD = INF;
|
|
adminCentersRaw.forEach((center, id) => {
|
|
if (!activeAdminIds.has(id)) return;
|
|
const d = Math.hypot(center.x - city.x, center.y - city.y);
|
|
if (d < bestD) { bestD = d; bestAdmin = id; }
|
|
});
|
|
if (bestAdmin < 0) return;
|
|
const r = Math.ceil(radius);
|
|
for (let dy = -r; dy <= r; dy++) {
|
|
for (let dx = -r; dx <= r; dx++) {
|
|
const x = city.x + dx;
|
|
const y = city.y + dy;
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
if (!prefectureMask[i] || sea[i]) continue;
|
|
const d = Math.hypot(dx, dy);
|
|
if (d > radius) continue;
|
|
const urban = landuse[i] === 2 || landuse[i] === 3 || (allowSuburban && (landuse[i] === 4 || landuse[i] === 7 || landuse[i] === 8));
|
|
if (urban || populationDensity[i] > 0.22) adminId[i] = bestAdmin;
|
|
}
|
|
}
|
|
}
|
|
for (const city of modernCities) {
|
|
const radius = (city.population || 0) >= 500000
|
|
? clamp(17 + Math.sqrt(city.population) / 120, 20, 38)
|
|
: clamp(5 + Math.sqrt(city.population || 70000) / 210, 6, 11);
|
|
lockUrbanClusterToMunicipality(city, radius, true);
|
|
}
|
|
for (const sat of satelliteCities || []) {
|
|
if (!prefectureMask[indexOf(sat.x, sat.y)]) continue;
|
|
let bestAdmin = -1;
|
|
if (sat.municipalityClass === "independentSatelliteMunicipality") {
|
|
let bestD = INF;
|
|
adminCentersRaw.forEach((center, id) => {
|
|
if (!activeAdminIds.has(id)) return;
|
|
const d = Math.hypot(center.x - sat.x, center.y - sat.y);
|
|
if (d < bestD) { bestD = d; bestAdmin = id; }
|
|
});
|
|
} else if (inside(sat.parentX ?? -1, sat.parentY ?? -1)) {
|
|
bestAdmin = adminId[indexOf(sat.parentX, sat.parentY)];
|
|
}
|
|
if (bestAdmin < 0) continue;
|
|
sat.parentAdminHint = bestAdmin;
|
|
const changed = expandSatelliteMunicipalityCatchment(adminId, sat, bestAdmin, {
|
|
prefectureMask, sea, elevation, slope, river, ridgeField: boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum,
|
|
landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities,
|
|
});
|
|
if (changed > 0 && sat.municipalityClass === "independentSatelliteMunicipality") adminDebug.satelliteMunicipalitiesExpanded++;
|
|
}
|
|
markChanged("changedAfterUrbanLock");
|
|
lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 520);
|
|
lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 620);
|
|
markChanged("changedAfterSmallUrbanLock");
|
|
const activeAdminCenters = () => adminCentersRaw.filter((_, id) => activeAdminIds.has(id));
|
|
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 120, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 100, protectedPoints: activeAdminCenters() });
|
|
markChanged("changedAfterInitialMerge");
|
|
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "topology cleanup" });
|
|
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 180);
|
|
markChanged("changedAfterInitialExclaveRemoval");
|
|
// The initial compartment graph assignment is now the primary natural partition.
|
|
// Re-running the older raw landscape-unit pass here collapses lowland seeds into a few broad owners.
|
|
markChanged("changedAfterLandscapePartition");
|
|
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "split oversized municipalities" });
|
|
// The older oversized-lowland pass rebuilds natural compartments a second time.
|
|
// The current pipeline already performs pending-seed lowland splitting on the active
|
|
// compartment graph above, so keep the full admin layout while avoiding the duplicate
|
|
// high-cost recomputation.
|
|
const oversizedSplitDebug = {
|
|
changedCells: 0,
|
|
splitMunicipalities: 0,
|
|
rejectedMunicipalities: 0,
|
|
skippedDuplicateCompartmentRebuild: true,
|
|
skippedForVisibleFragment: false,
|
|
};
|
|
adminDebug.changedAfterOversizedRuralSplit = oversizedSplitDebug.changedCells;
|
|
adminDebug.oversizedRuralMunicipalitiesSplit = oversizedSplitDebug.splitMunicipalities;
|
|
adminDebug.oversizedRuralSplits = oversizedSplitDebug.splitMunicipalities;
|
|
adminDebug.oversizedLowlandSplits = oversizedSplitDebug.splitMunicipalities;
|
|
adminDebug.ruralSplitsAccepted = oversizedSplitDebug.splitMunicipalities;
|
|
adminDebug.ruralSplitsRejected = oversizedSplitDebug.rejectedMunicipalities || 0;
|
|
adminDebug.oversizedSplitSkippedForVisibleFragment = Boolean(oversizedSplitDebug.skippedForVisibleFragment);
|
|
adminDebug.oversizedSplitSkippedDuplicateCompartmentRebuild = Boolean(oversizedSplitDebug.skippedDuplicateCompartmentRebuild);
|
|
previousSnapshot = new Int16Array(adminId);
|
|
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "terrain snap" });
|
|
snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 2);
|
|
markChanged("changedAfterSnap");
|
|
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 360);
|
|
markChanged("changedAfterFinalExclaveRemoval");
|
|
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 80, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 90, protectedPoints: activeAdminCenters() });
|
|
markChanged("changedAfterFinalMerge");
|
|
|
|
for (const sat of satelliteCities || []) {
|
|
if (sat.municipalityClass !== "independentSatelliteMunicipality" || !inside(sat.x, sat.y)) continue;
|
|
const targetAdmin = adminId[indexOf(sat.x, sat.y)];
|
|
if (targetAdmin < 0) continue;
|
|
expandSatelliteMunicipalityCatchment(adminId, sat, targetAdmin, {
|
|
prefectureMask, sea, elevation, slope, river, ridgeField: boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum,
|
|
landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities,
|
|
});
|
|
}
|
|
const cityCatchmentDebug = enforceCityMunicipalityCatchments(adminId, modernCities, {
|
|
prefectureMask, sea, elevation, slope, river, ridgeField: boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum,
|
|
landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence,
|
|
});
|
|
adminDebug.changedAfterCityMunicipalityCatchment = cityCatchmentDebug.changed;
|
|
adminDebug.protectedCityMunicipalityCount = cityCatchmentDebug.protectedCities;
|
|
adminDebug.tooSmallCityMunicipalityCountBeforeRepair = cityCatchmentDebug.tooSmall;
|
|
snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 2);
|
|
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 260);
|
|
const finalPromotionDebug = promotePendingSeedsForMunicipalityCount(adminId, prefectureMask, sea, {
|
|
plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation,
|
|
}, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, Math.min(22, targetMunicipalityCount));
|
|
adminDebug.changedAfterFinalPendingSeedCountRepair = finalPromotionDebug.changedCells;
|
|
adminDebug.pendingSeedsPromotedForCount += finalPromotionDebug.promotedSeeds;
|
|
const restoredSeedDebug = restoreSurvivedSeedsByCompartment(adminId, prefectureMask, sea, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, Math.min(22, targetMunicipalityCount));
|
|
adminDebug.changedAfterSurvivedSeedCompartmentRestore = restoredSeedDebug.changedCells;
|
|
adminDebug.survivedSeedsRestoredByCompartment = restoredSeedDebug.restoredSeeds;
|
|
let finalAreaBySeed = municipalityAreaById(adminId, prefectureMask, sea);
|
|
for (const seedState of seedLifecycle) {
|
|
seedState.area = finalAreaBySeed.get(seedState.id) || 0;
|
|
if (!seedState.protected && seedState.state === "pending" && seedState.area < 25) seedState.state = "absorbed";
|
|
}
|
|
absorbSeedCompartments(adminId, compartmentAssignment.compartments, seedLifecycle);
|
|
activeAdminIds = activeSeedIds(seedLifecycle);
|
|
adminDebug.changedAfterFinalCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
|
|
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 220);
|
|
adminDebug.changedAfterPostCompartmentExclaveRemoval = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
|
|
adminDebug.changedAfterUrbanUnification = lockCompactUrbanAreasToDominantAdmin(adminId, prefectureMask, sea, landuse, populationDensity, 980);
|
|
adminDebug.changedAfterAdminEnclaveRepair = repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, 6);
|
|
adminDebug.changedAfterFinalCompartmentOwnership += enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
|
|
|
|
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
|
|
const satelliteAreas = [];
|
|
(satelliteCities || []).forEach((sat, index) => {
|
|
if (!inside(sat.x, sat.y) || !prefectureMask[indexOf(sat.x, sat.y)]) return;
|
|
const id = adminId[indexOf(sat.x, sat.y)];
|
|
const area = areaById.get(id) || 0;
|
|
const key = sat.name || `satellite-${index}`;
|
|
adminDebug.satelliteMunicipalityAreaByNameOrIndex[key] = area;
|
|
if (sat.municipalityClass === "independentSatelliteMunicipality" && (area < Math.max(120, sat.satelliteMinArea || 0) || ((sat.population || 0) >= 60000 && area < 150))) {
|
|
sat.municipalityClass = "smallTownAttachedToRuralMunicipality";
|
|
adminDebug.satelliteMunicipalitiesTooSmall++;
|
|
return;
|
|
}
|
|
if (sat.municipalityClass === "independentSatelliteMunicipality") {
|
|
satelliteAreas.push(area);
|
|
if (area < 80) adminDebug.satelliteMunicipalitiesTooSmall++;
|
|
}
|
|
});
|
|
adminDebug.averageSatelliteMunicipalityArea = satelliteAreas.length ? satelliteAreas.reduce((sum, value) => sum + value, 0) / satelliteAreas.length : 0;
|
|
adminDebug.minSatelliteMunicipalityArea = satelliteAreas.length ? Math.min(...satelliteAreas) : 0;
|
|
adminDebug.satelliteMunicipalitiesIndependent = satelliteAreas.length;
|
|
const landscapeDebug = applyLandscapeUnitAdminPartition.lastDebug || {};
|
|
Object.assign(adminDebug, landscapeDebug);
|
|
adminDebug.targetNaturalCompartmentCount = compartmentAssignment.debug?.targetNaturalCompartmentCount || targetCompartmentCount;
|
|
adminDebug.naturalCompartmentCount = compartmentAssignment.debug?.naturalCompartmentCount || adminDebug.naturalCompartmentCount || adminDebug.compartmentCount || 0;
|
|
adminDebug.compartmentCount = adminDebug.naturalCompartmentCount;
|
|
adminDebug.averageCompartmentsPerMunicipality = compartmentAssignment.debug?.averageCompartmentsPerMunicipality || adminDebug.averageCompartmentsPerMunicipality || 0;
|
|
adminDebug.singleCompartmentMunicipalityRatio = compartmentAssignment.debug?.singleCompartmentMunicipalityRatio ?? adminDebug.singleCompartmentMunicipalityRatio ?? 0;
|
|
adminDebug.actualMunicipalityCount = new Set([...adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i])).size;
|
|
adminDebug.averageCompartmentsPerMunicipality = adminDebug.actualMunicipalityCount ? adminDebug.naturalCompartmentCount / adminDebug.actualMunicipalityCount : 0;
|
|
adminDebug.survivedSeedCount = seedLifecycle.filter((seed) => seed.state === "survived").length;
|
|
adminDebug.pendingSeedCount = seedLifecycle.filter((seed) => seed.state === "pending").length;
|
|
adminDebug.absorbedSeedCount = seedLifecycle.filter((seed) => seed.state === "absorbed").length;
|
|
adminDebug.finalMunicipalityCount = adminDebug.actualMunicipalityCount;
|
|
adminDebug.finalTinyMunicipalityCount = [...areaById.values()].filter((area) => area > 0 && area < 8).length;
|
|
adminDebug.seedLifecycle = seedLifecycle.map((seed) => ({ id: seed.id, state: seed.state, protected: seed.protected, area: seed.area }));
|
|
adminDebug.borderNaturalBarrierAverage = adminDebug.finalBorderNaturalBarrierAverage ?? 0;
|
|
adminDebug.voronoiLikeRate = adminDebug.voronoiLikeRateAfter ?? 0;
|
|
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "extract borders" });
|
|
const adminBorders = extractAdminBorderSegments(adminId, prefectureMask);
|
|
|
|
|
|
return {
|
|
adminCentersRaw,
|
|
adminId,
|
|
adminBorders,
|
|
adminDebug,
|
|
naturalCompartmentId: compartmentAssignment.compartmentId,
|
|
naturalCompartments: compartmentAssignment.compartments,
|
|
};
|
|
}
|
|
|
|
|
|
export function generateAdminLayout(context) {
|
|
const layout = generateAdminLayoutForMask(context);
|
|
return { ...layout, ...generatePrefecturesFromMunicipalities(context, layout) };
|
|
}
|