map/mapAdminCompartmentRepair.js
2026-05-28 00:30:09 +09:00

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 };
}