import { INF, SIZE, MinHeap, clamp, indexOf, inside, xyOf } from "./mapUtils.js"; import { municipalityAreaById } from "./mapAdminShared.js"; export function estimateUrbanComponentArea(city, prefectureMask, sea, landuse, populationDensity) { if (!city || !inside(city.x, city.y)) return 0; const start = indexOf(city.x, city.y); if (!prefectureMask[start] || sea[start]) return 0; const radius = Math.ceil(Math.max(7, (city.urbanRadius || 6) * 1.7)); const seen = new Uint8Array(SIZE); const queue = [start]; seen[start] = 1; let area = 0; for (let q = 0; q < queue.length; q++) { const cur = queue[q]; const [x, y] = xyOf(cur); const d = Math.hypot(x - city.x, y - city.y); if (d > radius) continue; const urban = (landuse[cur] >= 2 && landuse[cur] <= 4) || landuse[cur] === 7 || landuse[cur] === 8 || populationDensity[cur] > 0.18; if (!urban) continue; area++; for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { const nx = x + dx, ny = y + dy; if (!inside(nx, ny)) continue; const ni = indexOf(nx, ny); if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue; seen[ni] = 1; queue.push(ni); } } return area; } export function terrainSeparationBetween(a, b, ridgeField, river, flowAccum, populationDensity, landuse) { if (!a || !b) return { separatedByBarrier: false, ruralGap: false, averageDensity: 0, maxBarrier: 0 }; const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y))); let maxBarrier = 0; let lowUrbanRun = 0; let bestLowUrbanRun = 0; let densitySum = 0; for (let s = 0; s <= steps; s++) { const t = s / steps; const x = Math.round(a.x + (b.x - a.x) * t); const y = Math.round(a.y + (b.y - a.y) * t); if (!inside(x, y)) continue; const i = indexOf(x, y); const barrier = Math.max(ridgeField[i] * 0.95, river[i] * 0.85, flowAccum[i] * 0.42); maxBarrier = Math.max(maxBarrier, barrier); densitySum += populationDensity[i]; const urban = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.20; if (urban) lowUrbanRun = 0; else { lowUrbanRun++; bestLowUrbanRun = Math.max(bestLowUrbanRun, lowUrbanRun); } } return { separatedByBarrier: maxBarrier > 0.56, ruralGap: bestLowUrbanRun >= 4, averageDensity: densitySum / (steps + 1), maxBarrier, }; } export function classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, ridgeField, river, flowAccum) { let independent = 0; let attached = 0; for (const sat of satelliteCities || []) { if (!sat || !inside(sat.x, sat.y) || !prefectureMask[indexOf(sat.x, sat.y)] || sea[indexOf(sat.x, sat.y)]) continue; const parent = modernCities[sat.parentCityIndex] || modernCities.slice().sort((a, b) => Math.hypot(a.x - sat.x, a.y - sat.y) - Math.hypot(b.x - sat.x, b.y - sat.y))[0]; const parentDistance = parent ? Math.hypot(parent.x - sat.x, parent.y - sat.y) : 99; const separation = terrainSeparationBetween(sat, parent, ridgeField, river, flowAccum, populationDensity, landuse); const urbanArea = estimateUrbanComponentArea(sat, prefectureMask, sea, landuse, populationDensity); const i = indexOf(sat.x, sat.y); const continuousUrban = parent && parentDistance < Math.max(10, (parent.urbanRadius || 12) + (sat.urbanRadius || 5) + 5) && separation.averageDensity > 0.14 && !separation.ruralGap && !separation.separatedByBarrier; const newTownLike = landuse[i] === 7 || (railInfluence2[i] > 0.22 && roadInfluence[i] > 0.12 && (sat.population || 0) < 70000); let municipalityClass = "independentSatelliteMunicipality"; if (continuousUrban && (sat.population || 0) < 90000) municipalityClass = "suburbanDistrictMergedWithParent"; else if (newTownLike && (sat.population || 0) < 85000 && !separation.separatedByBarrier) municipalityClass = "newTownDistrict"; else if ((sat.population || 0) < 42000 && urbanArea < 55 && !separation.separatedByBarrier) municipalityClass = "smallTownAttachedToRuralMunicipality"; else if ((sat.population || 0) >= 60000 && urbanArea >= 42 && (separation.separatedByBarrier || separation.ruralGap || parentDistance > 15)) municipalityClass = "independentSatelliteMunicipality"; sat.municipalityClass = municipalityClass; sat.parentX = parent?.x; sat.parentY = parent?.y; sat.parentAdminHint = -1; sat.distinctUrbanComponentArea = urbanArea; sat.separatedByBarrier = separation.separatedByBarrier || separation.ruralGap; sat.satelliteMinArea = clamp(90 + Math.sqrt(sat.population || 24000) * 0.62 + (sat.urbanRadius || 5) * 12, 80, 360); if (municipalityClass === "independentSatelliteMunicipality") independent++; else attached++; } return { independent, attached }; } export function expandSatelliteMunicipalityCatchment(adminId, satellite, targetAdmin, context) { const { prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities } = context; if (!satellite || targetAdmin < 0 || !inside(satellite.x, satellite.y)) return 0; const start = indexOf(satellite.x, satellite.y); if (!prefectureMask[start] || sea[start]) return 0; const targetAreaBase = clamp(90 + Math.sqrt(satellite.population || 24000) * 0.8 + (satellite.urbanRadius || 5) * 18, 120, 520); const targetArea = satellite.municipalityClass === "smallTownAttachedToRuralMunicipality" ? Math.min(130, targetAreaBase * 0.55) : satellite.municipalityClass === "suburbanDistrictMergedWithParent" || satellite.municipalityClass === "newTownDistrict" ? Math.min(190, targetAreaBase * 0.62) : targetAreaBase; const maxCost = satellite.municipalityClass === "independentSatelliteMunicipality" ? 46 : 32; const heap = new MinHeap(); const best = new Float32Array(SIZE); best.fill(INF); heap.push({ i: start, f: 0 }); best[start] = 0; const claimed = []; while (heap.length > 0 && claimed.length < targetArea) { const cur = heap.pop(); if (!cur || cur.f > best[cur.i] + 1e-5 || cur.f > maxCost) continue; const [x, y] = xyOf(cur.i); const d = Math.hypot(x - satellite.x, y - satellite.y); if (!prefectureMask[cur.i] || sea[cur.i]) continue; let invadesOtherCore = false; for (const city of modernCities || []) { if (!city || (city.population || 0) < 140000) continue; if (Math.hypot(city.x - satellite.x, city.y - satellite.y) < 4) continue; if (Math.hypot(city.x - x, city.y - y) <= Math.max(3.5, (city.coreRadius || 4) * 1.25)) { invadesOtherCore = true; break; } } if (invadesOtherCore) continue; const compatible = d <= (satellite.urbanRadius || 5) * 1.25 || [2, 3, 4, 7, 8].includes(landuse[cur.i]) || populationDensity[cur.i] > 0.12 || roadInfluence[cur.i] > 0.12 || railInfluence2[cur.i] > 0.10 || stationInfluence?.[cur.i] > 0.10 || basinField[cur.i] > 0.22 || valleyField[cur.i] > 0.24 || coastalLowland[cur.i] > 0.20; if (!compatible && claimed.length > targetArea * 0.55) continue; claimed.push(cur.i); for (const [dx, dy, step] of [[1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1], [1, 1, 1.41], [-1, 1, 1.41], [1, -1, 1.41], [-1, -1, 1.41]]) { const nx = x + dx, ny = y + dy; if (!inside(nx, ny)) continue; const ni = indexOf(nx, ny); if (!prefectureMask[ni] || sea[ni]) continue; const barrier = ridgeField[ni] * 5.2 + Math.max(0, elevation[ni] - 0.58) * 4.0 + slope[ni] * 2.2 + (river[ni] > 0.55 || flowAccum[ni] > 0.70 ? 7.5 : river[ni] > 0.30 ? 2.8 : 0); const living = ([2, 3, 4, 7, 8].includes(landuse[ni]) ? 2.2 : 0) + populationDensity[ni] * 2.0 + roadInfluence[ni] * 0.85 + railInfluence2[ni] * 0.95 + (stationInfluence?.[ni] || 0) * 1.2 + basinField[ni] * 0.42 + valleyField[ni] * 0.48 + coastalLowland[ni] * 0.32; const distanceCost = Math.hypot(nx - satellite.x, ny - satellite.y) / Math.max(7, (satellite.urbanRadius || 5) * 1.9); const nd = cur.f + Math.max(0.28, 1.05 + barrier - living + distanceCost) * step; if (nd < best[ni]) { best[ni] = nd; heap.push({ i: ni, f: nd }); } } } let changed = 0; for (const i of claimed) { if (adminId[i] !== targetAdmin) changed++; adminId[i] = targetAdmin; } return changed; } export function cityMinimumMunicipalityArea(city) { const populationArea = Math.sqrt(city.population || 0) * 0.72; const footprintArea = (city.urbanFootprintCells || 0) * 0.42; return clamp(95 + populationArea + footprintArea, 130, (city.population || 0) >= 450000 ? 780 : 520); } export function enforceCityMunicipalityCatchments(adminId, cities, context) { const { prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence } = context; const areaById = municipalityAreaById(adminId, prefectureMask, sea); let changed = 0; let protectedCities = 0; let tooSmall = 0; for (const city of cities || []) { if (!city || (city.population || 0) < 95000 || !inside(city.x, city.y)) continue; const start = indexOf(city.x, city.y); if (!prefectureMask[start] || sea[start]) continue; const targetAdmin = adminId[start]; if (targetAdmin < 0) continue; protectedCities++; const minArea = cityMinimumMunicipalityArea(city); if ((areaById.get(targetAdmin) || 0) >= minArea) continue; tooSmall++; const heap = new MinHeap(); const best = new Float32Array(SIZE); best.fill(INF); heap.push({ i: start, f: 0 }); best[start] = 0; const claimed = []; const maxCost = (city.population || 0) >= 450000 ? 78 : 56; let projectedArea = areaById.get(targetAdmin) || 0; while (heap.length > 0 && projectedArea < minArea) { const cur = heap.pop(); if (!cur || cur.f > best[cur.i] + 1e-5 || cur.f > maxCost) continue; const [x, y] = xyOf(cur.i); if (!prefectureMask[cur.i] || sea[cur.i]) continue; const d = Math.hypot(x - city.x, y - city.y); const compatible = d <= Math.max(5, (city.coreRadius || 3) * 2.0) || [2, 3, 4, 7, 8].includes(landuse[cur.i]) || populationDensity[cur.i] > 0.10 || roadInfluence[cur.i] > 0.10 || railInfluence2[cur.i] > 0.10 || (stationInfluence?.[cur.i] || 0) > 0.10 || valleyField[cur.i] > 0.22 || basinField[cur.i] > 0.20 || coastalLowland[cur.i] > 0.18; if (!compatible && claimed.length > minArea * 0.55) continue; claimed.push(cur.i); if (adminId[cur.i] !== targetAdmin) projectedArea++; for (const [dx, dy, step] of [[1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1], [1, 1, 1.41], [-1, 1, 1.41], [1, -1, 1.41], [-1, -1, 1.41]]) { const nx = x + dx, ny = y + dy; if (!inside(nx, ny)) continue; const ni = indexOf(nx, ny); if (!prefectureMask[ni] || sea[ni]) continue; const majorBarrier = river[ni] > 0.62 || flowAccum[ni] > 0.74 || ridgeField[ni] > 0.70; const barrier = ridgeField[ni] * 5.4 + Math.max(0, elevation[ni] - 0.60) * 4.4 + slope[ni] * 2.6 + (majorBarrier ? 8.5 : river[ni] > 0.34 ? 2.6 : 0); const fit = ([2, 3, 4, 7, 8].includes(landuse[ni]) ? 2.4 : 0) + populationDensity[ni] * 2.2 + roadInfluence[ni] * 0.88 + railInfluence2[ni] * 0.92 + (stationInfluence?.[ni] || 0) * 1.15 + valleyField[ni] * 0.56 + basinField[ni] * 0.42 + coastalLowland[ni] * 0.34; const nd = cur.f + Math.max(0.30, 1.05 + barrier - fit + Math.hypot(nx - city.x, ny - city.y) / Math.max(8, (city.urbanRadius || 7) * 2.1)) * step; if (nd < best[ni]) { best[ni] = nd; heap.push({ i: ni, f: nd }); } } } for (const i of claimed) { const old = adminId[i]; if (old === targetAdmin) continue; if (old >= 0) areaById.set(old, Math.max(0, (areaById.get(old) || 0) - 1)); adminId[i] = targetAdmin; areaById.set(targetAdmin, (areaById.get(targetAdmin) || 0) + 1); changed++; } city.municipalityMinArea = minArea; } return { changed, protectedCities, tooSmall }; }