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