744 lines
32 KiB
JavaScript
744 lines
32 KiB
JavaScript
import { INF, MAP_H, MAP_W, SIZE, clamp, hash2, indexOf, inside, xyOf } from "./mapUtils.js";
|
|
import { applyCompartmentOwners, dominantCompartmentOwners } from "./mapAdminShared.js";
|
|
|
|
export function compactWholeCompartmentMunicipalities(adminId, centers, prefectureMask, sea, fields = {}) {
|
|
const activeIds = [...new Set([...adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i]))].sort((a, b) => a - b);
|
|
const idMap = new Map(activeIds.map((id, n) => [id, n]));
|
|
const compactId = new Int16Array(SIZE);
|
|
compactId.fill(-1);
|
|
const stats = activeIds.map(() => ({ sx: 0, sy: 0, count: 0, bestI: -1, bestScore: -INF }));
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
|
|
const nextId = idMap.get(adminId[i]);
|
|
if (nextId === undefined) continue;
|
|
compactId[i] = nextId;
|
|
const [x, y] = xyOf(i);
|
|
const row = stats[nextId];
|
|
row.sx += x;
|
|
row.sy += y;
|
|
row.count++;
|
|
const score = (fields.populationDensity?.[i] || 0) * 2.2 + (fields.plain?.[i] || 0) * 0.25 - (fields.slope?.[i] || 0) * 0.2;
|
|
if (score > row.bestScore) { row.bestScore = score; row.bestI = i; }
|
|
}
|
|
const compactCenters = activeIds.map((oldId, newId) => {
|
|
const current = centers[oldId];
|
|
if (current && inside(current.x, current.y) && compactId[indexOf(current.x, current.y)] === newId) {
|
|
return { ...current, originalAdminId: oldId };
|
|
}
|
|
const row = stats[newId];
|
|
const fallback = row.bestI >= 0 ? xyOf(row.bestI) : [Math.round(row.sx / Math.max(1, row.count)), Math.round(row.sy / Math.max(1, row.count))];
|
|
return {
|
|
...(current || {}),
|
|
x: fallback[0],
|
|
y: fallback[1],
|
|
originalAdminId: oldId,
|
|
generatedOfficePoint: true,
|
|
invisibleLowlandAdminSeed: current?.invisibleLowlandAdminSeed ?? true,
|
|
seedKind: current?.seedKind || "compactedMunicipalityOffice",
|
|
};
|
|
});
|
|
return { adminId: compactId, adminCentersRaw: compactCenters, activeMunicipalityCount: compactCenters.length };
|
|
}
|
|
|
|
export function enforceCompartmentMunicipalityOwnership(adminId, compartmentId, compartments, prefectureMask, sea) {
|
|
if (!compartmentId || !compartments) return 0;
|
|
let changed = 0;
|
|
for (const comp of compartments) {
|
|
if (!comp || !comp.cells?.length) continue;
|
|
const counts = new Map();
|
|
for (const i of comp.cells) {
|
|
if (!prefectureMask[i] || sea[i]) continue;
|
|
const id = adminId[i];
|
|
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
|
|
}
|
|
let bestId = -1, bestCount = -1;
|
|
for (const [id, count] of counts) {
|
|
if (count > bestCount || (count === bestCount && id < bestId)) {
|
|
bestId = id;
|
|
bestCount = count;
|
|
}
|
|
}
|
|
if (bestId < 0) continue;
|
|
for (const i of comp.cells) {
|
|
if (!prefectureMask[i] || sea[i] || adminId[i] === bestId) continue;
|
|
adminId[i] = bestId;
|
|
changed++;
|
|
}
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
|
|
export function mergeSingleCompartmentMunicipalities(adminId, compartments, prefectureMask, sea, minCompartments = 2, maxPasses = 8) {
|
|
if (!compartments?.length) return { changedCells: 0, mergedMunicipalities: 0, remainingSingleCompartmentMunicipalities: 0 };
|
|
let totalChangedCells = 0;
|
|
let mergedMunicipalities = 0;
|
|
let remainingSingles = 0;
|
|
for (let pass = 0; pass < maxPasses; pass++) {
|
|
const owner = dominantCompartmentOwners(compartments, adminId);
|
|
const byOwner = new Map();
|
|
const areaByOwner = new Map();
|
|
for (const unit of compartments) {
|
|
if (!unit || unit.area === 0) continue;
|
|
const id = owner[unit.id];
|
|
if (id < 0) continue;
|
|
if (!byOwner.has(id)) byOwner.set(id, []);
|
|
byOwner.get(id).push(unit);
|
|
areaByOwner.set(id, (areaByOwner.get(id) || 0) + unit.area);
|
|
}
|
|
const singles = [...byOwner.entries()]
|
|
.filter(([, units]) => units.length > 0 && units.length < minCompartments)
|
|
.sort((a, b) => (areaByOwner.get(a[0]) || 0) - (areaByOwner.get(b[0]) || 0) || a[0] - b[0]);
|
|
remainingSingles = singles.length;
|
|
if (!singles.length) break;
|
|
let passChanged = 0;
|
|
for (const [id, units] of singles) {
|
|
// The old rule allowed one natural compartment to become one municipality.
|
|
// That produces many tiny office-only municipalities and makes the hierarchy
|
|
// hard to read. Merge such municipalities into the strongest adjacent owner.
|
|
const neighborScores = new Map();
|
|
for (const unit of units) {
|
|
for (const [neighborId, edge] of unit.adjacent || []) {
|
|
const candidate = owner[neighborId];
|
|
if (candidate < 0 || candidate === id) continue;
|
|
const neighborUnit = compartments[neighborId];
|
|
const shared = edge.count || 1;
|
|
const barrier = edge.target ? edge.target / Math.max(1, shared) : 0;
|
|
const sameLandscape = neighborUnit?.classId === unit.classId ? 0.7 : 0;
|
|
const score = shared * (2.2 - Math.min(1.6, barrier) + sameLandscape) + Math.sqrt(areaByOwner.get(candidate) || 1) * 0.05;
|
|
neighborScores.set(candidate, (neighborScores.get(candidate) || 0) + score);
|
|
}
|
|
}
|
|
let best = -1, bestScore = -INF;
|
|
for (const [candidate, score] of neighborScores) {
|
|
if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; }
|
|
}
|
|
if (best < 0) {
|
|
// One-cell islets have no land adjacency. Attach them to the nearest
|
|
// existing municipality instead of leaving a one-compartment municipality.
|
|
const ux = units.reduce((sum, unit) => sum + (unit.x || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0));
|
|
const uy = units.reduce((sum, unit) => sum + (unit.y || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0));
|
|
let bestDist = INF;
|
|
for (const [candidate, candidateUnits] of byOwner) {
|
|
if (candidate === id || candidateUnits.length < minCompartments) continue;
|
|
for (const unit of candidateUnits) {
|
|
const d = Math.hypot((unit.x || 0) - ux, (unit.y || 0) - uy);
|
|
if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; }
|
|
}
|
|
}
|
|
if (bestDist > 28) best = -1;
|
|
}
|
|
if (best < 0) continue;
|
|
for (const unit of units) {
|
|
for (const i of unit.cells || []) {
|
|
if (!prefectureMask[i] || sea[i]) continue;
|
|
if (adminId[i] !== best) {
|
|
adminId[i] = best;
|
|
passChanged++;
|
|
}
|
|
}
|
|
}
|
|
mergedMunicipalities++;
|
|
}
|
|
totalChangedCells += passChanged;
|
|
if (!passChanged) break;
|
|
}
|
|
const finalOwner = dominantCompartmentOwners(compartments, adminId);
|
|
const finalCounts = new Map();
|
|
for (const unit of compartments) {
|
|
if (!unit || unit.area === 0) continue;
|
|
const id = finalOwner[unit.id];
|
|
if (id >= 0) finalCounts.set(id, (finalCounts.get(id) || 0) + 1);
|
|
}
|
|
remainingSingles = [...finalCounts.values()].filter((count) => count > 0 && count < minCompartments).length;
|
|
return { changedCells: totalChangedCells, mergedMunicipalities, remainingSingleCompartmentMunicipalities: remainingSingles };
|
|
}
|
|
|
|
export function ownerAreaByCompartment(owner, compartments) {
|
|
const area = new Map();
|
|
const count = new Map();
|
|
for (const unit of compartments || []) {
|
|
if (!unit || unit.area === 0) continue;
|
|
const id = owner[unit.id];
|
|
if (id < 0) continue;
|
|
area.set(id, (area.get(id) || 0) + (unit.area || 0));
|
|
count.set(id, (count.get(id) || 0) + 1);
|
|
}
|
|
return { area, count };
|
|
}
|
|
|
|
export function compartmentTouchesOutside(unit, prefectureMask, sea) {
|
|
if (!unit?.cells?.length) return true;
|
|
for (const i of unit.cells) {
|
|
const [x, y] = xyOf(i);
|
|
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) return true;
|
|
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
|
const nx = x + dx, ny = y + dy;
|
|
if (!inside(nx, ny)) return true;
|
|
const ni = indexOf(nx, ny);
|
|
if (!prefectureMask[ni] || sea[ni]) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function bestNeighborOwnerForUnits(units, owner, compartments, allowNearestFallback = false) {
|
|
const { area } = ownerAreaByCompartment(owner, compartments);
|
|
const scores = new Map();
|
|
const blocked = new Set(units.map((unit) => owner[unit.id]));
|
|
for (const unit of units) {
|
|
for (const [neighborId, edge] of unit.adjacent || []) {
|
|
const candidate = owner[neighborId];
|
|
if (candidate < 0 || blocked.has(candidate)) continue;
|
|
const neighbor = compartments[neighborId];
|
|
const shared = edge.count || 1;
|
|
const barrier = (edge.target || 0) / Math.max(1, shared);
|
|
const landscape = neighbor?.classId === unit.classId ? 0.75 : 0;
|
|
const lowland = Math.min(unit.lowlandFitness || 0, neighbor?.lowlandFitness || 0) * 0.5;
|
|
const score = shared * (2.4 + landscape + lowland - Math.min(1.8, barrier)) + Math.sqrt(area.get(candidate) || 1) * 0.04;
|
|
scores.set(candidate, (scores.get(candidate) || 0) + score);
|
|
}
|
|
}
|
|
let best = -1, bestScore = -INF;
|
|
for (const [candidate, score] of scores) {
|
|
if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; }
|
|
}
|
|
if (best >= 0 || !allowNearestFallback) return best;
|
|
const ux = units.reduce((sum, unit) => sum + (unit.x || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0));
|
|
const uy = units.reduce((sum, unit) => sum + (unit.y || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0));
|
|
let bestDist = INF;
|
|
for (const unit of compartments || []) {
|
|
if (!unit || unit.area === 0) continue;
|
|
const candidate = owner[unit.id];
|
|
if (candidate < 0 || blocked.has(candidate)) continue;
|
|
const d = Math.hypot((unit.x || 0) - ux, (unit.y || 0) - uy);
|
|
if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; }
|
|
}
|
|
return bestDist <= 32 ? best : -1;
|
|
}
|
|
|
|
export function mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, minCompartments = 2, maxPasses = 8) {
|
|
let changedCells = 0;
|
|
let mergedMunicipalities = 0;
|
|
let remainingSingleCompartmentMunicipalities = 0;
|
|
for (let pass = 0; pass < maxPasses; pass++) {
|
|
const byOwner = new Map();
|
|
for (const unit of compartments || []) {
|
|
if (!unit || unit.area === 0) continue;
|
|
const id = owner[unit.id];
|
|
if (id < 0) continue;
|
|
if (!byOwner.has(id)) byOwner.set(id, []);
|
|
byOwner.get(id).push(unit);
|
|
}
|
|
const small = [...byOwner.entries()]
|
|
.filter(([, units]) => units.length > 0 && units.length < minCompartments)
|
|
.sort((a, b) => a[1].length - b[1].length || a[0] - b[0]);
|
|
remainingSingleCompartmentMunicipalities = small.length;
|
|
if (!small.length) break;
|
|
let passChanged = 0;
|
|
for (const [id, units] of small) {
|
|
const target = bestNeighborOwnerForUnits(units, owner, compartments, true);
|
|
if (target < 0 || target === id) continue;
|
|
for (const unit of units) {
|
|
if (owner[unit.id] === target) continue;
|
|
owner[unit.id] = target;
|
|
changedCells += unit.area || 0;
|
|
passChanged += unit.area || 0;
|
|
}
|
|
mergedMunicipalities++;
|
|
}
|
|
if (!passChanged) break;
|
|
}
|
|
const counts = ownerAreaByCompartment(owner, compartments).count;
|
|
remainingSingleCompartmentMunicipalities = [...counts.values()].filter((count) => count > 0 && count < minCompartments).length;
|
|
return { changedCells, mergedMunicipalities, remainingSingleCompartmentMunicipalities };
|
|
}
|
|
|
|
export function repairCompartmentOwnerConnectivity(owner, compartments, maxPasses = 8) {
|
|
let changedCells = 0;
|
|
let changedComponents = 0;
|
|
for (let pass = 0; pass < maxPasses; pass++) {
|
|
const ownerIds = [...new Set([...owner].filter((id) => id >= 0))].sort((a, b) => a - b);
|
|
let passChanged = 0;
|
|
for (const id of ownerIds) {
|
|
const members = (compartments || []).filter((unit) => unit && unit.area > 0 && owner[unit.id] === id);
|
|
if (members.length <= 1) continue;
|
|
const memberSet = new Set(members.map((unit) => unit.id));
|
|
const seen = new Set();
|
|
const components = [];
|
|
for (const unit of members) {
|
|
if (seen.has(unit.id)) continue;
|
|
const queue = [unit.id];
|
|
const comp = [];
|
|
seen.add(unit.id);
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
comp.push(compartments[cur]);
|
|
for (const next of compartments[cur]?.adjacent?.keys?.() || []) {
|
|
if (!memberSet.has(next) || seen.has(next)) continue;
|
|
seen.add(next);
|
|
queue.push(next);
|
|
}
|
|
}
|
|
components.push(comp);
|
|
}
|
|
if (components.length <= 1) continue;
|
|
components.sort((a, b) => b.reduce((sum, unit) => sum + (unit.area || 0), 0) - a.reduce((sum, unit) => sum + (unit.area || 0), 0));
|
|
for (const comp of components.slice(1)) {
|
|
const target = bestNeighborOwnerForUnits(comp, owner, compartments, true);
|
|
if (target < 0 || target === id) continue;
|
|
for (const unit of comp) {
|
|
owner[unit.id] = target;
|
|
changedCells += unit.area || 0;
|
|
passChanged += unit.area || 0;
|
|
}
|
|
changedComponents++;
|
|
}
|
|
}
|
|
if (!passChanged) break;
|
|
}
|
|
return { changedCells, changedComponents };
|
|
}
|
|
|
|
export function repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, maxPasses = 6) {
|
|
let changedCells = 0;
|
|
let changedComponents = 0;
|
|
const outsideCache = new Map();
|
|
const touchesOutside = (unit) => {
|
|
if (!outsideCache.has(unit.id)) outsideCache.set(unit.id, compartmentTouchesOutside(unit, prefectureMask, sea));
|
|
return outsideCache.get(unit.id);
|
|
};
|
|
for (let pass = 0; pass < maxPasses; pass++) {
|
|
const ownerIds = [...new Set([...owner].filter((id) => id >= 0))].sort((a, b) => a - b);
|
|
let passChanged = 0;
|
|
for (const id of ownerIds) {
|
|
const members = (compartments || []).filter((unit) => unit && unit.area > 0 && owner[unit.id] === id);
|
|
if (!members.length) continue;
|
|
const memberSet = new Set(members.map((unit) => unit.id));
|
|
const seen = new Set();
|
|
for (const unit of members) {
|
|
if (seen.has(unit.id)) continue;
|
|
const queue = [unit.id];
|
|
const comp = [];
|
|
const boundaryOwners = new Map();
|
|
let outside = false;
|
|
seen.add(unit.id);
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
const curUnit = compartments[cur];
|
|
if (!curUnit) continue;
|
|
comp.push(curUnit);
|
|
if (touchesOutside(curUnit)) outside = true;
|
|
for (const [next, edge] of curUnit.adjacent || []) {
|
|
const nextOwner = owner[next];
|
|
if (nextOwner === id) {
|
|
if (!seen.has(next) && memberSet.has(next)) { seen.add(next); queue.push(next); }
|
|
} else if (nextOwner >= 0) {
|
|
boundaryOwners.set(nextOwner, (boundaryOwners.get(nextOwner) || 0) + (edge.count || 1));
|
|
}
|
|
}
|
|
}
|
|
if (outside || boundaryOwners.size !== 1) continue;
|
|
const [target] = boundaryOwners.keys();
|
|
if (target < 0 || target === id) continue;
|
|
for (const compUnit of comp) {
|
|
owner[compUnit.id] = target;
|
|
changedCells += compUnit.area || 0;
|
|
passChanged += compUnit.area || 0;
|
|
}
|
|
changedComponents++;
|
|
}
|
|
}
|
|
if (!passChanged) break;
|
|
}
|
|
return { changedCells, changedComponents };
|
|
}
|
|
|
|
export function lockCompactUrbanCompartmentsToDominantOwner(owner, compartments, maxCells = 1100) {
|
|
const isUrbanUnit = (unit) => unit && unit.area > 0 && (
|
|
unit.classId <= 3 ||
|
|
(unit.urbanWeight || 0) >= 0.34 ||
|
|
((unit.urbanWeight || 0) >= 0.22 && (unit.lowlandFitness || 0) >= 0.34)
|
|
);
|
|
const seen = new Set();
|
|
let changedCells = 0;
|
|
let unifiedComponents = 0;
|
|
for (const start of compartments || []) {
|
|
if (!isUrbanUnit(start) || seen.has(start.id)) continue;
|
|
const queue = [start.id];
|
|
const comp = [];
|
|
seen.add(start.id);
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
const unit = compartments[cur];
|
|
if (!isUrbanUnit(unit)) continue;
|
|
comp.push(unit);
|
|
for (const next of unit.adjacent?.keys?.() || []) {
|
|
if (seen.has(next) || !isUrbanUnit(compartments[next])) continue;
|
|
seen.add(next);
|
|
queue.push(next);
|
|
}
|
|
}
|
|
const totalArea = comp.reduce((sum, unit) => sum + (unit.area || 0), 0);
|
|
if (comp.length <= 1 || totalArea <= 0 || totalArea > maxCells) continue;
|
|
const ownerScore = new Map();
|
|
for (const unit of comp) {
|
|
const id = owner[unit.id];
|
|
if (id < 0) continue;
|
|
const score = (unit.area || 0) * (1 + (unit.urbanWeight || 0) * 1.8);
|
|
ownerScore.set(id, (ownerScore.get(id) || 0) + score);
|
|
}
|
|
let best = -1, bestScore = -INF;
|
|
for (const [id, score] of ownerScore) if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; }
|
|
if (best < 0) continue;
|
|
let localChanged = 0;
|
|
for (const unit of comp) {
|
|
if (owner[unit.id] === best) continue;
|
|
owner[unit.id] = best;
|
|
localChanged += unit.area || 0;
|
|
}
|
|
if (localChanged > 0) {
|
|
changedCells += localChanged;
|
|
unifiedComponents++;
|
|
}
|
|
}
|
|
return { changedCells, unifiedComponents };
|
|
}
|
|
|
|
|
|
export function lockCityMetroCompartmentsToSingleMunicipality(owner, compartments, fields = {}) {
|
|
const { modernCities = [] } = fields;
|
|
if (!owner || !compartments?.length || !modernCities?.length) return { changedCells: 0, unifiedCities: 0 };
|
|
let changedCells = 0;
|
|
let unifiedCities = 0;
|
|
const urbanUnit = (unit) => unit && unit.area > 0 && (
|
|
unit.classId <= 3 ||
|
|
(unit.urbanWeight || 0) >= 0.24 ||
|
|
((unit.urbanWeight || 0) >= 0.16 && (unit.lowlandFitness || 0) >= 0.40)
|
|
);
|
|
for (const city of modernCities) {
|
|
if (!city || !Number.isFinite(city.x) || !Number.isFinite(city.y) || (city.population || 0) < 18000) continue;
|
|
const radius = clamp(
|
|
(city.urbanRadius || 8) * ((city.population || 0) >= 200000 ? 1.95 : (city.population || 0) >= 80000 ? 1.65 : 1.35),
|
|
8,
|
|
(city.population || 0) >= 200000 ? 34 : 24
|
|
);
|
|
const units = [];
|
|
for (const unit of compartments) {
|
|
if (!urbanUnit(unit)) continue;
|
|
const d = Math.hypot((unit.x || 0) - city.x, (unit.y || 0) - city.y);
|
|
if (d > radius) continue;
|
|
const weight = (unit.area || 1) *
|
|
(1 + (unit.urbanWeight || 0) * 2.4 + (unit.lowlandFitness || 0) * 0.55) *
|
|
Math.max(0.20, 1 - d / Math.max(1, radius) * 0.58);
|
|
units.push({ unit, weight, d });
|
|
}
|
|
if (units.length <= 1) continue;
|
|
const totalArea = units.reduce((sum, row) => sum + (row.unit.area || 0), 0);
|
|
// Large multi-core conurbations may legitimately contain multiple municipalities.
|
|
// This pass targets compact urban areas that visually read as one city.
|
|
const maxArea = (city.population || 0) >= 200000 ? 1800 : 900;
|
|
if (totalArea > maxArea) continue;
|
|
const ownerScore = new Map();
|
|
for (const row of units) {
|
|
const id = owner[row.unit.id];
|
|
if (id < 0) continue;
|
|
ownerScore.set(id, (ownerScore.get(id) || 0) + row.weight);
|
|
}
|
|
if (ownerScore.size <= 1) continue;
|
|
let best = -1, bestScore = -INF, totalScore = 0;
|
|
for (const [id, score] of ownerScore) {
|
|
totalScore += score;
|
|
if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; }
|
|
}
|
|
if (best < 0 || bestScore / Math.max(1, totalScore) < 0.28) continue;
|
|
let localChanged = 0;
|
|
for (const row of units) {
|
|
if (owner[row.unit.id] === best) continue;
|
|
owner[row.unit.id] = best;
|
|
localChanged += row.unit.area || 0;
|
|
}
|
|
if (localChanged > 0) {
|
|
changedCells += localChanged;
|
|
unifiedCities++;
|
|
}
|
|
}
|
|
return { changedCells, unifiedCities };
|
|
}
|
|
|
|
export function enforceSimpleAdministrativeHierarchy(adminId, compartments, prefectureMask, sea, fields = {}) {
|
|
const owner = dominantCompartmentOwners(compartments, adminId);
|
|
const urban = lockCompactUrbanCompartmentsToDominantOwner(owner, compartments, fields.maxUrbanClusterCells || 1100);
|
|
const metro = lockCityMetroCompartmentsToSingleMunicipality(owner, compartments, fields);
|
|
const connectivity1 = repairCompartmentOwnerConnectivity(owner, compartments, 8);
|
|
const enclave1 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 6);
|
|
const singleMerge = mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, fields.minCompartmentsPerMunicipality || 2, 8);
|
|
const connectivity2 = repairCompartmentOwnerConnectivity(owner, compartments, 8);
|
|
const enclave2 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 6);
|
|
const singleMerge2 = mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, fields.minCompartmentsPerMunicipality || 2, 4);
|
|
const connectivity3 = repairCompartmentOwnerConnectivity(owner, compartments, 4);
|
|
const enclave3 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 4);
|
|
applyCompartmentOwners(adminId, compartments, owner);
|
|
const counts = ownerAreaByCompartment(owner, compartments).count;
|
|
return {
|
|
changedAfterUrbanUnification: urban.changedCells + metro.changedCells,
|
|
urbanComponentsUnified: urban.unifiedComponents,
|
|
changedAfterCityMetroMunicipalityUnification: metro.changedCells,
|
|
cityMetroMunicipalitiesUnified: metro.unifiedCities,
|
|
changedAfterCompartmentConnectivity: connectivity1.changedCells + connectivity2.changedCells + connectivity3.changedCells,
|
|
disconnectedCompartmentComponentsMerged: connectivity1.changedComponents + connectivity2.changedComponents + connectivity3.changedComponents,
|
|
changedAfterCompartmentEnclaveRepair: enclave1.changedCells + enclave2.changedCells + enclave3.changedCells,
|
|
compartmentEnclaveComponentsMerged: enclave1.changedComponents + enclave2.changedComponents + enclave3.changedComponents,
|
|
changedAfterSingleCompartmentMunicipalityMerge: singleMerge.changedCells + singleMerge2.changedCells,
|
|
singleCompartmentMunicipalitiesMerged: singleMerge.mergedMunicipalities + singleMerge2.mergedMunicipalities,
|
|
remainingSingleCompartmentMunicipalities: [...counts.values()].filter((count) => count > 0 && count < (fields.minCompartmentsPerMunicipality || 2)).length,
|
|
};
|
|
}
|
|
|
|
|
|
export function repairPrefectureCellEnclavesFromGrid(adminId, owner, prefectureMask, sea, maxPasses = 5) {
|
|
let changed = 0;
|
|
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
|
|
for (let pass = 0; pass < maxPasses; pass++) {
|
|
const prefId = new Int16Array(SIZE);
|
|
prefId.fill(-1);
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
|
|
prefId[i] = owner.get(adminId[i]) ?? -1;
|
|
}
|
|
const seen = new Uint8Array(SIZE);
|
|
let passChanged = 0;
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (seen[i] || prefId[i] < 0) continue;
|
|
const id = prefId[i];
|
|
const queue = [i];
|
|
const comp = [];
|
|
seen[i] = 1;
|
|
let touchesOutside = false;
|
|
const boundaryCounts = new Map();
|
|
const adminCounts = new Map();
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
comp.push(cur);
|
|
const aid = adminId[cur];
|
|
if (aid >= 0) adminCounts.set(aid, (adminCounts.get(aid) || 0) + 1);
|
|
const [x, y] = xyOf(cur);
|
|
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesOutside = true;
|
|
for (const [dx, dy] of dirs) {
|
|
const nx = x + dx, ny = y + dy;
|
|
if (!inside(nx, ny)) { touchesOutside = true; continue; }
|
|
const ni = indexOf(nx, ny);
|
|
if (!prefectureMask[ni] || sea[ni]) { touchesOutside = true; continue; }
|
|
const nid = prefId[ni];
|
|
if (nid === id) {
|
|
if (!seen[ni]) { seen[ni] = 1; queue.push(ni); }
|
|
} else if (nid >= 0) {
|
|
boundaryCounts.set(nid, (boundaryCounts.get(nid) || 0) + 1);
|
|
}
|
|
}
|
|
}
|
|
if (touchesOutside || boundaryCounts.size !== 1) continue;
|
|
const [targetPref] = boundaryCounts.keys();
|
|
if (targetPref < 0 || targetPref === id) continue;
|
|
for (const aid of adminCounts.keys()) {
|
|
if (owner.get(aid) !== targetPref) { owner.set(aid, targetPref); passChanged++; }
|
|
}
|
|
}
|
|
changed += passChanged;
|
|
if (!passChanged) break;
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
export function repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, maxPasses = 5) {
|
|
let changed = 0;
|
|
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
|
|
for (let pass = 0; pass < maxPasses; pass++) {
|
|
const seen = new Uint8Array(SIZE);
|
|
let passChanged = 0;
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (seen[i] || !prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
|
|
const id = adminId[i];
|
|
const queue = [i];
|
|
const comp = [];
|
|
seen[i] = 1;
|
|
let touchesOutside = false;
|
|
const boundaryCounts = new Map();
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
comp.push(cur);
|
|
const [x, y] = xyOf(cur);
|
|
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesOutside = true;
|
|
for (const [dx, dy] of dirs) {
|
|
const nx = x + dx, ny = y + dy;
|
|
if (!inside(nx, ny)) { touchesOutside = true; continue; }
|
|
const ni = indexOf(nx, ny);
|
|
if (!prefectureMask[ni] || sea[ni]) { touchesOutside = true; continue; }
|
|
const nid = adminId[ni];
|
|
if (nid === id) {
|
|
if (!seen[ni]) { seen[ni] = 1; queue.push(ni); }
|
|
} else if (nid >= 0) {
|
|
boundaryCounts.set(nid, (boundaryCounts.get(nid) || 0) + 1);
|
|
}
|
|
}
|
|
}
|
|
if (touchesOutside || boundaryCounts.size !== 1) continue;
|
|
const [targetId] = boundaryCounts.keys();
|
|
if (targetId < 0 || targetId === id) continue;
|
|
for (const ci of comp) adminId[ci] = targetId;
|
|
passChanged += comp.length;
|
|
}
|
|
changed += passChanged;
|
|
if (!passChanged) break;
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
export function lockCompactUrbanAreasToDominantAdmin(adminId, prefectureMask, sea, landuse, populationDensity, maxCells = 950) {
|
|
if (!landuse || !populationDensity) return 0;
|
|
const seen = new Uint8Array(SIZE);
|
|
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
|
|
let changed = 0;
|
|
const isUrban = (i) => !sea[i] && prefectureMask[i] && (populationDensity[i] > 0.34 || [2, 3, 4, 7, 8].includes(landuse[i]));
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (seen[i] || !isUrban(i) || adminId[i] < 0) continue;
|
|
const queue = [i];
|
|
const comp = [];
|
|
seen[i] = 1;
|
|
const counts = new Map();
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
comp.push(cur);
|
|
const id = adminId[cur];
|
|
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1 + (populationDensity[cur] || 0) * 2.0);
|
|
const [x, y] = xyOf(cur);
|
|
for (const [dx, dy] of dirs) {
|
|
const nx = x + dx, ny = y + dy;
|
|
if (!inside(nx, ny)) continue;
|
|
const ni = indexOf(nx, ny);
|
|
if (seen[ni] || !isUrban(ni)) continue;
|
|
seen[ni] = 1;
|
|
queue.push(ni);
|
|
}
|
|
}
|
|
if (comp.length < 8 || comp.length > maxCells || counts.size <= 1 || counts.size > 7) continue;
|
|
let best = -1, bestScore = -INF;
|
|
let total = 0;
|
|
for (const [id, score] of counts) {
|
|
total += score;
|
|
if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; }
|
|
}
|
|
if (best < 0 || bestScore / Math.max(1, total) < 0.38) continue;
|
|
for (const ci of comp) {
|
|
if (adminId[ci] !== best) { adminId[ci] = best; changed++; }
|
|
}
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
|
|
export function splitOversizedCompartmentMunicipalities(adminId, centers, compartments, prefectureMask, sea, fields = {}, seed = 0) {
|
|
if (!compartments?.length) return { changedCells: 0, splitMunicipalities: 0, addedCenters: 0 };
|
|
const compOwner = new Int16Array(compartments.length);
|
|
compOwner.fill(-1);
|
|
for (const comp of compartments) {
|
|
if (!comp || !comp.cells?.length) continue;
|
|
const counts = new Map();
|
|
for (const i of comp.cells) {
|
|
if (!prefectureMask[i] || sea[i]) continue;
|
|
const id = adminId[i];
|
|
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
|
|
}
|
|
let best = -1, bestCount = -1;
|
|
for (const [id, count] of counts) if (count > bestCount || (count === bestCount && id < best)) { best = id; bestCount = count; }
|
|
compOwner[comp.id] = best;
|
|
}
|
|
const byOwner = new Map();
|
|
for (const comp of compartments) {
|
|
if (!comp || !comp.cells?.length) continue;
|
|
const owner = compOwner[comp.id];
|
|
if (owner < 0) continue;
|
|
if (!byOwner.has(owner)) byOwner.set(owner, []);
|
|
byOwner.get(owner).push(comp);
|
|
}
|
|
const areas = [...byOwner.values()].map((list) => list.reduce((sum, comp) => sum + comp.area, 0)).sort((a, b) => a - b);
|
|
if (!areas.length) return { changedCells: 0, splitMunicipalities: 0, addedCenters: 0 };
|
|
const median = areas[Math.floor(areas.length / 2)] || 1;
|
|
const total = areas.reduce((sum, value) => sum + value, 0);
|
|
const maxArea = Math.max(360, Math.min(total * 0.11, Math.max(median * 2.65, total / Math.max(12, Math.round(total / 520)))));
|
|
let changedCells = 0;
|
|
let splitMunicipalities = 0;
|
|
let addedCenters = 0;
|
|
const elevation = fields.elevation;
|
|
const slope = fields.slope;
|
|
const ridgeField = fields.ridgeField;
|
|
const plain = fields.plain;
|
|
const agriculture = fields.agriculture;
|
|
const basinField = fields.basinField;
|
|
const coastalLowland = fields.coastalLowland;
|
|
const populationDensity = fields.populationDensity;
|
|
for (const [owner, list] of [...byOwner.entries()].sort((a, b) => a[0] - b[0])) {
|
|
const area = list.reduce((sum, comp) => sum + comp.area, 0);
|
|
if (area <= maxArea || list.length < 4) continue;
|
|
const desiredParts = clamp(Math.ceil(area / Math.max(1, maxArea)), 2, 9);
|
|
const splitCount = desiredParts - 1;
|
|
if (splitCount <= 0) continue;
|
|
const candidates = list.map((comp) => {
|
|
let sx = 0, sy = 0, n = 0, score = 0, bestI = -1, bestScore = -INF;
|
|
for (const i of comp.cells) {
|
|
if (!prefectureMask[i] || sea[i]) continue;
|
|
const [x, y] = xyOf(i);
|
|
sx += x; sy += y; n++;
|
|
const cellScore =
|
|
(plain?.[i] || 0) * 0.22 +
|
|
(agriculture?.[i] || 0) * 0.24 +
|
|
(basinField?.[i] || 0) * 0.14 +
|
|
(coastalLowland?.[i] || 0) * 0.10 +
|
|
(populationDensity?.[i] || 0) * 0.24 -
|
|
(slope?.[i] || 0) * 0.20 -
|
|
(ridgeField?.[i] || 0) * 0.18 -
|
|
Math.max(0, (elevation?.[i] || 0) - 0.62) * 0.38;
|
|
score += cellScore;
|
|
if (cellScore > bestScore) { bestScore = cellScore; bestI = i; }
|
|
}
|
|
const [x, y] = bestI >= 0 ? xyOf(bestI) : [Math.round(sx / Math.max(1, n)), Math.round(sy / Math.max(1, n))];
|
|
return { comp, x, y, score: score / Math.max(1, n) + Math.sqrt(comp.area) * 0.025 + hash2(seed + owner, comp.id) * 0.03 };
|
|
}).sort((a, b) => b.score - a.score || a.comp.id - b.comp.id);
|
|
const newSeeds = [];
|
|
for (const cand of candidates) {
|
|
if (newSeeds.length >= splitCount) break;
|
|
if (newSeeds.every((s) => Math.hypot(s.x - cand.x, s.y - cand.y) >= 9)) newSeeds.push(cand);
|
|
}
|
|
if (!newSeeds.length) continue;
|
|
const seedIds = newSeeds.map((cand) => {
|
|
const id = centers.length;
|
|
centers.push({ x: cand.x, y: cand.y, score: cand.score, invisibleLowlandAdminSeed: true, seedKind: "oversizedMunicipalitySplit", splitFromAdminId: owner });
|
|
addedCenters++;
|
|
return id;
|
|
});
|
|
const oldCenter = centers[owner] || candidates[0] || { x: list[0].x || 0, y: list[0].y || 0, score: 0 };
|
|
const owners = [{ id: owner, x: oldCenter.x, y: oldCenter.y, score: oldCenter.score || 0 }, ...newSeeds.map((cand, k) => ({ id: seedIds[k], x: cand.x, y: cand.y, score: cand.score }))];
|
|
const targetArea = area / Math.max(1, owners.length);
|
|
const claimedArea = new Map(owners.map((entry) => [entry.id, 0]));
|
|
for (const cand of candidates) {
|
|
let bestSeed = owner;
|
|
let bestCost = INF;
|
|
for (const entry of owners) {
|
|
const d = Math.hypot(cand.x - entry.x, cand.y - entry.y);
|
|
const pressure = Math.max(0, ((claimedArea.get(entry.id) || 0) + cand.comp.area - targetArea * 1.25) / Math.max(1, targetArea));
|
|
const cost = d + pressure * 18 - cand.score * 2.5 + hash2(entry.id, cand.comp.id) * 0.05;
|
|
if (cost < bestCost) { bestCost = cost; bestSeed = entry.id; }
|
|
}
|
|
claimedArea.set(bestSeed, (claimedArea.get(bestSeed) || 0) + cand.comp.area);
|
|
if (bestSeed === owner) continue;
|
|
for (const i of cand.comp.cells) {
|
|
if (!prefectureMask[i] || sea[i]) continue;
|
|
if (adminId[i] !== bestSeed) { adminId[i] = bestSeed; changedCells++; }
|
|
}
|
|
}
|
|
splitMunicipalities++;
|
|
}
|
|
return { changedCells, splitMunicipalities, addedCenters, maxArea };
|
|
}
|
|
|
|
|