import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, indexOf, inside, weightedScore, xyOf } from "./mapUtils.js"; function neighbors8(x, y) { const out = []; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { if (dx === 0 && dy === 0) continue; const nx = x + dx; const ny = y + dy; if (inside(nx, ny)) out.push([nx, ny, Math.hypot(dx, dy)]); } } return out; } function neighbors4(x, y) { const out = []; for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { const nx = x + dx; const ny = y + dy; if (inside(nx, ny)) out.push([nx, ny, 1]); } return out; } export function generateAdminRegions(centers, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse) { const adminId = new Int16Array(SIZE); adminId.fill(-1); const dist = new Float32Array(SIZE); dist.fill(INF); const heap = new MinHeap(); centers.forEach((center, regionId) => { const i = indexOf(center.x, center.y); dist[i] = 0; adminId[i] = regionId; heap.push({ i, f: 0, regionId }); }); let guard = 0; while (heap.length > 0 && guard++ < SIZE * 12) { const current = heap.pop(); if (!current) continue; const curIndex = current.i; const curRegion = adminId[curIndex]; if (curRegion < 0 || current.f > dist[curIndex] + 1e-5) continue; const [cx, cy] = xyOf(curIndex); for (const [nx, ny, step] of neighbors8(cx, cy)) { const ni = indexOf(nx, ny); if (!prefectureMask[ni] || sea[ni]) continue; const ridgeBarrier = Math.max(ridgeField[ni], ridgeField[curIndex]); const riverBarrier = Math.max(river[ni], river[curIndex]); const highDivide = Math.max(elevation[ni], elevation[curIndex]); const watershedBarrier = ridgeBarrier * (27.0 + Math.max(0, highDivide - 0.46) * 46.0); const ridgePenalty = Math.max(0, highDivide - 0.36) * 16.0 + Math.abs(elevation[ni] - elevation[curIndex]) * 12.4 + watershedBarrier; const slopePenalty = slope[ni] * 10.6; const valleyBarrier = valleyField[ni] > 0.50 ? valleyField[ni] * (riverBarrier > 0.16 ? 7.2 : 2.6) : 0; const riverPenalty = riverBarrier > 0.7 ? 22.0 : riverBarrier > 0.42 ? 14.8 : riverBarrier > 0.22 ? 7.4 : riverBarrier > 0.12 ? 2.2 : 0; const urbanContinuityBonus = (landuse[ni] >= 2 && landuse[ni] <= 4 && populationDensity[ni] > 0.20) ? 1.65 : 0; const valleyLocalityBonus = valleyField[ni] * 0.16; const stepCost = Math.max(0.25, 0.72 + ridgePenalty + slopePenalty + riverPenalty + valleyBarrier - valleyLocalityBonus - urbanContinuityBonus) * step; const nextDist = dist[curIndex] + stepCost; if (nextDist < dist[ni]) { dist[ni] = nextDist; adminId[ni] = curRegion; heap.push({ i: ni, f: nextDist, regionId: curRegion }); } } } return adminId; } function terrainBoundaryStrength(i, elevation, slope, river, ridgeField, valleyField) { return clamp(weightedScore([ [ridgeField[i], 3.15], [river[i], 2.45], [valleyField[i], 0.62], [slope[i], 1.06], [Math.max(0, elevation[i] - 0.5), 1.18], ])); } export function smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse, passes = 5) { let current = new Int16Array(adminId); for (let pass = 0; pass < passes; pass++) { const next = new Int16Array(current); for (let y = 1; y < MAP_H - 1; y++) { for (let x = 1; x < MAP_W - 1; x++) { const i = indexOf(x, y); const own = current[i]; if (!prefectureMask[i] || sea[i] || own < 0) continue; const urbanCell = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.24; const barrier = terrainBoundaryStrength(i, elevation, slope, river, ridgeField, valleyField); if (barrier > 0.62 || urbanCell) continue; const counts = new Map(); let ownCount = 0; for (const [nx, ny] of neighbors4(x, y)) { const ni = indexOf(nx, ny); if (!prefectureMask[ni] || sea[ni]) continue; const id = current[ni]; if (id < 0) continue; const weight = terrainBoundaryStrength(ni, elevation, slope, river, ridgeField, valleyField) > 0.72 ? 0.45 : 1; counts.set(id, (counts.get(id) || 0) + weight); if (id === own) ownCount += weight; } let bestId = own; let best = ownCount; for (const [id, score] of counts) if (score > best) { best = score; bestId = id; } if (bestId !== own && (best >= 4.2 || ownCount <= 2.1)) next[i] = bestId; } } current = next; } adminId.set(current); } export function lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, maxCells = 360) { const seen = new Uint8Array(SIZE); const queue = []; for (let i = 0; i < SIZE; i++) { if (seen[i] || !prefectureMask[i] || sea[i]) continue; const isUrbanStart = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8 || populationDensity[i] > 0.20; if (!isUrbanStart) continue; const component = []; queue.length = 0; queue.push(i); seen[i] = 1; for (let q = 0; q < queue.length; q++) { const cur = queue[q]; component.push(cur); const [x, y] = xyOf(cur); for (const [nx, ny] of neighbors8(x, y)) { const ni = indexOf(nx, ny); if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue; const isUrban = (landuse[ni] >= 2 && landuse[ni] <= 4) || landuse[ni] === 7 || landuse[ni] === 8 || populationDensity[ni] > 0.20; if (!isUrban) continue; seen[ni] = 1; queue.push(ni); } } if (component.length === 0 || component.length > maxCells) continue; const counts = new Map(); for (const ci of component) { const id = adminId[ci]; if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1 + populationDensity[ci]); } let bestId = -1; let best = -1; for (const [id, score] of counts) if (score > best) { best = score; bestId = id; } if (bestId >= 0) for (const ci of component) adminId[ci] = bestId; } } export function mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities = [], minArea = 320, options = {}) { const area = new Map(); const pop = new Map(); const adjacency = new Map(); const cityMunicipalities = new Set(); for (const city of modernCities || []) { if (!inside(city.x, city.y)) continue; const id = adminId[indexOf(city.x, city.y)]; if (id < 0) continue; if (options.protectAllModernCities !== false || city.isPrefecturalCapital || (city.population || 0) >= (options.majorCityPopulationThreshold || 120000)) cityMunicipalities.add(id); } for (const point of options.protectedPoints || []) { if (!point || !inside(point.x, point.y)) continue; const id = adminId[indexOf(point.x, point.y)]; if (id >= 0) cityMunicipalities.add(id); } const satelliteByAdmin = new Map(); for (const sat of options.satelliteCities || []) { if (!sat || !inside(sat.x, sat.y)) continue; const id = adminId[indexOf(sat.x, sat.y)]; if (id < 0) continue; if (!satelliteByAdmin.has(id)) satelliteByAdmin.set(id, []); satelliteByAdmin.get(id).push(sat); } const satelliteStats = options.satelliteStats || null; 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]) continue; const id = adminId[i]; if (id < 0) continue; area.set(id, (area.get(id) || 0) + 1); pop.set(id, (pop.get(id) || 0) + populationDensity[i]); 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]) continue; const other = adminId[ni]; if (other < 0 || other === id) continue; const key = id < other ? `${id}:${other}` : `${other}:${id}`; adjacency.set(key, (adjacency.get(key) || 0) + 1); } } } const mergeTarget = new Map(); for (const [id, cells] of area) { const score = cells + (pop.get(id) || 0) * 16; if (cells >= minArea || cityMunicipalities.has(id)) continue; const satellites = satelliteByAdmin.get(id) || []; const protectedSatellite = satellites.some((sat) => { const minSatelliteArea = sat.satelliteMinArea || options.satelliteMinArea || 110; return sat.municipalityClass === "independentSatelliteMunicipality" && ( cells >= minSatelliteArea || (sat.population || 0) >= (options.satelliteIndependentPopulationThreshold || 60000) || (sat.distinctUrbanComponentArea || 0) >= 80 || sat.separatedByBarrier ); }); if (protectedSatellite) continue; let bestNeighbor = -1; let bestScore = -1; for (const [key, border] of adjacency) { const [a, b] = key.split(":").map(Number); if (a !== id && b !== id) continue; const other = a === id ? b : a; const parentBias = satellites.some((sat) => inside(sat.parentX ?? -1, sat.parentY ?? -1) && adminId[indexOf(sat.parentX, sat.parentY)] === other) ? 26 : 0; const ruralBias = satellites.some((sat) => sat.municipalityClass === "smallTownAttachedToRuralMunicipality") ? Math.min(12, (area.get(other) || 0) * 0.01) : 0; const candidate = border * 3 + (area.get(other) || 0) * 0.012 + (pop.get(other) || 0) * 0.24 + parentBias + ruralBias; if (candidate > bestScore) { bestScore = candidate; bestNeighbor = other; } } if (bestNeighbor >= 0 && (area.get(bestNeighbor) || 0) >= score * 0.35) { mergeTarget.set(id, bestNeighbor); if (satelliteStats && satellites.length) { satelliteStats.satelliteMunicipalitiesMerged += satellites.length; for (const sat of satellites) sat.mergedMunicipalityTarget = bestNeighbor; } } } if (mergeTarget.size === 0) return; for (let i = 0; i < SIZE; i++) if (mergeTarget.has(adminId[i])) adminId[i] = mergeTarget.get(adminId[i]); } export function removeMunicipalExclaves(adminId, prefectureMask, sea, adminCenters = [], protectedPoints = [], maxIslandCells = 220) { const protectedByAdmin = new Map(); for (const p of [...adminCenters, ...protectedPoints]) { if (!p || !inside(p.x, p.y)) continue; const id = adminId[indexOf(p.x, p.y)]; if (id < 0) continue; if (!protectedByAdmin.has(id)) protectedByAdmin.set(id, new Set()); protectedByAdmin.get(id).add(indexOf(p.x, p.y)); } const ids = new Set(); for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && adminId[i] >= 0) ids.add(adminId[i]); const globalSeen = new Uint8Array(SIZE); const queue = []; for (const id of ids) { const components = []; for (let i = 0; i < SIZE; i++) { if (globalSeen[i] || adminId[i] !== id || !prefectureMask[i] || sea[i]) continue; const comp = []; let hasProtected = protectedByAdmin.get(id)?.has(i) || false; queue.length = 0; queue.push(i); globalSeen[i] = 1; for (let q = 0; q < queue.length; q++) { const cur = queue[q]; comp.push(cur); if (protectedByAdmin.get(id)?.has(cur)) hasProtected = true; const [x, y] = xyOf(cur); for (const [nx, ny] of neighbors4(x, y)) { const ni = indexOf(nx, ny); if (globalSeen[ni] || adminId[ni] !== id || !prefectureMask[ni] || sea[ni]) continue; globalSeen[ni] = 1; queue.push(ni); } } components.push({ cells: comp, hasProtected }); } if (components.length <= 1) continue; components.sort((a, b) => (b.hasProtected ? 1000000 : 0) + b.cells.length - ((a.hasProtected ? 1000000 : 0) + a.cells.length)); for (const component of components.slice(1)) { const mainSize = components[0].cells.length; if (component.hasProtected && component.cells.length > maxIslandCells && component.cells.length > mainSize * 0.42) continue; if (component.cells.length > maxIslandCells && component.cells.length > mainSize * 0.36) continue; const counts = new Map(); for (const ci of component.cells) { const [x, y] = xyOf(ci); for (const [nx, ny] of neighbors4(x, y)) { const ni = indexOf(nx, ny); if (!prefectureMask[ni] || sea[ni]) continue; const other = adminId[ni]; if (other >= 0 && other !== id) counts.set(other, (counts.get(other) || 0) + 1); } } let target = -1; let best = -1; for (const [other, count] of counts) if (count > best) { best = count; target = other; } if (target >= 0) for (const ci of component.cells) adminId[ci] = target; } } } export function terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse) { const urbanPenalty = urbanBoundaryPenalty(i, populationDensity, landuse); const majorRiver = clamp(Math.max(river[i] - 0.32, 0) * 1.9 + Math.max(flowAccum[i] - 0.38, 0) * 0.75); const minorStream = clamp(river[i] * 0.34 + flowAccum[i] * 0.18); const ridgeDivide = clamp(ridgeField[i] * 1.55 + Math.max(0, elevation[i] - 0.54) * ridgeField[i] * 0.95); const slopeBreak = clamp(slope[i] * 0.58 + Math.max(0, slope[i] - 0.32) * 0.68); const highGround = Math.max(0, elevation[i] - 0.56) * 0.22; const valleyFloorPenalty = valleyField[i] * (majorRiver > 0.34 ? -0.10 : -0.62); return clamp(ridgeDivide + majorRiver * 0.88 + minorStream * 0.22 + slopeBreak + highGround + valleyFloorPenalty - urbanPenalty * 0.72); } function urbanBoundaryPenalty(i, populationDensity, landuse) { const lu = landuse[i]; const core = lu === 3 ? 1.45 : lu === 2 ? 1.12 : lu === 4 ? 0.95 : lu === 7 ? 0.90 : lu === 8 ? 0.64 : lu === 5 || lu === 6 ? 0.48 : 0; return clamp(core + populationDensity[i] * 1.35); } function isAdminBoundaryCell(labels, prefectureMask, sea, x, y, useEight = true) { const i = indexOf(x, y); const own = labels[i]; if (!prefectureMask[i] || sea[i] || own < 0) return false; const neighbors = useEight ? neighbors8(x, y) : neighbors4(x, y); for (const [nx, ny] of neighbors) { const ni = indexOf(nx, ny); if (prefectureMask[ni] && !sea[ni] && labels[ni] >= 0 && labels[ni] !== own) return true; } return false; } function buildBoundaryBand(labels, prefectureMask, sea, radius = 5) { const band = new Uint8Array(SIZE); for (let y = 1; y < MAP_H - 1; y++) { for (let x = 1; x < MAP_W - 1; x++) { if (!isAdminBoundaryCell(labels, prefectureMask, sea, x, y, true)) continue; for (let dy = -radius; dy <= radius; dy++) { for (let dx = -radius; dx <= radius; dx++) { if (Math.hypot(dx, dy) > radius) continue; const nx = x + dx; const ny = y + dy; if (!inside(nx, ny)) continue; const ni = indexOf(nx, ny); if (prefectureMask[ni] && !sea[ni]) band[ni] = 1; } } } } return band; } function buildAdminProtectedMask(adminId, prefectureMask, sea, adminCenters = [], protectedPoints = [], populationDensity, landuse) { const protectedMask = new Uint8Array(SIZE); function protectDisk(p, radius) { if (!p || !inside(p.x, p.y)) return; const owner = adminId[indexOf(p.x, p.y)]; if (owner < 0) return; const r = Math.ceil(radius); for (let dy = -r; dy <= r; dy++) { for (let dx = -r; dx <= r; dx++) { if (Math.hypot(dx, dy) > radius) continue; const x = p.x + dx; const y = p.y + dy; if (!inside(x, y)) continue; const i = indexOf(x, y); if (prefectureMask[i] && !sea[i] && adminId[i] === owner) protectedMask[i] = 1; } } } for (const center of adminCenters) protectDisk(center, 2.2); for (const p of protectedPoints || []) protectDisk(p, p.population ? clamp(1.6 + Math.sqrt(p.population) / 520, 2.1, 6.0) : p.portClass ? 2.0 : 1.7); for (let i = 0; i < SIZE; i++) { if (prefectureMask[i] && !sea[i] && (landuse[i] === 3 || populationDensity[i] > 0.72)) protectedMask[i] = 1; } return protectedMask; } function localBoundaryEnergy(labels, i, candidateId, targetScore, centerDist, populationDensity, landuse, river, valleyField) { const [x, y] = xyOf(i); const oldId = labels[i]; let energy = centerDist[candidateId]?.[i] ?? 0; let same4 = 0; let diff4 = 0; let diagDiff = 0; for (const [nx, ny] of neighbors8(x, y)) { const ni = indexOf(nx, ny); const neighborId = labels[ni]; if (neighborId < 0) continue; const isCardinal = nx === x || ny === y; const differs = neighborId !== candidateId; if (isCardinal) { if (differs) { diff4++; const boundaryTarget = (targetScore[i] + targetScore[ni]) * 0.5; const urbanCut = (urbanBoundaryPenalty(i, populationDensity, landuse) + urbanBoundaryPenalty(ni, populationDensity, landuse)) * 0.5; const minorValley = (valleyField[i] + valleyField[ni]) * 0.5 > 0.34 && Math.max(river[i], river[ni]) < 0.30 ? 0.72 : 0; const dHere = centerDist[candidateId]?.[i] ?? 99; const dThere = centerDist[neighborId]?.[i] ?? 99; const weakBisectorPenalty = Math.abs(dHere - dThere) < 4.0 && boundaryTarget < 0.42 ? 0.62 : 0; energy += 2.15 - boundaryTarget * 1.55 + urbanCut * 3.0 + minorValley + weakBisectorPenalty; } else same4++; } else if (differs) diagDiff++; } if (same4 === 0) energy += 5.2; if (same4 === 1) energy += 1.7; if (diff4 >= 3 && targetScore[i] < 0.42) energy += 1.25; if (diagDiff >= 3 && diff4 >= 2 && targetScore[i] < 0.50) energy += 0.42; if (candidateId !== oldId) { const candidateDistance = centerDistanceAt(centerDist, candidateId, i); const oldDistance = centerDistanceAt(centerDist, oldId, i); if (Number.isFinite(candidateDistance) && Number.isFinite(oldDistance)) { const drift = candidateDistance - oldDistance; if (drift > 0) energy += Math.min(0.9, drift * 0.012); } } return energy; } function centerDistanceAt(centerDist, id, i) { const field = centerDist?.fields?.[id] || centerDist?.[id]; if (field) return field[i]; const center = centerDist?.centers?.[id]; if (!center || !inside(center.x, center.y)) return 24; const [x, y] = xyOf(i); return Math.hypot(x - center.x, y - center.y); } function buildCenterDistanceFields(adminIds, adminCenters, prefectureMask, sea) { // Older versions materialized one full SIZE Float32Array per municipality. // In multi-prefecture generation this can create heavy transient memory use. // Keep the same interface conceptually, but compute distances on demand. return { ids: adminIds, centers: adminCenters, prefectureMask, sea }; } function repairAdminTopology(adminId, prefectureMask, sea, adminCenters = [], targetScore = null, populationDensity = null, landuse = null) { const ids = new Set(); for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && adminId[i] >= 0) ids.add(adminId[i]); const queue = []; for (const id of ids) { const seen = new Uint8Array(SIZE); const components = []; for (let i = 0; i < SIZE; i++) { if (seen[i] || adminId[i] !== id || !prefectureMask[i] || sea[i]) continue; const cells = []; queue.length = 0; queue.push(i); seen[i] = 1; for (let q = 0; q < queue.length; q++) { const cur = queue[q]; cells.push(cur); const [x, y] = xyOf(cur); for (const [nx, ny] of neighbors4(x, y)) { const ni = indexOf(nx, ny); if (seen[ni] || adminId[ni] !== id || !prefectureMask[ni] || sea[ni]) continue; seen[ni] = 1; queue.push(ni); } } components.push(cells); } if (components.length <= 1) continue; const centerIndex = adminCenters[id] && inside(adminCenters[id].x, adminCenters[id].y) ? indexOf(adminCenters[id].x, adminCenters[id].y) : -1; let keepIndex = centerIndex >= 0 ? components.findIndex((cells) => cells.includes(centerIndex)) : -1; if (keepIndex < 0) { let bestSize = -1; for (let c = 0; c < components.length; c++) if (components[c].length > bestSize) { bestSize = components[c].length; keepIndex = c; } } for (let c = 0; c < components.length; c++) { if (c === keepIndex) continue; const counts = new Map(); for (const ci of components[c]) { const [x, y] = xyOf(ci); for (const [nx, ny] of neighbors4(x, y)) { const ni = indexOf(nx, ny); if (!prefectureMask[ni] || sea[ni]) continue; const other = adminId[ni]; if (other < 0 || other === id) continue; const terrainFit = targetScore ? targetScore[ci] * 0.18 : 0; const urbanFit = populationDensity && landuse ? (1 - urbanBoundaryPenalty(ci, populationDensity, landuse)) * 0.08 : 0; counts.set(other, (counts.get(other) || 0) + 1 + terrainFit + urbanFit); } } let target = -1; let best = -1; for (const [other, score] of counts) if (score > best) { best = score; target = other; } if (target >= 0) for (const ci of components[c]) adminId[ci] = target; } } } function classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse) { if (landuse[i] === 3 || populationDensity[i] > 0.70) return 1; if ((landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8 || populationDensity[i] > 0.24) return 2; if (landuse[i] === 5 || landuse[i] === 6) return 3; if ((river[i] > 0.50 && flowAccum[i] > 0.34) || flowAccum[i] > 0.68) return 4; if (coastalLowland[i] > 0.42 && elevation[i] < 0.44) return 5; if (basinField[i] > 0.38 && plain[i] > 0.26) return 6; if (valleyField[i] > 0.42 && ridgeField[i] < 0.55) return 7; if (ridgeField[i] > 0.54 || (ridgeField[i] > 0.40 && elevation[i] > 0.54)) return 8; if (elevation[i] > 0.62 || slope[i] > 0.42) return 9; if (landuse[i] === 0 || agriculture[i] > 0.45 || plain[i] > 0.48) return 10; return 11; } export function buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null) { const score = new Float32Array(SIZE); for (let i = 0; i < SIZE; i++) { if (!prefectureMask[i] || sea[i]) continue; const [x, y] = xyOf(i); let coastEdge = 0; for (const [nx, ny] of neighbors8(x, y)) if (sea[indexOf(nx, ny)]) coastEdge = 1; const urbanContinuity = populationDensity && landuse ? urbanBoundaryPenalty(i, populationDensity, landuse) : 0; const majorRiver = clamp(Math.max(river[i] - 0.34, 0) * 1.95 + Math.max(flowAccum[i] - 0.42, 0) * 0.82); const ridgeDivide = clamp(ridgeField[i] * 1.65 + Math.max(0, elevation[i] - 0.52) * ridgeField[i] * 1.05); const crest = crestCrossingScore ? crestCrossingScore[i] : clamp(Math.max(0, elevation[i] - 0.55) * ridgeField[i] * 1.4 + slope[i] * ridgeField[i] * 0.8); const basinRim = basinField ? clamp(Math.max(0, basinField[i] - 0.32) * Math.max(0, slope[i] - 0.18) * 1.15 + Math.max(0, ridgeField[i] - 0.34) * basinField[i] * 0.62) : 0; const foothillBreak = clamp(Math.max(0, slope[i] - 0.30) * Math.max(ridgeField[i], Math.max(0, elevation[i] - 0.48)) * 0.82); const livingCorridor = clamp((plain?.[i] || 0) * 0.34 + (agriculture?.[i] || 0) * 0.26 + valleyField[i] * (majorRiver > 0.34 ? 0.10 : 0.46) + coastalLowland[i] * 0.18); score[i] = clamp( ridgeDivide * 0.92 + crest * 0.72 + majorRiver * 0.86 + basinRim * 0.54 + foothillBreak * 0.48 + coastEdge * 0.34 + terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity || score, landuse || score) * 0.38 - livingCorridor * 0.50 - urbanContinuity * 0.72 ); } return score; } function lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse) { const lowRelief = clamp((0.68 - elevation[i]) * 1.25) + clamp((0.36 - slope[i]) * 1.45) + clamp((0.48 - ridgeField[i]) * 1.10); const landuseFit = [1, 2, 3, 4, 7, 8].includes(landuse[i]) ? 0.42 : landuse[i] === 5 || landuse[i] === 6 ? 0.20 : 0; return clamp( lowRelief * 0.30 + (plain?.[i] || 0) * 0.30 + (agriculture?.[i] || 0) * 0.16 + basinField[i] * 0.24 + coastalLowland[i] * 0.24 + valleyField[i] * 0.10 + populationDensity[i] * 0.34 + landuseFit ); } function mountainCompartmentFitness(i, elevation, slope, ridgeField, populationDensity, landuse) { const settled = populationDensity[i] * 0.85 + ([2, 3, 4, 7, 8].includes(landuse[i]) ? 0.35 : 0); return clamp(elevation[i] * 0.38 + slope[i] * 0.32 + ridgeField[i] * 0.42 - settled); } function canShareNaturalCompartment(a, b, classA, classB, barrier, river, flowAccum, valleyField, populationDensity, landuse) { if (classA !== classB) { const bothUrban = classA <= 3 && classB <= 3; const bothLivingCorridor = [5, 6, 7, 10].includes(classA) && [5, 6, 7, 10].includes(classB); if (!bothUrban && !bothLivingCorridor) return false; } const majorRiverEdge = Math.max(river[a], river[b]) > 0.56 || Math.max(flowAccum[a], flowAccum[b]) > 0.68; const urbanEdge = ((landuse[a] >= 2 && landuse[a] <= 4) || landuse[a] === 7 || populationDensity[a] > 0.34) && ((landuse[b] >= 2 && landuse[b] <= 4) || landuse[b] === 7 || populationDensity[b] > 0.34); const valleyContinuity = (valleyField[a] + valleyField[b]) * 0.5 > 0.48 && !majorRiverEdge; const threshold = urbanEdge ? 0.78 : valleyContinuity ? 0.62 : classA === 8 || classB === 8 ? 0.36 : 0.50; return barrier < threshold && (!majorRiverEdge || urbanEdge); } function refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse) { let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = unit.riverExposure || 0; let coastalExposure = 0, basinIdentity = 0, valleyIdentity = 0, lowlandFitness = 0, mountainFitness = 0; let minX = MAP_W, minY = MAP_H, maxX = 0, maxY = 0; for (const i of unit.cells) { const [x, y] = xyOf(i); sx += x; sy += y; pop += populationDensity[i]; minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); urbanWeight += urbanBoundaryPenalty(i, populationDensity, landuse); ridgeExposure += ridgeField[i]; coastalExposure += coastalLowland[i]; basinIdentity += basinField[i]; valleyIdentity += valleyField[i]; lowlandFitness += lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); mountainFitness += mountainCompartmentFitness(i, elevation, slope, ridgeField, populationDensity, landuse); } const area = unit.cells.length; unit.area = area; unit.x = sx / Math.max(1, area); unit.y = sy / Math.max(1, area); unit.minX = area ? minX : 0; unit.minY = area ? minY : 0; unit.maxX = area ? maxX : 0; unit.maxY = area ? maxY : 0; unit.width = area ? maxX - minX + 1 : 0; unit.height = area ? maxY - minY + 1 : 0; unit.elongation = Math.max(unit.width, unit.height) / Math.max(1, Math.min(unit.width, unit.height)); unit.population = pop; unit.urbanWeight = urbanWeight / Math.max(1, area); unit.ridgeExposure = ridgeExposure / Math.max(1, area); unit.riverExposure = riverExposure / Math.max(1, area); unit.coastalExposure = coastalExposure / Math.max(1, area); unit.basinIdentity = basinIdentity / Math.max(1, area); unit.valleyIdentity = valleyIdentity / Math.max(1, area); unit.lowlandFitness = lowlandFitness / Math.max(1, area); unit.mountainFitness = mountainFitness / Math.max(1, area); } function splitOneNaturalCompartment(unit, newId, compartmentId, fields, seed) { if (!unit || unit.area < 24) return null; const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum } = fields; const minPart = Math.max(8, Math.min(28, Math.floor(unit.area * 0.20))); let first = -1; let second = -1; let bestA = -INF; let bestB = -INF; const width = unit.width || (unit.maxX - unit.minX + 1) || 1; const height = unit.height || (unit.maxY - unit.minY + 1) || 1; const horizontal = width >= height; const elongated = Math.max(width, height) / Math.max(1, Math.min(width, height)) > 1.65; for (const i of unit.cells) { const [x, y] = xyOf(i); const low = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); const settled = populationDensity[i] * 0.28 + ([2, 3, 4, 7, 8].includes(landuse[i]) ? 0.34 : 0); const axis = elongated ? (horizontal ? (unit.maxX - x) / Math.max(1, width) : (unit.maxY - y) / Math.max(1, height)) : 0.0; const score = axis * 1.7 + low * 0.42 + settled + hashSeededTie(x, y, seed) * 0.05 - ridgeField[i] * 0.10; if (score > bestA) { bestA = score; first = i; } } if (first < 0) return null; const [fx, fy] = xyOf(first); for (const i of unit.cells) { const [x, y] = xyOf(i); const low = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); const axis = elongated ? (horizontal ? (x - unit.minX) / Math.max(1, width) : (y - unit.minY) / Math.max(1, height)) : 0.0; const d = Math.hypot(x - fx, y - fy); const score = axis * 1.9 + d * (0.18 + low * 0.22) + hashSeededTie(x, y, seed + 17) * 0.08 - ridgeField[i] * 0.08; if (score > bestB) { bestB = score; second = i; } } if (second < 0 || second === first) return null; const cellSet = new Set(unit.cells); const owner = new Int8Array(SIZE); owner.fill(-1); const dist = new Float32Array(SIZE); dist.fill(INF); const heap = new MinHeap(); for (const [source, sourceOwner] of [[first, 0], [second, 1]]) { owner[source] = sourceOwner; dist[source] = 0; heap.push({ i: source, f: 0, owner: sourceOwner }); } while (heap.length > 0) { const cur = heap.pop(); if (!cur || cur.f > dist[cur.i] + 1e-5) continue; const [x, y] = xyOf(cur.i); for (const [nx, ny, step] of neighbors4(x, y)) { const ni = indexOf(nx, ny); if (!cellSet.has(ni)) continue; const barrier = ((naturalBarrierScore?.[cur.i] || 0) + (naturalBarrierScore?.[ni] || 0)) * 0.5; const riverBarrier = Math.max(river?.[cur.i] || 0, river?.[ni] || 0) + Math.max(flowAccum?.[cur.i] || 0, flowAccum?.[ni] || 0) * 0.32; const ridgeStep = Math.max(ridgeField[cur.i], ridgeField[ni]) * 0.80 + Math.abs(elevation[cur.i] - elevation[ni]) * 1.25; const corridorBonus = Math.min(0.48, ((valleyField[cur.i] + valleyField[ni]) * 0.5 + (plain?.[ni] || 0) * 0.18 + (coastalLowland?.[ni] || 0) * 0.12)); const stepCost = Math.max(0.18, 0.78 + barrier * 3.0 + riverBarrier * 1.10 + ridgeStep + slope[ni] * 0.38 - corridorBonus) * step; const nd = cur.f + stepCost; if (nd < dist[ni]) { dist[ni] = nd; owner[ni] = cur.owner; heap.push({ i: ni, f: nd, owner: cur.owner }); } } } const aCells = []; const bCells = []; for (const ci of unit.cells) { if (owner[ci] === 1) bCells.push(ci); else aCells.push(ci); } if (aCells.length < minPart || bCells.length < minPart) return null; unit.cells = aCells; const newUnit = { ...unit, id: newId, cells: bCells, centerIds: [], adjacent: new Map() }; for (const ci of bCells) compartmentId[ci] = newId; refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); refreshCompartmentStats(newUnit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); return newUnit; } function hashSeededTie(x, y, seed) { let h = Math.imul((x | 0) ^ (seed | 0), 1597334677) ^ Math.imul((y | 0) ^ ((seed >>> 1) | 0), 3812015801); h = (h ^ (h >>> 15)) >>> 0; return h / 4294967295; } function naturalGroupKey(unit) { if (unit.classId <= 3) return `urban:${Math.round(unit.x / 10)}:${Math.round(unit.y / 10)}`; if (unit.classId === 5) return `coast:${Math.round(unit.y / 8)}`; if (unit.classId === 6) return `basin:${Math.round(unit.x / 12)}:${Math.round(unit.y / 12)}`; if (unit.classId === 7) return `valley:${Math.round((unit.x + unit.y) / 12)}`; if (unit.classId === 8 || unit.classId === 9) return `mountain:${Math.round(unit.x / 14)}:${Math.round(unit.y / 14)}`; return `plain:${Math.round(unit.x / 14)}:${Math.round(unit.y / 14)}`; } function collectLandComponents(prefectureMask, sea) { const seen = new Uint8Array(SIZE); const components = []; const queue = []; for (let i = 0; i < SIZE; i++) { if (seen[i] || !prefectureMask[i] || sea[i]) continue; const cells = []; queue.length = 0; queue.push(i); seen[i] = 1; for (let q = 0; q < queue.length; q++) { const cur = queue[q]; cells.push(cur); const [x, y] = xyOf(cur); for (const [nx, ny] of neighbors4(x, y)) { const ni = indexOf(nx, ny); if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue; seen[ni] = 1; queue.push(ni); } } components.push(cells); } return components; } function naturalSeedScore(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, seed) { const klassUrban = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8; const lowland = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); const mountain = mountainCompartmentFitness(i, elevation, slope, ridgeField, populationDensity, landuse); const stableInterior = clamp(1 - (naturalBarrierScore[i] || 0)); const settlement = clamp(populationDensity[i] * 0.65 + (klassUrban ? 0.24 : 0)); const streamCorridor = clamp(valleyField[i] * 0.28 + river[i] * 0.08); const mountainInterior = clamp(mountain * 0.45 + stableInterior * 0.28 - ridgeField[i] * 0.22); return stableInterior * 0.56 + lowland * 0.42 + mountainInterior * 0.32 + settlement * 0.26 + streamCorridor + hashSeededTie(...xyOf(i), seed) * 0.13 - slope[i] * 0.10; } function chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, seed) { const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore } = fields; const totalArea = landComponents.reduce((sum, cells) => sum + cells.length, 0); const seeds = []; const seedComponentId = []; const minCellsPerUnit = 9; let remainingTarget = Math.max(1, Math.min(targetCount || Math.round(totalArea / 42), Math.floor(totalArea / minCellsPerUnit))); const sortedComponents = landComponents .map((cells, componentIndex) => ({ cells, componentIndex, area: cells.length })) .sort((a, b) => b.area - a.area); for (let componentOrder = 0; componentOrder < sortedComponents.length; componentOrder++) { const { cells, componentIndex, area } = sortedComponents[componentOrder]; if (area <= 0) continue; const proportional = Math.round((targetCount || Math.round(totalArea / 42)) * area / Math.max(1, totalArea)); let localTarget = Math.max(1, proportional); localTarget = Math.min(localTarget, Math.max(1, Math.floor(area / minCellsPerUnit))); if (componentOrder === sortedComponents.length - 1) localTarget = Math.max(1, Math.min(localTarget, remainingTarget)); remainingTarget -= localTarget; const candidates = cells .map((i) => ({ i, score: naturalSeedScore(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, seed + componentIndex * 1009) })) .sort((a, b) => b.score - a.score); const localSeeds = []; const idealSpacing = Math.sqrt(area / Math.max(1, localTarget)); const spacingPasses = [0.95, 0.78, 0.62, 0.48, 0.34]; for (const factor of spacingPasses) { const minDist = Math.max(2.2, idealSpacing * factor); for (const candidate of candidates) { if (localSeeds.length >= localTarget) break; const [x, y] = xyOf(candidate.i); let ok = true; for (const existing of localSeeds) { const [ex, ey] = xyOf(existing); if (Math.hypot(x - ex, y - ey) < minDist) { ok = false; break; } } if (ok) localSeeds.push(candidate.i); } if (localSeeds.length >= localTarget) break; } for (const i of localSeeds) { seeds.push(i); seedComponentId.push(componentIndex); } } if (seeds.length === 0 && landComponents[0]?.length) { seeds.push(landComponents[0][0]); seedComponentId.push(0); } return { seeds, seedComponentId }; } function naturalStepCost(a, b, cellClass, fields) { const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, flowAccum } = fields; const barrier = ((naturalBarrierScore?.[a] || 0) + (naturalBarrierScore?.[b] || 0)) * 0.5; const ridge = Math.max(ridgeField[a], ridgeField[b]); const riverEdge = Math.max(river[a], river[b]); const flowEdge = Math.max(flowAccum?.[a] || 0, flowAccum?.[b] || 0); const majorRiverCrossing = riverEdge > 0.44 || flowEdge > 0.55; const elevationBreak = Math.abs(elevation[a] - elevation[b]); const slopeBreak = Math.max(slope[a], slope[b]); const classBreak = cellClass[a] !== cellClass[b] ? 0.34 : -0.08; const bothUrban = ((landuse[a] >= 2 && landuse[a] <= 4) || landuse[a] === 7 || populationDensity[a] > 0.30) && ((landuse[b] >= 2 && landuse[b] <= 4) || landuse[b] === 7 || populationDensity[b] > 0.30); const lowlandContinuity = Math.min( (plain?.[a] || 0) + (agriculture?.[a] || 0) * 0.35 + basinField[a] * 0.25 + coastalLowland[a] * 0.20, (plain?.[b] || 0) + (agriculture?.[b] || 0) * 0.35 + basinField[b] * 0.25 + coastalLowland[b] * 0.20 ); const valleyContinuity = Math.min(valleyField[a], valleyField[b]) * (majorRiverCrossing ? 0.10 : 0.45); const corridorBonus = Math.min(0.42, lowlandContinuity * 0.22 + valleyContinuity + (bothUrban ? 0.18 : 0)); const riverPenalty = majorRiverCrossing && !bothUrban ? 1.85 + flowEdge * 1.45 : riverEdge > 0.22 ? 0.38 : 0; return Math.max(0.16, 0.72 + barrier * 5.1 + ridge * 0.82 + elevationBreak * 3.0 + slopeBreak * 0.56 + riverPenalty + classBreak - corridorBonus ); } function buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields) { const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse } = fields; let maxId = -1; for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && compartmentId[i] > maxId) maxId = compartmentId[i]; const units = Array.from({ length: maxId + 1 }, (_, id) => ({ id, cells: [], centerIds: [], adjacent: new Map(), area: 0 })); for (let i = 0; i < SIZE; i++) { const id = compartmentId[i]; if (id >= 0 && units[id]) units[id].cells.push(i); } for (const unit of units) { if (!unit.cells.length) { unit.area = 0; continue; } const counts = new Map(); for (const ci of unit.cells) counts.set(cellClass[ci], (counts.get(cellClass[ci]) || 0) + 1); let klass = -1, best = -1; for (const [k, count] of counts) if (count > best) { best = count; klass = k; } unit.classId = klass; unit.dominantLandscapeClass = klass; refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); let riverExposure = 0; for (const ci of unit.cells) riverExposure += river[ci] + (fields.flowAccum?.[ci] || 0) * 0.45; unit.riverExposure = riverExposure / Math.max(1, unit.area); } return units; } function splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea) { const queue = []; for (const unit of [...compartments]) { if (!unit || unit.area === 0 || !unit.cells?.length) continue; const unitCellSet = new Set(unit.cells); const seen = new Set(); const components = []; for (const start of unit.cells) { if (seen.has(start) || compartmentId[start] !== unit.id) continue; const cells = []; queue.length = 0; queue.push(start); seen.add(start); for (let q = 0; q < queue.length; q++) { const cur = queue[q]; cells.push(cur); const [x, y] = xyOf(cur); for (const [nx, ny] of neighbors4(x, y)) { const ni = indexOf(nx, ny); if (!prefectureMask[ni] || sea[ni] || seen.has(ni) || compartmentId[ni] !== unit.id || !unitCellSet.has(ni)) continue; seen.add(ni); queue.push(ni); } } components.push(cells); } if (components.length <= 1) continue; components.sort((a, b) => b.length - a.length); unit.cells = components[0]; for (const extra of components.slice(1)) { const newId = compartments.length; for (const ci of extra) compartmentId[ci] = newId; compartments.push({ id: newId, cells: extra, centerIds: [], adjacent: new Map(), classId: unit.classId, dominantLandscapeClass: unit.dominantLandscapeClass }); } } } function renumberCompartments(compartmentId, compartments, prefectureMask, sea) { const active = compartments.filter((unit) => unit && unit.area > 0 && unit.cells?.length); const idMap = new Map(); active.forEach((unit, newId) => idMap.set(unit.id, newId)); for (let i = 0; i < SIZE; i++) { const id = compartmentId[i]; if (!prefectureMask[i] || sea[i]) compartmentId[i] = -1; else if (idMap.has(id)) compartmentId[i] = idMap.get(id); } active.forEach((unit, newId) => { unit.id = newId; unit.centerIds = []; }); return active; } function refreshAllCompartmentStats(compartments, fields) { const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, river, flowAccum } = fields; for (const unit of compartments) { if (!unit || unit.area === 0 || !unit.cells?.length) continue; refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); let riverExposure = 0; const counts = new Map(); for (const ci of unit.cells) { riverExposure += river[ci] + (flowAccum?.[ci] || 0) * 0.45; if (unit._cellClass) counts.set(unit._cellClass[ci], (counts.get(unit._cellClass[ci]) || 0) + 1); } unit.riverExposure = riverExposure / Math.max(1, unit.area); } } function splitNaturalCompartmentCompact(unit, newId, compartmentId, fields, seed) { if (!unit || unit.area < 20) return null; const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum, cellClass } = fields; const minPart = Math.max(7, Math.min(30, Math.floor(unit.area * 0.18))); let cx = unit.x || 0, cy = unit.y || 0; let first = -1, second = -1, bestA = -INF, bestB = -INF; const elongated = (unit.elongation || 1) > 2.3; const horizontal = (unit.width || 0) >= (unit.height || 0); for (const i of unit.cells) { const [x, y] = xyOf(i); const centerDist = Math.hypot(x - cx, y - cy); const axis = elongated ? Math.abs((horizontal ? x - cx : y - cy)) / Math.max(1, horizontal ? unit.width : unit.height) : 0; const interior = 1 - (naturalBarrierScore[i] || 0); const score = centerDist * 0.13 + axis * 1.1 + interior * 0.35 + hashSeededTie(x, y, seed) * 0.08 - ridgeField[i] * 0.10; if (score > bestA) { bestA = score; first = i; } } if (first < 0) return null; const [fx, fy] = xyOf(first); for (const i of unit.cells) { const [x, y] = xyOf(i); const d = Math.hypot(x - fx, y - fy); const interior = 1 - (naturalBarrierScore[i] || 0); const score = d * 0.20 + interior * 0.38 + hashSeededTie(x, y, seed + 31) * 0.08 - ridgeField[i] * 0.08; if (score > bestB) { bestB = score; second = i; } } if (second < 0 || second === first) return null; const cellSet = new Set(unit.cells); const owner = new Int8Array(SIZE); owner.fill(-1); const dist = new Float32Array(SIZE); dist.fill(INF); const heap = new MinHeap(); for (const [source, sourceOwner] of [[first, 0], [second, 1]]) { owner[source] = sourceOwner; dist[source] = 0; heap.push({ i: source, f: 0, owner: sourceOwner }); } while (heap.length > 0) { const cur = heap.pop(); if (!cur || cur.f > dist[cur.i] + 1e-5) continue; const [x, y] = xyOf(cur.i); for (const [nx, ny, step] of neighbors4(x, y)) { const ni = indexOf(nx, ny); if (!cellSet.has(ni)) continue; const nd = cur.f + naturalStepCost(cur.i, ni, cellClass, fields) * step; if (nd < dist[ni]) { dist[ni] = nd; owner[ni] = cur.owner; heap.push({ i: ni, f: nd, owner: cur.owner }); } } } const aCells = [], bCells = []; for (const ci of unit.cells) (owner[ci] === 1 ? bCells : aCells).push(ci); if (aCells.length < minPart || bCells.length < minPart) return null; unit.cells = aCells; for (const ci of bCells) compartmentId[ci] = newId; const newUnit = { id: newId, cells: bCells, centerIds: [], adjacent: new Map(), classId: unit.classId, dominantLandscapeClass: unit.dominantLandscapeClass }; refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); refreshCompartmentStats(newUnit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); return newUnit; } function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) { const progress = typeof options.progress === "function" ? options.progress : null; const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse); const cellClass = new Int16Array(SIZE); cellClass.fill(-1); for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) cellClass[i] = classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse); const fields = { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, naturalBarrierScore, cellClass }; const landComponents = collectLandComponents(prefectureMask, sea); const landArea = landComponents.reduce((sum, cells) => sum + cells.length, 0); const requestedTarget = options.targetCompartmentCount || clamp(Math.round(landArea / 34), 40, 360); const targetCount = clamp(Math.round(requestedTarget), Math.min(1, landArea), Math.max(1, Math.floor(landArea / 8))); const { seeds } = chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, options.seed || 0); progress?.(`natural seeds chosen: ${seeds.length}/${targetCount}`); const compartmentId = new Int32Array(SIZE); compartmentId.fill(-1); const dist = new Float32Array(SIZE); dist.fill(INF); const heap = new MinHeap(); seeds.forEach((i, id) => { compartmentId[i] = id; dist[i] = 0; heap.push({ i, f: 0, id }); }); while (heap.length > 0) { const cur = heap.pop(); if (!cur || cur.f > dist[cur.i] + 1e-5) continue; const [x, y] = xyOf(cur.i); for (const [nx, ny, step] of neighbors4(x, y)) { const ni = indexOf(nx, ny); if (!prefectureMask[ni] || sea[ni]) continue; const nd = cur.f + naturalStepCost(cur.i, ni, cellClass, fields) * step; if (nd < dist[ni]) { dist[ni] = nd; compartmentId[ni] = cur.id; heap.push({ i: ni, f: nd, id: cur.id }); } } } for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && compartmentId[i] < 0) compartmentId[i] = 0; progress?.("natural seeded growth complete"); let compartments = buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields); rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); mergeTinyLandscapeUnits(compartmentId, compartments, 9); progress?.(`natural post-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`); splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea); compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea); refreshAllCompartmentStats(compartments, fields); rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); progress?.(`natural pre-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`); const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(28, Math.round(landArea / Math.max(1, targetCount) * 1.55)); let guard = Math.max(60, targetCount * 2); while (guard-- > 0) { let active = compartments.filter((unit) => unit && unit.area > 0); const needMore = active.length < targetCount; const worst = active .filter((unit) => unit.area >= 20 && (unit._splitRejected || 0) < 3 && (needMore || unit.area > maxNaturalCompartmentArea * 1.18 || (unit.elongation || 1) > 4.2)) .sort((a, b) => { const sa = (a.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (a.elongation || 1) - 3.0) * 1.2; const sb = (b.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (b.elongation || 1) - 3.0) * 1.2; return sb - sa; })[0]; if (!worst) break; const newUnit = splitNaturalCompartmentCompact(worst, compartments.length, compartmentId, fields, (options.seed || 0) + guard * 97); if (!newUnit) { worst._splitRejected = (worst._splitRejected || 0) + 1; if (worst._splitRejected > 2) worst.elongation = Math.min(worst.elongation || 1, 3.1); if (!needMore) break; continue; } compartments.push(newUnit); if (compartments.filter((unit) => unit && unit.area > 0).length >= targetCount && newUnit.area <= maxNaturalCompartmentArea) { const stillBad = compartments.some((unit) => unit && unit.area >= 20 && ( unit.area > maxNaturalCompartmentArea * 1.35 || ((unit.elongation || 1) > 4.2 && unit.area > 28) )); if (!stillBad) break; } } splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea); compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea); refreshAllCompartmentStats(compartments, fields); rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); return { compartmentId, compartments, naturalBarrierScore }; } export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) { return buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse, options); const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse); const compartmentId = new Int32Array(SIZE); compartmentId.fill(-1); const cellClass = new Int16Array(SIZE); cellClass.fill(-1); for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) cellClass[i] = classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse); const compartments = []; const queue = []; for (let i = 0; i < SIZE; i++) { if (cellClass[i] < 0 || compartmentId[i] >= 0) continue; const id = compartments.length; const startClass = cellClass[i]; const cells = []; let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = 0, coastalExposure = 0, basinIdentity = 0, valleyIdentity = 0; let minX = MAP_W, minY = MAP_H, maxX = 0, maxY = 0; queue.length = 0; queue.push(i); compartmentId[i] = id; for (let q = 0; q < queue.length; q++) { const cur = queue[q]; const [x, y] = xyOf(cur); cells.push(cur); sx += x; sy += y; pop += populationDensity[cur]; minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); urbanWeight += urbanBoundaryPenalty(cur, populationDensity, landuse); ridgeExposure += ridgeField[cur]; riverExposure += river[cur] + flowAccum[cur] * 0.45; coastalExposure += coastalLowland[cur]; basinIdentity += basinField[cur]; valleyIdentity += valleyField[cur]; for (const [nx, ny] of neighbors4(x, y)) { const ni = indexOf(nx, ny); if (compartmentId[ni] >= 0 || cellClass[ni] < 0) continue; const edgeBarrier = (naturalBarrierScore[cur] + naturalBarrierScore[ni]) * 0.5; if (!canShareNaturalCompartment(cur, ni, startClass, cellClass[ni], edgeBarrier, river, flowAccum, valleyField, populationDensity, landuse)) continue; compartmentId[ni] = id; queue.push(ni); } } const area = cells.length; const unit = { id, cells, area, x: sx / area, y: sy / area, classId: startClass, dominantLandscapeClass: startClass, minX, minY, maxX, maxY, width: maxX - minX + 1, height: maxY - minY + 1, elongation: Math.max(maxX - minX + 1, maxY - minY + 1) / Math.max(1, Math.min(maxX - minX + 1, maxY - minY + 1)), population: pop, urbanWeight: urbanWeight / area, ridgeExposure: ridgeExposure / area, riverExposure: riverExposure / area, coastalExposure: coastalExposure / area, basinIdentity: basinIdentity / area, valleyIdentity: valleyIdentity / area, centerIds: [], adjacent: new Map(), }; unit.lowlandFitness = cells.reduce((sum, ci) => sum + lowlandCompartmentFitness(ci, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse), 0) / area; unit.mountainFitness = cells.reduce((sum, ci) => sum + mountainCompartmentFitness(ci, elevation, slope, ridgeField, populationDensity, landuse), 0) / area; compartments.push(unit); } rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); mergeTinyLandscapeUnits(compartmentId, compartments, 12); rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); const targetCount = options.targetCompartmentCount || 0; if (targetCount > 0) { const fields = { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum }; const landArea = compartments.reduce((sum, unit) => sum + (unit.area || 0), 0); const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(34, Math.round(landArea / Math.max(1, targetCount) * 1.65)); const splitScore = (unit) => { const elongated = Math.max(0, (unit.elongation || 1) - 2.1); const areaPressure = unit.area / Math.max(1, maxNaturalCompartmentArea); const settled = (unit.lowlandFitness || 0) * 0.65 + (unit.urbanWeight || 0) * 0.35; return areaPressure * 2.2 + elongated * 1.4 + settled - (unit.mountainFitness || 0) * 0.20; }; let guard = Math.max(targetCount * 4, 80); while (compartments.filter((unit) => unit.area > 0).length < targetCount && guard-- > 0) { const candidates = compartments .filter((unit) => unit.area > 0 && unit.area >= 24 && ((unit.lowlandFitness || 0) > 0.18 || unit.area > maxNaturalCompartmentArea * 1.20 || (unit.elongation || 1) > 2.8)) .sort((a, b) => splitScore(b) - splitScore(a)); const target = candidates[0]; if (!target) break; const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard); if (!newUnit) { target._splitRejected = (target._splitRejected || 0) + 1; target.elongation = Math.max(1, (target.elongation || 1) * 0.72); if (target._splitRejected > 2) target.area = target.cells.length; continue; } compartments.push(newUnit); if (compartments.filter((unit) => unit.area > 0).length % 12 === 0) rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); } guard = Math.max(targetCount * 2, 60); while (guard-- > 0) { const target = compartments .filter((unit) => unit.area > 0 && unit.area >= 24 && (unit.area > maxNaturalCompartmentArea * 1.55 || ((unit.elongation || 1) > 3.2 && unit.area > maxNaturalCompartmentArea * 0.85))) .sort((a, b) => splitScore(b) - splitScore(a))[0]; if (!target) break; const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard + 991); if (!newUnit) { target.elongation = Math.max(1, (target.elongation || 1) * 0.70); break; } compartments.push(newUnit); if (compartments.filter((unit) => unit.area > 0).length % 12 === 0) rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); } rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); } return { compartmentId, compartments, naturalBarrierScore }; } function buildLandscapeUnits(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse) { const unitId = new Int32Array(SIZE); unitId.fill(-1); const cellClass = new Int16Array(SIZE); cellClass.fill(-1); for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) cellClass[i] = classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse); const units = []; const queue = []; for (let i = 0; i < SIZE; i++) { if (cellClass[i] < 0 || unitId[i] >= 0) continue; const id = units.length; const klass = cellClass[i]; const cells = []; let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = 0; queue.length = 0; queue.push(i); unitId[i] = id; for (let q = 0; q < queue.length; q++) { const cur = queue[q]; const [x, y] = xyOf(cur); cells.push(cur); sx += x; sy += y; pop += populationDensity[cur]; urbanWeight += urbanBoundaryPenalty(cur, populationDensity, landuse); ridgeExposure += ridgeField[cur]; riverExposure += river[cur] + flowAccum[cur] * 0.45; for (const [nx, ny] of neighbors4(x, y)) { const ni = indexOf(nx, ny); if (unitId[ni] >= 0 || cellClass[ni] !== klass) continue; unitId[ni] = id; queue.push(ni); } } units.push({ id, classId: klass, cells, area: cells.length, x: sx / cells.length, y: sy / cells.length, population: pop, urbanWeight: urbanWeight / cells.length, ridgeExposure: ridgeExposure / cells.length, riverExposure: riverExposure / cells.length, adjacent: new Map(), centerIds: [], owner: -1 }); } const targetScore = new Float32Array(SIZE); for (let i = 0; i < SIZE; i++) if (cellClass[i] >= 0) targetScore[i] = terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse); rebuildLandscapeUnitAdjacency(unitId, units, targetScore, prefectureMask, sea); mergeTinyLandscapeUnits(unitId, units, 10); rebuildLandscapeUnitAdjacency(unitId, units, targetScore, prefectureMask, sea); return { unitId, units, targetScore }; } function rebuildLandscapeUnitAdjacency(unitId, units, targetScore, prefectureMask, sea) { for (const unit of units) unit.adjacent = new Map(); 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]) continue; const a = unitId[i]; if (a < 0 || !units[a] || units[a].area === 0) continue; 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]) continue; const b = unitId[ni]; if (b < 0 || b === a || !units[b] || units[b].area === 0) continue; const v = (targetScore[i] + targetScore[ni]) * 0.5; const keyA = units[a].adjacent.get(b) || { count: 0, target: 0 }; keyA.count++; keyA.target += v; units[a].adjacent.set(b, keyA); const keyB = units[b].adjacent.get(a) || { count: 0, target: 0 }; keyB.count++; keyB.target += v; units[b].adjacent.set(a, keyB); } } } } function mergeTinyLandscapeUnits(unitId, units, minArea = 10) { for (const unit of units) { if (unit.area === 0 || unit.area >= minArea) continue; let bestId = -1, bestScore = -INF; for (const [otherId, edge] of unit.adjacent) { const other = units[otherId]; if (!other || other.area === 0) continue; const score = edge.count * 3 + (other.classId === unit.classId ? 8 : 0) + other.area * 0.01 - edge.target / Math.max(1, edge.count); if (score > bestScore) { bestScore = score; bestId = otherId; } } if (bestId < 0) continue; const target = units[bestId]; for (const i of unit.cells) { unitId[i] = bestId; target.cells.push(i); } const totalArea = target.area + unit.area; target.x = (target.x * target.area + unit.x * unit.area) / totalArea; target.y = (target.y * target.area + unit.y * unit.area) / totalArea; target.population += unit.population; target.urbanWeight = (target.urbanWeight * target.area + unit.urbanWeight * unit.area) / totalArea; target.ridgeExposure = (target.ridgeExposure * target.area + unit.ridgeExposure * unit.area) / totalArea; target.riverExposure = (target.riverExposure * target.area + unit.riverExposure * unit.area) / totalArea; target.area = totalArea; unit.area = 0; unit.cells = []; } } function naturalOwnershipAffinity(unit, neighbor, edge) { const boundaryTarget = edge.target / Math.max(1, edge.count); const sameClass = unit.classId === neighbor.classId ? 1.0 : 0; const sameGroup = naturalGroupKey(unit) === naturalGroupKey(neighbor) ? 1.1 : 0; const bothUrban = unit.classId <= 3 && neighbor.classId <= 3; const bothCorridor = [5, 6, 7, 10].includes(unit.classId) && [5, 6, 7, 10].includes(neighbor.classId); const urbanContinuity = bothUrban ? 1.35 : (unit.urbanWeight + neighbor.urbanWeight) > 0.75 && Math.abs(unit.urbanWeight - neighbor.urbanWeight) < 0.35 ? 0.58 : 0; const strongDividerPenalty = boundaryTarget * (edge.count > 2 ? 2.8 : 1.8); return edge.count * 0.55 + sameClass + sameGroup + urbanContinuity + (bothCorridor ? 0.72 : 0) - strongDividerPenalty; } function compartmentCrossingCost(unit, neighbor, edge) { const boundaryScore = edge.target / Math.max(1, edge.count); const sameClass = unit.classId === neighbor.classId ? 1 : 0; const sameGroup = naturalGroupKey(unit) === naturalGroupKey(neighbor) ? 1 : 0; const lowlandContinuity = Math.min(unit.lowlandFitness || 0, neighbor.lowlandFitness || 0); const urbanContinuity = Math.min(unit.urbanWeight || 0, neighbor.urbanWeight || 0); const mountainPenalty = Math.max(unit.mountainFitness || 0, neighbor.mountainFitness || 0); const ridgePenalty = Math.max(unit.ridgeExposure || 0, neighbor.ridgeExposure || 0); return Math.max(0.18, 1.0 + boundaryScore * 5.2 + mountainPenalty * 1.8 + ridgePenalty * 0.9 - sameClass * 0.45 - sameGroup * 0.35 - lowlandContinuity * 1.15 - urbanContinuity * 0.70 - Math.min(1.0, edge.count / 12) * 0.25 ); } function graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options = {}) { const owner = new Int16Array(compartments.length); const dist = new Float32Array(compartments.length); owner.fill(-1); dist.fill(INF); const heap = new MinHeap(); for (let id = 0; id < adminCenters.length; id++) { const center = adminCenters[id]; if (!center || !inside(center.x, center.y)) continue; const compIndex = compartmentId[indexOf(center.x, center.y)]; const unit = compartments[compIndex]; if (compIndex < 0 || !unit || unit.area === 0) continue; unit.centerIds.push(id); if (dist[compIndex] > 0) { dist[compIndex] = 0; owner[compIndex] = id; heap.push({ i: compIndex, f: 0, owner: id }); } } while (heap.length > 0) { const cur = heap.pop(); if (!cur || cur.f > dist[cur.i] + 1e-5) continue; const unit = compartments[cur.i]; if (!unit || unit.area === 0) continue; const center = adminCenters[cur.owner]; for (const [neighborId, edge] of unit.adjacent) { const neighbor = compartments[neighborId]; if (!neighbor || neighbor.area === 0) continue; const crossing = compartmentCrossingCost(unit, neighbor, edge); const euclideanTie = center ? Math.hypot(neighbor.x - center.x, neighbor.y - center.y) * 0.006 : 0; const hinterlandDrag = (neighbor.mountainFitness || 0) > 0.64 && (neighbor.population || 0) < 6 ? 0.35 : 0; const next = cur.f + crossing + euclideanTie + hinterlandDrag; if (next + 1e-5 < dist[neighborId]) { dist[neighborId] = next; owner[neighborId] = cur.owner; heap.push({ i: neighborId, f: next, owner: cur.owner }); } else if (Math.abs(next - dist[neighborId]) < 0.08 && owner[neighborId] >= 0) { const oldCenter = adminCenters[owner[neighborId]]; const oldD = oldCenter ? Math.hypot(neighbor.x - oldCenter.x, neighbor.y - oldCenter.y) : INF; const newD = center ? Math.hypot(neighbor.x - center.x, neighbor.y - center.y) : INF; if (newD < oldD - 1.5 || (newD < oldD + 1.5 && cur.owner < owner[neighborId])) owner[neighborId] = cur.owner; } } } for (const unit of compartments) { if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue; let bestOwner = -1, bestScore = INF; for (const [neighborId, edge] of unit.adjacent) { if (owner[neighborId] < 0) continue; const neighbor = compartments[neighborId]; const score = compartmentCrossingCost(unit, neighbor, edge) + (neighbor?.area || 0) * -0.001; if (score < bestScore) { bestScore = score; bestOwner = owner[neighborId]; } } owner[unit.id] = bestOwner >= 0 ? bestOwner : 0; } return owner; } function compartmentMunicipalityMetrics(compartments, owner, targetMunicipalityCount = 0, targetCompartmentCount = 0) { const counts = new Map(); let active = 0; for (const unit of compartments) { if (!unit || unit.area === 0) continue; active++; const id = owner[unit.id]; if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); } const actual = counts.size; const singles = [...counts.values()].filter((value) => value === 1).length; return { targetMunicipalityCount, actualMunicipalityCount: actual, targetNaturalCompartmentCount: targetCompartmentCount, naturalCompartmentCount: active, compartmentCount: active, averageCompartmentsPerMunicipality: actual ? active / actual : 0, singleCompartmentMunicipalityRatio: actual ? singles / actual : 0, }; } function averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore) { 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; for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { const ni = indexOf(nx, ny); if (!prefectureMask[ni] || sea[ni] || adminId[ni] < 0 || adminId[ni] === adminId[i]) continue; sum += (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5; count++; } } } return count ? sum / count : 0; } function assignCompartmentsToAdminOwners(compartments, compartmentId, adminCenters, naturalBarrierScore, prefectureMask, sea) { const owner = new Int16Array(compartments.length); owner.fill(-1); for (let id = 0; id < adminCenters.length; id++) { const center = adminCenters[id]; if (!center || !inside(center.x, center.y)) continue; const compIndex = compartmentId[indexOf(center.x, center.y)]; if (compIndex >= 0 && compartments[compIndex]?.area > 0) { const unit = compartments[compIndex]; unit.centerIds.push(id); owner[compIndex] = id; } } for (let pass = 0; pass < compartments.length + 8; pass++) { let changed = 0; for (const unit of compartments) { if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue; let bestOwner = -1; let bestScore = -INF; for (const [neighborId, edge] of unit.adjacent) { const neighborOwner = owner[neighborId]; if (neighborOwner < 0) continue; const neighbor = compartments[neighborId]; if (!neighbor || neighbor.area === 0) continue; const center = adminCenters[neighborOwner]; const d = center ? Math.hypot(unit.x - center.x, unit.y - center.y) : 0; const score = naturalOwnershipAffinity(unit, neighbor, edge) - d * 0.006 + Math.min(0.9, Math.sqrt(Math.max(1, neighbor.area)) * 0.020); if (score > bestScore) { bestScore = score; bestOwner = neighborOwner; } } const accept = unit.classId <= 3 ? bestScore > -0.35 : unit.classId === 8 || unit.classId === 9 ? bestScore > -1.05 : bestScore > -0.70; if (bestOwner >= 0 && accept) { owner[unit.id] = bestOwner; changed++; } } if (changed === 0) break; } for (const unit of compartments) { if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue; let bestId = -1; let bestScore = -INF; for (let id = 0; id < adminCenters.length; id++) { const center = adminCenters[id]; if (!center || !inside(center.x, center.y)) continue; const centerComp = compartments[compartmentId[indexOf(center.x, center.y)]]; const sameGroup = centerComp && naturalGroupKey(centerComp) === naturalGroupKey(unit) ? 2.3 : 0; const sameClass = centerComp && centerComp.classId === unit.classId ? 0.8 : 0; const urbanFit = unit.urbanWeight > 0.55 && centerComp?.urbanWeight > 0.55 ? 1.3 : 0; const d = Math.hypot(unit.x - center.x, unit.y - center.y); const score = sameGroup + sameClass + urbanFit - d * 0.020 - unit.ridgeExposure * 0.16; if (score > bestScore) { bestScore = score; bestId = id; } } owner[unit.id] = bestId >= 0 ? bestId : 0; } return owner; } export function extractCompartmentBorders(compartmentId, 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] || compartmentId[i] < 0) continue; const a = compartmentId[i]; if (x + 1 < MAP_W) { const ni = indexOf(x + 1, y); if (prefectureMask[ni] && !sea[ni] && compartmentId[ni] >= 0 && compartmentId[ni] !== a) segments.push([[x + 1, y], [x + 1, y + 1]]); } if (y + 1 < MAP_H) { const ni = indexOf(x, y + 1); if (prefectureMask[ni] && !sea[ni] && compartmentId[ni] >= 0 && compartmentId[ni] !== a) segments.push([[x, y + 1], [x + 1, y + 1]]); } } } return segments; } export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], options = {}) { const progress = typeof options.progress === "function" ? options.progress : null; const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse, options); progress?.("natural compartments built"); const adminId = new Int16Array(SIZE); adminId.fill(-1); const owner = graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options); progress?.("natural compartments assigned"); for (const unit of compartments) { const assigned = owner[unit.id]; if (assigned < 0) continue; for (const i of unit.cells) adminId[i] = assigned; } for (let i = 0; i < SIZE; i++) { if (!prefectureMask[i] || sea[i] || adminId[i] >= 0) continue; const comp = compartments[compartmentId[i]]; adminId[i] = comp && owner[comp.id] >= 0 ? owner[comp.id] : 0; } repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse); progress?.("natural topology repaired"); const activeCompartments = compartments.filter((unit) => unit.area > 0); const relationMetrics = compartmentMunicipalityMetrics(compartments, owner, options.targetMunicipalityCount || adminCenters.length, options.targetCompartmentCount || 0); return { adminId, compartmentId, compartments, naturalBarrierScore, debug: { ...relationMetrics, compartmentBorders: extractCompartmentBorders(compartmentId, prefectureMask, sea), averageCompartmentArea: activeCompartments.length ? activeCompartments.reduce((sum, unit) => sum + unit.area, 0) / activeCompartments.length : 0, maxCompartmentArea: activeCompartments.length ? Math.max(...activeCompartments.map((unit) => unit.area || 0)) : 0, maxCompartmentElongation: activeCompartments.length ? Math.max(...activeCompartments.map((unit) => unit.elongation || 1)) : 1, worstNaturalCompartments: activeCompartments .map((unit) => ({ id: unit.id, area: unit.area || 0, width: unit.width || 0, height: unit.height || 0, elongation: unit.elongation || 1, classId: unit.classId, x: Math.round(unit.x || 0), y: Math.round(unit.y || 0) })) .sort((a, b) => (b.elongation * Math.sqrt(Math.max(1, b.area))) - (a.elongation * Math.sqrt(Math.max(1, a.area)))) .slice(0, 8), finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore), voronoiLikeRateBefore: 0, voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore), }, }; } function weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore) { let weak = 0; let total = 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; for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { const ni = indexOf(nx, ny); const a = adminId[i], b = adminId[ni]; if (!prefectureMask[ni] || sea[ni] || a < 0 || b < 0 || a === b) continue; total++; const ca = adminCenters[a], cb = adminCenters[b]; if (!ca || !cb) continue; const mx = (x + nx) * 0.5, my = (y + ny) * 0.5; const nearBisector = Math.abs(Math.hypot(mx - ca.x, my - ca.y) - Math.hypot(mx - cb.x, my - cb.y)) < 4.0; if (nearBisector && (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5 < 0.38) weak++; } } } return total ? weak / total : 0; } export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = []) { const before = new Int16Array(adminId); const initialNaturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse); const beforeVoronoiLikeRate = weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, initialNaturalBarrierScore); const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse); if (compartments.length === 0) return; for (let id = 0; id < adminCenters.length; id++) { const center = adminCenters[id]; if (!center || !inside(center.x, center.y)) continue; const unit = compartments[compartmentId[indexOf(center.x, center.y)]]; if (unit) unit.centerIds.push(id); } const owner = new Int16Array(compartments.length); owner.fill(-1); for (const unit of compartments) { if (unit.area === 0 || unit.centerIds.length === 0) continue; owner[unit.id] = unit.centerIds[0]; } for (let pass = 0; pass < compartments.length + 4; pass++) { let changed = 0; for (const unit of compartments) { if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue; let bestOwner = -1; let bestScore = -INF; for (const [neighborId, edge] of unit.adjacent) { const neighborOwner = owner[neighborId]; if (neighborOwner < 0) continue; const neighbor = compartments[neighborId]; if (!neighbor || neighbor.area === 0) continue; const score = naturalOwnershipAffinity(unit, neighbor, edge) + Math.min(0.8, Math.sqrt(Math.max(1, neighbor.area)) * 0.018); if (score > bestScore) { bestScore = score; bestOwner = neighborOwner; } } const accept = unit.classId <= 3 ? bestScore > -0.15 : unit.classId === 8 || unit.classId === 9 ? bestScore > -0.80 : bestScore > -0.45; if (bestOwner >= 0 && accept) { owner[unit.id] = bestOwner; changed++; } } if (changed === 0) break; } for (const unit of compartments) { if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue; let bestId = -1, bestScore = -INF; for (let id = 0; id < adminCenters.length; id++) { const center = adminCenters[id]; if (!center || !inside(center.x, center.y)) continue; const centerComp = compartments[compartmentId[indexOf(center.x, center.y)]]; const sameGroup = centerComp && naturalGroupKey(centerComp) === naturalGroupKey(unit) ? 2.4 : 0; const sameClass = centerComp && centerComp.classId === unit.classId ? 0.9 : 0; const urbanFit = unit.urbanWeight > 0.55 && centerComp?.urbanWeight > 0.55 ? 1.2 : 0; const d = Math.hypot(unit.x - center.x, unit.y - center.y); const score = sameGroup + sameClass + urbanFit - d * 0.018 - unit.ridgeExposure * 0.18; if (score > bestScore) { bestScore = score; bestId = id; } } owner[unit.id] = bestId >= 0 ? bestId : 0; } for (const unit of compartments) { const assigned = owner[unit.id]; if (assigned >= 0) for (const i of unit.cells) adminId[i] = assigned; } for (let i = 0; i < SIZE; i++) { if (!prefectureMask[i] || sea[i] || adminId[i] >= 0) continue; const comp = compartments[compartmentId[i]]; adminId[i] = comp && owner[comp.id] >= 0 ? owner[comp.id] : 0; } for (let id = 0; id < adminCenters.length; id++) { const center = adminCenters[id]; if (!center || !inside(center.x, center.y)) continue; for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) { if (Math.hypot(dx, dy) > 2) continue; const x = center.x + dx, y = center.y + dy; if (!inside(x, y)) continue; const i = indexOf(x, y); if (prefectureMask[i] && !sea[i]) adminId[i] = id; } } repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse); let changedCells = 0; for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== adminId[i]) changedCells++; const activeCompartments = compartments.filter((unit) => unit.area > 0); applyLandscapeUnitAdminPartition.lastDebug = { compartmentCount: activeCompartments.length, averageCompartmentArea: activeCompartments.length ? activeCompartments.reduce((sum, unit) => sum + unit.area, 0) / activeCompartments.length : 0, changedAfterNaturalCompartmentPartition: changedCells, finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore), voronoiLikeRateBefore: beforeVoronoiLikeRate, voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore), }; } export function snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse, adminCenters = [], protectedPoints = [], passes = 6) { const targetScore = new Float32Array(SIZE); for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) targetScore[i] = terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse); const protectedMask = buildAdminProtectedMask(adminId, prefectureMask, sea, adminCenters, protectedPoints, populationDensity, landuse); const band = buildBoundaryBand(adminId, prefectureMask, sea, 5); const adminIds = [...new Set([...adminId].filter((id) => id >= 0))]; const centerDist = buildCenterDistanceFields(adminIds, adminCenters, prefectureMask, sea); let current = new Int16Array(adminId); for (let pass = 0; pass < passes; pass++) { const next = new Int16Array(current); let changed = 0; for (let y = 1; y < MAP_H - 1; y++) { for (let x = 1; x < MAP_W - 1; x++) { const i = indexOf(x, y); const own = current[i]; if (!band[i] || protectedMask[i] || !prefectureMask[i] || sea[i] || own < 0) continue; if (!isAdminBoundaryCell(current, prefectureMask, sea, x, y, true)) continue; const candidates = new Set(); for (const [nx, ny] of neighbors8(x, y)) { const ni = indexOf(nx, ny); if (prefectureMask[ni] && !sea[ni] && current[ni] >= 0 && current[ni] !== own) candidates.add(current[ni]); } if (candidates.size === 0) continue; const currentEnergy = localBoundaryEnergy(current, i, own, targetScore, centerDist, populationDensity, landuse, river, valleyField); let bestId = own, bestEnergy = currentEnergy; for (const candidate of candidates) { const candidateEnergy = localBoundaryEnergy(current, i, candidate, targetScore, centerDist, populationDensity, landuse, river, valleyField); const threshold = 0.18 + (targetScore[i] < 0.36 ? 0.16 : 0) + urbanBoundaryPenalty(i, populationDensity, landuse) * 0.25; if (bestEnergy - candidateEnergy > threshold) { bestEnergy = candidateEnergy; bestId = candidate; } } if (bestId !== own) { next[i] = bestId; changed++; } } } current = next; if (changed === 0) break; } adminId.set(current); repairAdminTopology(adminId, prefectureMask, sea, adminCenters, targetScore, populationDensity, landuse); } export function splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], settlements = []) { const before = new Int16Array(adminId); const area = new Map(); const lowland = new Map(); const mountain = new Map(); for (let i = 0; i < SIZE; i++) { if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; const id = adminId[i]; area.set(id, (area.get(id) || 0) + 1); const living = (plain[i] || 0) * 0.42 + (agriculture[i] || 0) * 0.28 + basinField[i] * 0.20 + coastalLowland[i] * 0.20 + valleyField[i] * 0.12; const rough = ridgeField[i] * 0.54 + slope[i] * 0.36 + Math.max(0, elevation[i] - 0.58) * 0.38; lowland.set(id, (lowland.get(id) || 0) + living); mountain.set(id, (mountain.get(id) || 0) + rough); } const areas = [...area.values()].sort((a, b) => a - b); const median = areas.length ? areas[Math.floor(areas.length / 2)] : 0; if (!median) return { changedCells: 0, splitMunicipalities: 0 }; const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse); const unitOwner = new Int16Array(compartments.length); unitOwner.fill(-1); for (const unit of compartments) { if (!unit || unit.area === 0) continue; const counts = new Map(); for (const i of unit.cells) { const id = adminId[i]; if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); } let bestId = -1, best = -1; for (const [id, count] of counts) if (count > best) { best = count; bestId = id; } unitOwner[unit.id] = bestId; } const adminCenterIndex = new Map(); for (let id = 0; id < adminCenters.length; id++) { const c = adminCenters[id]; if (c && inside(c.x, c.y)) adminCenterIndex.set(id, indexOf(c.x, c.y)); } let splitMunicipalities = 0; let rejectedMunicipalities = 0; for (const [id, cells] of area) { const averageLowland = (lowland.get(id) || 0) / cells; const averageMountain = (mountain.get(id) || 0) / cells; if (cells < median * 1.85 || averageLowland < 0.24 || averageMountain > 0.48) { if (cells >= median * 1.85) rejectedMunicipalities++; continue; } const localSettlements = settlements.filter((p) => p && inside(p.x, p.y) && adminId[indexOf(p.x, p.y)] === id); const meaningfulNodes = localSettlements.filter((p) => p.kind === "Satellite City" || p.kind === "New Town" || p.kind === "Market Town" || (p.population || 0) >= 30000); if (meaningfulNodes.length < 2) { rejectedMunicipalities++; continue; } let changedHere = 0; for (const unit of compartments) { if (!unit || unit.area === 0 || unitOwner[unit.id] !== id) continue; const centerIndex = adminCenterIndex.get(id); if (centerIndex >= 0 && unit.cells.includes(centerIndex)) continue; if (unit.classId === 8 || unit.classId === 9) continue; let bestNeighbor = -1; let bestScore = -INF; for (const [neighborId, edge] of unit.adjacent) { const neighborOwner = unitOwner[neighborId]; if (neighborOwner < 0 || neighborOwner === id) continue; const boundaryTarget = edge.target / Math.max(1, edge.count); const neighbor = compartments[neighborId]; const nodePull = meaningfulNodes.reduce((best, p) => Math.max(best, 1 / (1 + Math.hypot(p.x - unit.x, p.y - unit.y) / 6)), 0); const score = edge.count * 0.7 + boundaryTarget * 1.4 + nodePull * 1.2 - Math.max(0, (neighbor?.ridgeExposure || 0) - unit.ridgeExposure) * 0.35; if (score > bestScore) { bestScore = score; bestNeighbor = neighborOwner; } } if (bestNeighbor < 0 || bestScore < 2.2) continue; for (const ci of unit.cells) { if (adminId[ci] === id) { adminId[ci] = bestNeighbor; changedHere++; } } } if (changedHere > Math.max(28, cells * 0.035)) splitMunicipalities++; } repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse); let changedCells = 0; for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== adminId[i]) changedCells++; return { changedCells, splitMunicipalities, rejectedMunicipalities }; } export function splitOversizedLowlandMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], settlements = []) { return splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters, settlements); }