map/mapPrefectureStage.js
2026-05-28 02:47:33 +09:00

897 lines
45 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 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 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,
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),
},
};
}