965 lines
49 KiB
JavaScript
965 lines
49 KiB
JavaScript
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, xyOf } from "./mapUtils.js";
|
|
|
|
export function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity, settlementFeatures = [], geography = {}) {
|
|
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, habitability: 0, accessibility: 0, centrality: 0, boundaryAvoidance: 0, adminBoundaryPreference: 0, geographicBarrier: 0 });
|
|
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.habitability += geography.habitability?.[i] || 0;
|
|
node.accessibility += geography.accessibility?.[i] || 0;
|
|
node.centrality += geography.centrality?.[i] || 0;
|
|
node.boundaryAvoidance += geography.boundaryAvoidance?.[i] || 0;
|
|
node.adminBoundaryPreference += geography.adminBoundaryPreference?.[i] || 0;
|
|
node.geographicBarrier += geography.geographicBarrier?.[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, adminBoundaryPreference: 0, boundaryAvoidance: 0, centrality: 0, accessibility: 0 };
|
|
edge.count++;
|
|
edge.barrier += ((naturalBarrierScore?.[i] || 0) + (naturalBarrierScore?.[ni] || 0)) * 0.5;
|
|
edge.adminBoundaryPreference += ((geography.adminBoundaryPreference?.[i] || 0) + (geography.adminBoundaryPreference?.[ni] || 0)) * 0.5;
|
|
edge.boundaryAvoidance += ((geography.boundaryAvoidance?.[i] || 0) + (geography.boundaryAvoidance?.[ni] || 0)) * 0.5;
|
|
edge.centrality += ((geography.centrality?.[i] || 0) + (geography.centrality?.[ni] || 0)) * 0.5;
|
|
edge.accessibility += ((geography.accessibility?.[i] || 0) + (geography.accessibility?.[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.habitability /= Math.max(1, node.area);
|
|
node.accessibility /= Math.max(1, node.area);
|
|
node.centrality /= Math.max(1, node.area);
|
|
node.boundaryAvoidance /= Math.max(1, node.area);
|
|
node.adminBoundaryPreference /= Math.max(1, node.area);
|
|
node.geographicBarrier /= Math.max(1, node.area);
|
|
node.adjacent = new Map();
|
|
}
|
|
for (const edge of edges.values()) {
|
|
edge.barrier /= Math.max(1, edge.count);
|
|
edge.adminBoundaryPreference /= Math.max(1, edge.count);
|
|
edge.boundaryAvoidance /= Math.max(1, edge.count);
|
|
edge.centrality /= Math.max(1, edge.count);
|
|
edge.accessibility /= 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 = Math.max(0.22,
|
|
1.0 +
|
|
edge.barrier * 7.2 +
|
|
edge.adminBoundaryPreference * 6.4 -
|
|
edge.boundaryAvoidance * 2.6 -
|
|
edge.centrality * 1.4 -
|
|
edge.accessibility * 0.9 +
|
|
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 };
|
|
}
|
|
|
|
export 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) >= 90000 || node.majorCityCount > 0 || (node.settlementPopulation || 0) >= 140000)
|
|
.sort((a, b) => ((b.cityPopulation || 0) + (b.settlementPopulation || 0) * 0.35) - ((a.cityPopulation || 0) + (a.settlementPopulation || 0) * 0.35) || b.population - a.population || a.id - b.id);
|
|
// Prefectures should grow outward from municipalities that already look like
|
|
// future prefectural capitals. This keeps the generated prefecture shape from
|
|
// starting at arbitrary peripheral municipalities.
|
|
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 livingCoreBonus = (node.centrality || 0) * 9.5 + (node.accessibility || 0) * 4.0 + (node.habitability || 0) * 2.0 - (node.geographicBarrier || 0) * 3.8;
|
|
const score = nearest * (1.0 + hash2(seed, node.id) * 0.08) + Math.sqrt(node.area) * 0.12 + node.population * 0.24 + capitalBonus + livingCoreBonus;
|
|
if (score > bestScore) { bestScore = score; best = node; }
|
|
}
|
|
if (!best) break;
|
|
seeds.push(best);
|
|
}
|
|
seeds.minMunicipalitiesPerPrefecture = minMunicipalitiesPerPrefecture;
|
|
return seeds;
|
|
}
|
|
|
|
|
|
export function chooseSecondStagePrefectureMunicipalitySeeds(nodes, initialOwner, initialSeeds, context = {}) {
|
|
const { seed = 0, allowOffscreenCapitals = true } = context;
|
|
const prefIds = [...new Set(initialOwner.values())].sort((a, b) => a - b);
|
|
const seeds = [];
|
|
const usedNodeIds = new Set();
|
|
const offscreenPrefectureSeeds = [];
|
|
function scoreNode(node, prefId) {
|
|
if (!node) return -INF;
|
|
const populationCore = (node.cityPopulation || 0) * 1.55 + (node.settlementPopulation || 0) * 0.42 + (node.population || 0) * 900;
|
|
const civicCore = (node.majorCityCount || 0) * 420000 + (node.centrality || 0) * 76000 + (node.accessibility || 0) * 52000 + (node.habitability || 0) * 18000;
|
|
const terrainPenalty = (node.geographicBarrier || 0) * 62000;
|
|
const edgeBonus = allowOffscreenCapitals && node.touchesOutside ? 95000 : 0;
|
|
const jitter = hash2(seed + 331, node.id * 17 + prefId * 41) * 2500;
|
|
return populationCore + civicCore + edgeBonus - terrainPenalty + jitter;
|
|
}
|
|
for (const prefId of prefIds) {
|
|
const members = [...nodes.values()].filter((node) => initialOwner.get(node.id) === prefId);
|
|
if (!members.length) continue;
|
|
const insideCapitalCandidate = members
|
|
.filter((node) => (node.cityPopulation || 0) >= 45000 || (node.settlementPopulation || 0) >= 70000 || (node.majorCityCount || 0) > 0)
|
|
.sort((a, b) => scoreNode(b, prefId) - scoreNode(a, prefId))[0] || null;
|
|
const edgeCandidate = allowOffscreenCapitals
|
|
? members
|
|
.filter((node) => node.touchesOutside && node.area >= 8)
|
|
.sort((a, b) => scoreNode(b, prefId) - scoreNode(a, prefId))[0] || null
|
|
: null;
|
|
const useOffscreen = edgeCandidate && (!insideCapitalCandidate || scoreNode(edgeCandidate, prefId) > scoreNode(insideCapitalCandidate, prefId) + 35000);
|
|
const chosen = useOffscreen ? edgeCandidate : (insideCapitalCandidate || edgeCandidate || members.sort((a, b) => scoreNode(b, prefId) - scoreNode(a, prefId))[0]);
|
|
if (chosen && !usedNodeIds.has(chosen.id)) {
|
|
seeds.push(chosen);
|
|
usedNodeIds.add(chosen.id);
|
|
if (useOffscreen) offscreenPrefectureSeeds.push({ prefId, nodeId: chosen.id, x: Math.round(chosen.x), y: Math.round(chosen.y) });
|
|
}
|
|
}
|
|
if (!seeds.length) seeds.push(...(initialSeeds || []).filter(Boolean));
|
|
seeds.minMunicipalitiesPerPrefecture = initialSeeds?.minMunicipalitiesPerPrefecture || 14;
|
|
seeds.offscreenPrefectureSeeds = offscreenPrefectureSeeds;
|
|
seeds.secondStage = true;
|
|
return seeds;
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
|
|
export function chooseSecondStagePrefectureSeeds(nodes, firstOwner, originalSeeds = [], seed = 0) {
|
|
const prefIds = [...new Set(firstOwner.values())].filter((id) => id >= 0).sort((a, b) => a - b);
|
|
const used = new Set();
|
|
const seeds = [];
|
|
const offscreenCapitalPrefectures = [];
|
|
for (const prefId of prefIds) {
|
|
const members = [...nodes.values()].filter((node) => firstOwner.get(node.id) === prefId && node.area > 0);
|
|
if (!members.length) continue;
|
|
const membersSortedByCapital = members.slice().sort((a, b) => {
|
|
const as = (a.cityPopulation || 0) * 1.45 + (a.settlementPopulation || 0) * 0.36 + (a.population || 0) * 900 + (a.centrality || 0) * 5200 + (a.accessibility || 0) * 2600 + Math.sqrt(a.area || 1) * 45;
|
|
const bs = (b.cityPopulation || 0) * 1.45 + (b.settlementPopulation || 0) * 0.36 + (b.population || 0) * 900 + (b.centrality || 0) * 5200 + (b.accessibility || 0) * 2600 + Math.sqrt(b.area || 1) * 45;
|
|
return bs - as || a.id - b.id;
|
|
});
|
|
const capitalCandidate = membersSortedByCapital.find((node) => !used.has(node.id) && ((node.cityPopulation || 0) >= 90000 || (node.settlementPopulation || 0) >= 130000 || (node.population || 0) >= 15));
|
|
let chosen = capitalCandidate;
|
|
if (!chosen) {
|
|
const edgeCandidate = members
|
|
.filter((node) => !used.has(node.id) && node.touchesOutside)
|
|
.sort((a, b) => {
|
|
const as = Math.sqrt(a.area || 1) * 0.7 + (a.habitability || 0) * 8 + (a.accessibility || 0) * 6 - (a.geographicBarrier || 0) * 4 + hash2(seed + 7100, a.id) * 0.2;
|
|
const bs = Math.sqrt(b.area || 1) * 0.7 + (b.habitability || 0) * 8 + (b.accessibility || 0) * 6 - (b.geographicBarrier || 0) * 4 + hash2(seed + 7100, b.id) * 0.2;
|
|
return bs - as || a.id - b.id;
|
|
})[0];
|
|
if (edgeCandidate) {
|
|
chosen = edgeCandidate;
|
|
offscreenCapitalPrefectures.push(prefId);
|
|
}
|
|
}
|
|
if (!chosen) chosen = membersSortedByCapital.find((node) => !used.has(node.id)) || membersSortedByCapital[0];
|
|
if (chosen) {
|
|
used.add(chosen.id);
|
|
seeds.push(chosen);
|
|
}
|
|
}
|
|
// If merges removed too many first-stage regions, preserve count by adding high-score unused capital-like nodes.
|
|
for (const node of [...nodes.values()].sort((a, b) => ((b.cityPopulation || 0) + (b.settlementPopulation || 0) * 0.25 + b.area * 5) - ((a.cityPopulation || 0) + (a.settlementPopulation || 0) * 0.25 + a.area * 5) || a.id - b.id)) {
|
|
if (seeds.length >= Math.max(1, originalSeeds.length || seeds.length)) break;
|
|
if (used.has(node.id)) continue;
|
|
used.add(node.id);
|
|
seeds.push(node);
|
|
}
|
|
seeds.minMunicipalitiesPerPrefecture = originalSeeds.minMunicipalitiesPerPrefecture || 14;
|
|
seeds.offscreenCapitalPrefectures = offscreenCapitalPrefectures;
|
|
return seeds;
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
|
|
export function lockLivingSphereMunicipalitiesToSinglePrefecture(owner, nodes, adminId, context) {
|
|
const { prefectureMask, sea, modernCities = [], markets = [], ports = [] } = context || {};
|
|
if (!owner || !nodes || !adminId) return 0;
|
|
const hubs = [
|
|
...(modernCities || []).filter((p) => p && ((p.population || 0) >= 70000 || p.isPrefecturalCapital || p.isRegionalCapital)),
|
|
...(markets || []).filter((p) => p && (p.population || 0) >= 26000),
|
|
...(ports || []).filter((p) => p && (p.portClass === "major" || p.portClass === "regional")),
|
|
];
|
|
let changed = 0;
|
|
for (const hub of hubs) {
|
|
if (!hub || !inside(hub.x, hub.y)) continue;
|
|
const startCell = indexOf(hub.x, hub.y);
|
|
if (!prefectureMask[startCell] || sea[startCell]) continue;
|
|
const startAdmin = adminId[startCell];
|
|
if (startAdmin < 0 || !nodes.has(startAdmin)) continue;
|
|
const targetPref = owner.get(startAdmin);
|
|
if (targetPref < 0) continue;
|
|
const population = hub.population || (hub.portClass === "major" ? 140000 : 42000);
|
|
const maxCost = (population >= 260000 || hub.isPrefecturalCapital) ? 34 : population >= 120000 ? 25 : 17;
|
|
const maxDistance = clamp(10 + Math.sqrt(population) / 42, 14, 38);
|
|
const heap = new MinHeap();
|
|
const best = new Map([[startAdmin, 0]]);
|
|
heap.push({ i: startAdmin, f: 0 });
|
|
const candidates = new Set([startAdmin]);
|
|
while (heap.length) {
|
|
const cur = heap.pop();
|
|
if (!cur || cur.f > (best.get(cur.i) ?? INF) + 1e-5 || cur.f > maxCost) continue;
|
|
const node = nodes.get(cur.i);
|
|
if (!node) continue;
|
|
if (Math.hypot(node.x - hub.x, node.y - hub.y) <= maxDistance) candidates.add(cur.i);
|
|
for (const [nextId, edge] of node.adjacent || []) {
|
|
const next = nodes.get(nextId);
|
|
if (!next) continue;
|
|
if (Math.hypot(next.x - hub.x, next.y - hub.y) > maxDistance * 1.25) continue;
|
|
if ((edge.barrier || 0) > 0.68 && (edge.adminBoundaryPreference || 0) > 0.44) continue;
|
|
const lifeContinuity = (edge.boundaryAvoidance || 0) * 1.7 + (edge.accessibility || 0) * 0.9 + (edge.centrality || 0) * 0.9;
|
|
const stepCost = Math.max(0.35, (edge.crossingCost ?? 1.5) - lifeContinuity);
|
|
const nd = cur.f + stepCost;
|
|
if (nd < (best.get(nextId) ?? INF)) {
|
|
best.set(nextId, nd);
|
|
heap.push({ i: nextId, f: nd });
|
|
}
|
|
}
|
|
}
|
|
if (candidates.size <= 1) continue;
|
|
let totalWeight = 0;
|
|
for (const id of candidates) {
|
|
const node = nodes.get(id);
|
|
totalWeight += Math.max(1, (node?.centrality || 0) * 8 + (node?.accessibility || 0) * 5 + Math.sqrt(node?.area || 1) * 0.18);
|
|
}
|
|
if (totalWeight < 5.5) continue;
|
|
for (const id of candidates) {
|
|
if (id === startAdmin || owner.get(id) === targetPref) continue;
|
|
const node = nodes.get(id);
|
|
if (!node) continue;
|
|
if ((node.cityPopulation || 0) >= 160000 && Math.hypot(node.x - hub.x, node.y - hub.y) > 8) continue;
|
|
owner.set(id, targetPref);
|
|
changed++;
|
|
}
|
|
}
|
|
if (changed) repairPrefectureMunicipalityConnectivity(nodes, owner);
|
|
return changed;
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
|
|
export 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;
|
|
}
|
|
|
|
|
|
export function prefectureMunicipalityCounts(owner) {
|
|
const counts = new Map();
|
|
for (const pref of owner.values()) counts.set(pref, (counts.get(pref) || 0) + 1);
|
|
return counts;
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
|
|
export 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;
|
|
}
|
|
|
|
|
|
export function relaxHighBoundaryShareMunicipalities(nodes, owner, options = {}) {
|
|
const threshold = options.threshold ?? 0.50;
|
|
const maxPasses = options.maxPasses ?? 5;
|
|
const minSharedToTarget = options.minSharedToTarget ?? 2;
|
|
let changed = 0;
|
|
for (let pass = 0; pass < maxPasses; pass++) {
|
|
let passChanged = 0;
|
|
const counts = prefectureMunicipalityCounts(owner);
|
|
const candidates = [];
|
|
for (const [id, pref] of owner) {
|
|
if (pref === undefined || pref < 0) continue;
|
|
const node = nodes.get(id);
|
|
if (!node || !node.adjacent?.size) continue;
|
|
if ((node.cityPopulation || 0) >= 180000 || (node.majorCityCount || 0) > 0) continue;
|
|
let totalBoundary = 0;
|
|
let sameBoundary = 0;
|
|
const byPref = new Map();
|
|
for (const [nextId, edge] of node.adjacent) {
|
|
const nPref = owner.get(nextId);
|
|
if (nPref === undefined || nPref < 0) continue;
|
|
const w = Math.max(1, edge.count || 1);
|
|
totalBoundary += w;
|
|
if (nPref === pref) sameBoundary += w;
|
|
else {
|
|
const row = byPref.get(nPref) || { pref: nPref, shared: 0, score: 0, minCrossing: INF };
|
|
row.shared += w;
|
|
row.score += w * 2.8 - (edge.crossingCost ?? (1 + (edge.barrier || 0) * 8.0)) * 0.55;
|
|
row.minCrossing = Math.min(row.minCrossing, edge.crossingCost ?? 1);
|
|
byPref.set(nPref, row);
|
|
}
|
|
}
|
|
if (totalBoundary <= 0) continue;
|
|
const borderBoundary = totalBoundary - sameBoundary;
|
|
const borderShare = borderBoundary / totalBoundary;
|
|
if (borderShare < threshold || !byPref.size) continue;
|
|
if ((counts.get(pref) || 0) <= Math.max(5, options.minSourceCount ?? 8)) continue;
|
|
if (!wouldRemainConnectedAfterRemoval(nodes, owner, id, pref)) continue;
|
|
const best = [...byPref.values()]
|
|
.filter((row) => row.shared >= minSharedToTarget)
|
|
.sort((a, b) => b.score - a.score || b.shared - a.shared || a.pref - b.pref)[0];
|
|
if (!best) continue;
|
|
const compactnessGain = best.shared - sameBoundary * 0.72 + borderShare * 6.0;
|
|
if (compactnessGain < 1.2 && best.score < 1.0) continue;
|
|
candidates.push({ id, from: pref, to: best.pref, borderShare, score: compactnessGain + best.score * 0.08 });
|
|
}
|
|
candidates.sort((a, b) => b.borderShare - a.borderShare || b.score - a.score || a.id - b.id);
|
|
const touched = new Set();
|
|
for (const cand of candidates) {
|
|
if (touched.has(cand.id) || owner.get(cand.id) !== cand.from) continue;
|
|
if (!wouldRemainConnectedAfterRemoval(nodes, owner, cand.id, cand.from)) continue;
|
|
owner.set(cand.id, cand.to);
|
|
touched.add(cand.id);
|
|
passChanged++;
|
|
}
|
|
if (!passChanged) break;
|
|
changed += passChanged;
|
|
repairPrefectureMunicipalityConnectivity(nodes, owner);
|
|
repairPrefectureMunicipalityEnclaves(nodes, owner, 6);
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
export function lockPrefectureCapitalNeighborMunicipalities(owner, nodes, seeds = [], maxNeighbors = 6) {
|
|
let changed = 0;
|
|
const seedIds = new Set((seeds || []).map((node) => node?.id).filter((id) => id !== undefined));
|
|
for (const seedNode of seeds || []) {
|
|
if (!seedNode || !nodes.has(seedNode.id)) continue;
|
|
const prefId = owner.get(seedNode.id);
|
|
if (prefId === undefined || prefId < 0) continue;
|
|
const neighbors = [...(nodes.get(seedNode.id)?.adjacent || [])]
|
|
.map(([id, edge]) => ({ node: nodes.get(id), id, edge }))
|
|
.filter((row) => row.node && owner.get(row.id) !== prefId && !seedIds.has(row.id))
|
|
.sort((a, b) => (a.edge.crossingCost ?? 1) - (b.edge.crossingCost ?? 1) || Math.hypot(a.node.x - seedNode.x, a.node.y - seedNode.y) - Math.hypot(b.node.x - seedNode.x, b.node.y - seedNode.y));
|
|
let taken = 0;
|
|
for (const row of neighbors) {
|
|
if (taken >= maxNeighbors) break;
|
|
const donorPref = owner.get(row.id);
|
|
if (donorPref === undefined || donorPref < 0 || donorPref === prefId) continue;
|
|
if (!wouldRemainConnectedAfterRemoval(nodes, owner, row.id, donorPref)) continue;
|
|
owner.set(row.id, prefId);
|
|
changed++;
|
|
taken++;
|
|
}
|
|
}
|
|
if (changed) {
|
|
repairPrefectureMunicipalityConnectivity(nodes, owner);
|
|
repairPrefectureMunicipalityEnclaves(nodes, owner, 6);
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function averageRegionalBorderField(adminId, municipalityToPrefectureId, prefectureMask, sea, field) {
|
|
if (!field) return 0;
|
|
let sum = 0;
|
|
let count = 0;
|
|
for (let y = 1; y < MAP_H - 1; y++) {
|
|
for (let x = 1; x < MAP_W - 1; x++) {
|
|
const i = indexOf(x, y);
|
|
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
|
|
const a = municipalityToPrefectureId[adminId[i]] ?? -1;
|
|
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
|
|
const ni = indexOf(nx, ny);
|
|
if (!prefectureMask[ni] || sea[ni] || adminId[ni] < 0) continue;
|
|
const b = municipalityToPrefectureId[adminId[ni]] ?? -1;
|
|
if (a < 0 || b < 0 || a === b) continue;
|
|
sum += ((field[i] || 0) + (field[ni] || 0)) * 0.5;
|
|
count++;
|
|
}
|
|
}
|
|
}
|
|
return count ? sum / count : 0;
|
|
}
|
|
|
|
export function generatePrefecturesFromMunicipalities(context, adminResult) {
|
|
const { adminId } = adminResult;
|
|
const { prefectureMask, sea, naturalBarrierScore, populationDensity, landuse, modernCities, markets, ports, seed, geography = null, habitability = null, accessibility = null, centrality = null, adminBoundaryPreference = null, boundaryAvoidance = null, geographicBarrier = null } = context;
|
|
const geographyFields = geography || { habitability, accessibility, centrality, adminBoundaryPreference, boundaryAvoidance, geographicBarrier };
|
|
const graph = buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity, [...(modernCities || []), ...(markets || []), ...(ports || [])], geographyFields);
|
|
const firstStageSeeds = choosePrefectureMunicipalitySeeds(graph.nodes, seed + 91001);
|
|
const firstStageOwner = assignMunicipalitiesToPrefectures(graph.nodes, firstStageSeeds);
|
|
repairPrefectureMunicipalityConnectivity(graph.nodes, firstStageOwner);
|
|
repairPrefectureMunicipalityEnclaves(graph.nodes, firstStageOwner, 4);
|
|
const secondStageSeeds = chooseSecondStagePrefectureSeeds(graph.nodes, firstStageOwner, firstStageSeeds, seed + 91077);
|
|
const seeds = secondStageSeeds.length ? secondStageSeeds : firstStageSeeds;
|
|
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 });
|
|
const changedForLivingSphereUnification = lockLivingSphereMunicipalitiesToSinglePrefecture(owner, graph.nodes, adminId, { prefectureMask, sea, modernCities, markets, ports });
|
|
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 changedForPostRepairLivingSphereUnification = lockLivingSphereMunicipalitiesToSinglePrefecture(owner, graph.nodes, adminId, { prefectureMask, sea, modernCities, markets, ports });
|
|
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);
|
|
// Rebalancing and oversized splitting can create small municipality-level
|
|
// exclaves. Run enclave/connectivity repair as the final owner operation so
|
|
// rendered prefectures are contiguous unions of municipalities.
|
|
changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 10);
|
|
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
|
changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 10);
|
|
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
|
const changedForCapitalNeighborLock = lockPrefectureCapitalNeighborMunicipalities(owner, graph.nodes, seeds, 7);
|
|
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
|
changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 12);
|
|
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
|
const changedForBoundaryShareRelaxation = relaxHighBoundaryShareMunicipalities(graph.nodes, owner, { threshold: 0.50, maxPasses: 6 });
|
|
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
|
changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 12);
|
|
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,
|
|
prefectureTwoStageReassignment: true,
|
|
prefectureFirstStageSeedCount: firstStageSeeds.length,
|
|
prefectureSecondStageSeedCount: secondStageSeeds.length,
|
|
prefectureSecondStageOffscreenCapitalSeedCount: secondStageSeeds.offscreenCapitalPrefectures?.length || 0,
|
|
prefectureSecondStageOffscreenCapitalPrefectures: secondStageSeeds.offscreenCapitalPrefectures || [],
|
|
prefectureTinyMergeChangedMunicipalities: changedForTinyMerge,
|
|
prefectureConnectivityRepairChangedMunicipalities: changedForConnectivity,
|
|
prefectureEnclaveRepairChangedMunicipalities: changedForEnclaveRepair,
|
|
prefectureUrbanMetroUnificationChangedMunicipalities: changedForMetroUnification,
|
|
prefectureLivingSphereUnificationChangedMunicipalities: (changedForLivingSphereUnification || 0) + (changedForPostRepairLivingSphereUnification || 0),
|
|
prefectureUnifiedGeographyBasis: true,
|
|
prefectureMunicipalityCountRebalanceChangedMunicipalities: (changedForMunicipalityCountRebalance || 0) + (changedForPostMergeMunicipalityCountRebalance || 0),
|
|
prefectureTinyMunicipalityCountMergeChangedMunicipalities: changedForTinyPrefectureCountMerge || 0,
|
|
prefectureOversizedCountSplitChangedMunicipalities: changedForOversizedPrefectureSplit || 0,
|
|
prefectureCapitalNeighborLockChangedMunicipalities: changedForCapitalNeighborLock || 0,
|
|
prefectureBoundaryShareRelaxationChangedMunicipalities: changedForBoundaryShareRelaxation || 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,
|
|
finalRegionalBorderUnifiedBoundaryPreferenceAverage: averageRegionalBorderField(adminId, municipalityToPrefectureId, prefectureMask, sea, geographyFields.adminBoundaryPreference),
|
|
finalRegionalBorderUnifiedBoundaryAvoidanceAverage: averageRegionalBorderField(adminId, municipalityToPrefectureId, prefectureMask, sea, geographyFields.boundaryAvoidance),
|
|
finalRegionalBorderUnifiedCentralityAverage: averageRegionalBorderField(adminId, municipalityToPrefectureId, prefectureMask, sea, geographyFields.centrality),
|
|
},
|
|
};
|
|
}
|
|
|
|
|