import { INF, MAP_H, MAP_W, SIZE, clamp, hash2, indexOf, inside, xyOf } from "./mapUtils.js"; import { applyCompartmentOwners, dominantCompartmentOwners } from "./mapAdminShared.js"; export function compactWholeCompartmentMunicipalities(adminId, centers, prefectureMask, sea, fields = {}) { const activeIds = [...new Set([...adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i]))].sort((a, b) => a - b); const idMap = new Map(activeIds.map((id, n) => [id, n])); const compactId = new Int16Array(SIZE); compactId.fill(-1); const stats = activeIds.map(() => ({ sx: 0, sy: 0, count: 0, bestI: -1, bestScore: -INF })); for (let i = 0; i < SIZE; i++) { if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; const nextId = idMap.get(adminId[i]); if (nextId === undefined) continue; compactId[i] = nextId; const [x, y] = xyOf(i); const row = stats[nextId]; row.sx += x; row.sy += y; row.count++; const score = (fields.populationDensity?.[i] || 0) * 2.2 + (fields.plain?.[i] || 0) * 0.25 - (fields.slope?.[i] || 0) * 0.2; if (score > row.bestScore) { row.bestScore = score; row.bestI = i; } } const compactCenters = activeIds.map((oldId, newId) => { const current = centers[oldId]; if (current && inside(current.x, current.y) && compactId[indexOf(current.x, current.y)] === newId) { return { ...current, originalAdminId: oldId }; } const row = stats[newId]; const fallback = row.bestI >= 0 ? xyOf(row.bestI) : [Math.round(row.sx / Math.max(1, row.count)), Math.round(row.sy / Math.max(1, row.count))]; return { ...(current || {}), x: fallback[0], y: fallback[1], originalAdminId: oldId, generatedOfficePoint: true, invisibleLowlandAdminSeed: current?.invisibleLowlandAdminSeed ?? true, seedKind: current?.seedKind || "compactedMunicipalityOffice", }; }); return { adminId: compactId, adminCentersRaw: compactCenters, activeMunicipalityCount: compactCenters.length }; } export function enforceCompartmentMunicipalityOwnership(adminId, compartmentId, compartments, prefectureMask, sea) { if (!compartmentId || !compartments) return 0; let changed = 0; for (const comp of compartments) { if (!comp || !comp.cells?.length) continue; const counts = new Map(); for (const i of comp.cells) { if (!prefectureMask[i] || sea[i]) continue; const id = adminId[i]; if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); } let bestId = -1, bestCount = -1; for (const [id, count] of counts) { if (count > bestCount || (count === bestCount && id < bestId)) { bestId = id; bestCount = count; } } if (bestId < 0) continue; for (const i of comp.cells) { if (!prefectureMask[i] || sea[i] || adminId[i] === bestId) continue; adminId[i] = bestId; changed++; } } return changed; } export function mergeSingleCompartmentMunicipalities(adminId, compartments, prefectureMask, sea, minCompartments = 2, maxPasses = 8) { if (!compartments?.length) return { changedCells: 0, mergedMunicipalities: 0, remainingSingleCompartmentMunicipalities: 0 }; let totalChangedCells = 0; let mergedMunicipalities = 0; let remainingSingles = 0; for (let pass = 0; pass < maxPasses; pass++) { const owner = dominantCompartmentOwners(compartments, adminId); const byOwner = new Map(); const areaByOwner = new Map(); for (const unit of compartments) { if (!unit || unit.area === 0) continue; const id = owner[unit.id]; if (id < 0) continue; if (!byOwner.has(id)) byOwner.set(id, []); byOwner.get(id).push(unit); areaByOwner.set(id, (areaByOwner.get(id) || 0) + unit.area); } const singles = [...byOwner.entries()] .filter(([, units]) => units.length > 0 && units.length < minCompartments) .sort((a, b) => (areaByOwner.get(a[0]) || 0) - (areaByOwner.get(b[0]) || 0) || a[0] - b[0]); remainingSingles = singles.length; if (!singles.length) break; let passChanged = 0; for (const [id, units] of singles) { // The old rule allowed one natural compartment to become one municipality. // That produces many tiny office-only municipalities and makes the hierarchy // hard to read. Merge such municipalities into the strongest adjacent owner. const neighborScores = new Map(); for (const unit of units) { for (const [neighborId, edge] of unit.adjacent || []) { const candidate = owner[neighborId]; if (candidate < 0 || candidate === id) continue; const neighborUnit = compartments[neighborId]; const shared = edge.count || 1; const barrier = edge.target ? edge.target / Math.max(1, shared) : 0; const sameLandscape = neighborUnit?.classId === unit.classId ? 0.7 : 0; const score = shared * (2.2 - Math.min(1.6, barrier) + sameLandscape) + Math.sqrt(areaByOwner.get(candidate) || 1) * 0.05; neighborScores.set(candidate, (neighborScores.get(candidate) || 0) + score); } } let best = -1, bestScore = -INF; for (const [candidate, score] of neighborScores) { if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; } } if (best < 0) { // One-cell islets have no land adjacency. Attach them to the nearest // existing municipality instead of leaving a one-compartment municipality. const ux = units.reduce((sum, unit) => sum + (unit.x || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0)); const uy = units.reduce((sum, unit) => sum + (unit.y || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0)); let bestDist = INF; for (const [candidate, candidateUnits] of byOwner) { if (candidate === id || candidateUnits.length < minCompartments) continue; for (const unit of candidateUnits) { const d = Math.hypot((unit.x || 0) - ux, (unit.y || 0) - uy); if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; } } } if (bestDist > 28) best = -1; } if (best < 0) continue; for (const unit of units) { for (const i of unit.cells || []) { if (!prefectureMask[i] || sea[i]) continue; if (adminId[i] !== best) { adminId[i] = best; passChanged++; } } } mergedMunicipalities++; } totalChangedCells += passChanged; if (!passChanged) break; } const finalOwner = dominantCompartmentOwners(compartments, adminId); const finalCounts = new Map(); for (const unit of compartments) { if (!unit || unit.area === 0) continue; const id = finalOwner[unit.id]; if (id >= 0) finalCounts.set(id, (finalCounts.get(id) || 0) + 1); } remainingSingles = [...finalCounts.values()].filter((count) => count > 0 && count < minCompartments).length; return { changedCells: totalChangedCells, mergedMunicipalities, remainingSingleCompartmentMunicipalities: remainingSingles }; } export function ownerAreaByCompartment(owner, compartments) { const area = new Map(); const count = new Map(); for (const unit of compartments || []) { if (!unit || unit.area === 0) continue; const id = owner[unit.id]; if (id < 0) continue; area.set(id, (area.get(id) || 0) + (unit.area || 0)); count.set(id, (count.get(id) || 0) + 1); } return { area, count }; } export function compartmentTouchesOutside(unit, prefectureMask, sea) { if (!unit?.cells?.length) return true; for (const i of unit.cells) { const [x, y] = xyOf(i); if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) return true; for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { const nx = x + dx, ny = y + dy; if (!inside(nx, ny)) return true; const ni = indexOf(nx, ny); if (!prefectureMask[ni] || sea[ni]) return true; } } return false; } export function bestNeighborOwnerForUnits(units, owner, compartments, allowNearestFallback = false) { const { area } = ownerAreaByCompartment(owner, compartments); const scores = new Map(); const blocked = new Set(units.map((unit) => owner[unit.id])); for (const unit of units) { for (const [neighborId, edge] of unit.adjacent || []) { const candidate = owner[neighborId]; if (candidate < 0 || blocked.has(candidate)) continue; const neighbor = compartments[neighborId]; const shared = edge.count || 1; const barrier = (edge.target || 0) / Math.max(1, shared); const landscape = neighbor?.classId === unit.classId ? 0.75 : 0; const lowland = Math.min(unit.lowlandFitness || 0, neighbor?.lowlandFitness || 0) * 0.5; const score = shared * (2.4 + landscape + lowland - Math.min(1.8, barrier)) + Math.sqrt(area.get(candidate) || 1) * 0.04; scores.set(candidate, (scores.get(candidate) || 0) + score); } } let best = -1, bestScore = -INF; for (const [candidate, score] of scores) { if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; } } if (best >= 0 || !allowNearestFallback) return best; const ux = units.reduce((sum, unit) => sum + (unit.x || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0)); const uy = units.reduce((sum, unit) => sum + (unit.y || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0)); let bestDist = INF; for (const unit of compartments || []) { if (!unit || unit.area === 0) continue; const candidate = owner[unit.id]; if (candidate < 0 || blocked.has(candidate)) continue; const d = Math.hypot((unit.x || 0) - ux, (unit.y || 0) - uy); if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; } } return bestDist <= 32 ? best : -1; } export function mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, minCompartments = 2, maxPasses = 8) { let changedCells = 0; let mergedMunicipalities = 0; let remainingSingleCompartmentMunicipalities = 0; for (let pass = 0; pass < maxPasses; pass++) { const byOwner = new Map(); for (const unit of compartments || []) { if (!unit || unit.area === 0) continue; const id = owner[unit.id]; if (id < 0) continue; if (!byOwner.has(id)) byOwner.set(id, []); byOwner.get(id).push(unit); } const small = [...byOwner.entries()] .filter(([, units]) => units.length > 0 && units.length < minCompartments) .sort((a, b) => a[1].length - b[1].length || a[0] - b[0]); remainingSingleCompartmentMunicipalities = small.length; if (!small.length) break; let passChanged = 0; for (const [id, units] of small) { const target = bestNeighborOwnerForUnits(units, owner, compartments, true); if (target < 0 || target === id) continue; for (const unit of units) { if (owner[unit.id] === target) continue; owner[unit.id] = target; changedCells += unit.area || 0; passChanged += unit.area || 0; } mergedMunicipalities++; } if (!passChanged) break; } const counts = ownerAreaByCompartment(owner, compartments).count; remainingSingleCompartmentMunicipalities = [...counts.values()].filter((count) => count > 0 && count < minCompartments).length; return { changedCells, mergedMunicipalities, remainingSingleCompartmentMunicipalities }; } export function repairCompartmentOwnerConnectivity(owner, compartments, maxPasses = 8) { let changedCells = 0; let changedComponents = 0; for (let pass = 0; pass < maxPasses; pass++) { const ownerIds = [...new Set([...owner].filter((id) => id >= 0))].sort((a, b) => a - b); let passChanged = 0; for (const id of ownerIds) { const members = (compartments || []).filter((unit) => unit && unit.area > 0 && owner[unit.id] === id); if (members.length <= 1) continue; const memberSet = new Set(members.map((unit) => unit.id)); const seen = new Set(); const components = []; for (const unit of members) { if (seen.has(unit.id)) continue; const queue = [unit.id]; const comp = []; seen.add(unit.id); for (let q = 0; q < queue.length; q++) { const cur = queue[q]; comp.push(compartments[cur]); for (const next of compartments[cur]?.adjacent?.keys?.() || []) { if (!memberSet.has(next) || seen.has(next)) continue; seen.add(next); queue.push(next); } } components.push(comp); } if (components.length <= 1) continue; components.sort((a, b) => b.reduce((sum, unit) => sum + (unit.area || 0), 0) - a.reduce((sum, unit) => sum + (unit.area || 0), 0)); for (const comp of components.slice(1)) { const target = bestNeighborOwnerForUnits(comp, owner, compartments, true); if (target < 0 || target === id) continue; for (const unit of comp) { owner[unit.id] = target; changedCells += unit.area || 0; passChanged += unit.area || 0; } changedComponents++; } } if (!passChanged) break; } return { changedCells, changedComponents }; } export function repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, maxPasses = 6) { let changedCells = 0; let changedComponents = 0; const outsideCache = new Map(); const touchesOutside = (unit) => { if (!outsideCache.has(unit.id)) outsideCache.set(unit.id, compartmentTouchesOutside(unit, prefectureMask, sea)); return outsideCache.get(unit.id); }; for (let pass = 0; pass < maxPasses; pass++) { const ownerIds = [...new Set([...owner].filter((id) => id >= 0))].sort((a, b) => a - b); let passChanged = 0; for (const id of ownerIds) { const members = (compartments || []).filter((unit) => unit && unit.area > 0 && owner[unit.id] === id); if (!members.length) continue; const memberSet = new Set(members.map((unit) => unit.id)); const seen = new Set(); for (const unit of members) { if (seen.has(unit.id)) continue; const queue = [unit.id]; const comp = []; const boundaryOwners = new Map(); let outside = false; seen.add(unit.id); for (let q = 0; q < queue.length; q++) { const cur = queue[q]; const curUnit = compartments[cur]; if (!curUnit) continue; comp.push(curUnit); if (touchesOutside(curUnit)) outside = true; for (const [next, edge] of curUnit.adjacent || []) { const nextOwner = owner[next]; if (nextOwner === id) { if (!seen.has(next) && memberSet.has(next)) { seen.add(next); queue.push(next); } } else if (nextOwner >= 0) { boundaryOwners.set(nextOwner, (boundaryOwners.get(nextOwner) || 0) + (edge.count || 1)); } } } if (outside || boundaryOwners.size !== 1) continue; const [target] = boundaryOwners.keys(); if (target < 0 || target === id) continue; for (const compUnit of comp) { owner[compUnit.id] = target; changedCells += compUnit.area || 0; passChanged += compUnit.area || 0; } changedComponents++; } } if (!passChanged) break; } return { changedCells, changedComponents }; } export function lockCompactUrbanCompartmentsToDominantOwner(owner, compartments, maxCells = 1100) { const isUrbanUnit = (unit) => unit && unit.area > 0 && ( unit.classId <= 3 || (unit.urbanWeight || 0) >= 0.34 || ((unit.urbanWeight || 0) >= 0.22 && (unit.lowlandFitness || 0) >= 0.34) ); const seen = new Set(); let changedCells = 0; let unifiedComponents = 0; for (const start of compartments || []) { if (!isUrbanUnit(start) || seen.has(start.id)) continue; const queue = [start.id]; const comp = []; seen.add(start.id); for (let q = 0; q < queue.length; q++) { const cur = queue[q]; const unit = compartments[cur]; if (!isUrbanUnit(unit)) continue; comp.push(unit); for (const next of unit.adjacent?.keys?.() || []) { if (seen.has(next) || !isUrbanUnit(compartments[next])) continue; seen.add(next); queue.push(next); } } const totalArea = comp.reduce((sum, unit) => sum + (unit.area || 0), 0); if (comp.length <= 1 || totalArea <= 0 || totalArea > maxCells) continue; const ownerScore = new Map(); for (const unit of comp) { const id = owner[unit.id]; if (id < 0) continue; const score = (unit.area || 0) * (1 + (unit.urbanWeight || 0) * 1.8); ownerScore.set(id, (ownerScore.get(id) || 0) + score); } let best = -1, bestScore = -INF; for (const [id, score] of ownerScore) if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; } if (best < 0) continue; let localChanged = 0; for (const unit of comp) { if (owner[unit.id] === best) continue; owner[unit.id] = best; localChanged += unit.area || 0; } if (localChanged > 0) { changedCells += localChanged; unifiedComponents++; } } return { changedCells, unifiedComponents }; } export function lockCityMetroCompartmentsToSingleMunicipality(owner, compartments, fields = {}) { const { modernCities = [] } = fields; if (!owner || !compartments?.length || !modernCities?.length) return { changedCells: 0, unifiedCities: 0 }; let changedCells = 0; let unifiedCities = 0; const urbanUnit = (unit) => unit && unit.area > 0 && ( unit.classId <= 3 || (unit.urbanWeight || 0) >= 0.24 || ((unit.urbanWeight || 0) >= 0.16 && (unit.lowlandFitness || 0) >= 0.40) ); for (const city of modernCities) { if (!city || !Number.isFinite(city.x) || !Number.isFinite(city.y) || (city.population || 0) < 18000) continue; const radius = clamp( (city.urbanRadius || 8) * ((city.population || 0) >= 200000 ? 1.95 : (city.population || 0) >= 80000 ? 1.65 : 1.35), 8, (city.population || 0) >= 200000 ? 34 : 24 ); const units = []; for (const unit of compartments) { if (!urbanUnit(unit)) continue; const d = Math.hypot((unit.x || 0) - city.x, (unit.y || 0) - city.y); if (d > radius) continue; const weight = (unit.area || 1) * (1 + (unit.urbanWeight || 0) * 2.4 + (unit.lowlandFitness || 0) * 0.55) * Math.max(0.20, 1 - d / Math.max(1, radius) * 0.58); units.push({ unit, weight, d }); } if (units.length <= 1) continue; const totalArea = units.reduce((sum, row) => sum + (row.unit.area || 0), 0); // Large multi-core conurbations may legitimately contain multiple municipalities. // This pass targets compact urban areas that visually read as one city. const maxArea = (city.population || 0) >= 200000 ? 1800 : 900; if (totalArea > maxArea) continue; const ownerScore = new Map(); for (const row of units) { const id = owner[row.unit.id]; if (id < 0) continue; ownerScore.set(id, (ownerScore.get(id) || 0) + row.weight); } if (ownerScore.size <= 1) continue; let best = -1, bestScore = -INF, totalScore = 0; for (const [id, score] of ownerScore) { totalScore += score; if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; } } if (best < 0 || bestScore / Math.max(1, totalScore) < 0.28) continue; let localChanged = 0; for (const row of units) { if (owner[row.unit.id] === best) continue; owner[row.unit.id] = best; localChanged += row.unit.area || 0; } if (localChanged > 0) { changedCells += localChanged; unifiedCities++; } } return { changedCells, unifiedCities }; } export function enforceSimpleAdministrativeHierarchy(adminId, compartments, prefectureMask, sea, fields = {}) { const owner = dominantCompartmentOwners(compartments, adminId); const urban = lockCompactUrbanCompartmentsToDominantOwner(owner, compartments, fields.maxUrbanClusterCells || 1100); const metro = lockCityMetroCompartmentsToSingleMunicipality(owner, compartments, fields); const connectivity1 = repairCompartmentOwnerConnectivity(owner, compartments, 8); const enclave1 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 6); const singleMerge = mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, fields.minCompartmentsPerMunicipality || 2, 8); const connectivity2 = repairCompartmentOwnerConnectivity(owner, compartments, 8); const enclave2 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 6); const singleMerge2 = mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, fields.minCompartmentsPerMunicipality || 2, 4); const connectivity3 = repairCompartmentOwnerConnectivity(owner, compartments, 4); const enclave3 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 4); applyCompartmentOwners(adminId, compartments, owner); const counts = ownerAreaByCompartment(owner, compartments).count; return { changedAfterUrbanUnification: urban.changedCells + metro.changedCells, urbanComponentsUnified: urban.unifiedComponents, changedAfterCityMetroMunicipalityUnification: metro.changedCells, cityMetroMunicipalitiesUnified: metro.unifiedCities, changedAfterCompartmentConnectivity: connectivity1.changedCells + connectivity2.changedCells + connectivity3.changedCells, disconnectedCompartmentComponentsMerged: connectivity1.changedComponents + connectivity2.changedComponents + connectivity3.changedComponents, changedAfterCompartmentEnclaveRepair: enclave1.changedCells + enclave2.changedCells + enclave3.changedCells, compartmentEnclaveComponentsMerged: enclave1.changedComponents + enclave2.changedComponents + enclave3.changedComponents, changedAfterSingleCompartmentMunicipalityMerge: singleMerge.changedCells + singleMerge2.changedCells, singleCompartmentMunicipalitiesMerged: singleMerge.mergedMunicipalities + singleMerge2.mergedMunicipalities, remainingSingleCompartmentMunicipalities: [...counts.values()].filter((count) => count > 0 && count < (fields.minCompartmentsPerMunicipality || 2)).length, }; } export function repairPrefectureCellEnclavesFromGrid(adminId, owner, prefectureMask, sea, maxPasses = 5) { let changed = 0; const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; for (let pass = 0; pass < maxPasses; pass++) { const prefId = new Int16Array(SIZE); prefId.fill(-1); for (let i = 0; i < SIZE; i++) { if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; prefId[i] = owner.get(adminId[i]) ?? -1; } const seen = new Uint8Array(SIZE); let passChanged = 0; for (let i = 0; i < SIZE; i++) { if (seen[i] || prefId[i] < 0) continue; const id = prefId[i]; const queue = [i]; const comp = []; seen[i] = 1; let touchesOutside = false; const boundaryCounts = new Map(); const adminCounts = new Map(); for (let q = 0; q < queue.length; q++) { const cur = queue[q]; comp.push(cur); const aid = adminId[cur]; if (aid >= 0) adminCounts.set(aid, (adminCounts.get(aid) || 0) + 1); const [x, y] = xyOf(cur); if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesOutside = true; for (const [dx, dy] of dirs) { const nx = x + dx, ny = y + dy; if (!inside(nx, ny)) { touchesOutside = true; continue; } const ni = indexOf(nx, ny); if (!prefectureMask[ni] || sea[ni]) { touchesOutside = true; continue; } const nid = prefId[ni]; if (nid === id) { if (!seen[ni]) { seen[ni] = 1; queue.push(ni); } } else if (nid >= 0) { boundaryCounts.set(nid, (boundaryCounts.get(nid) || 0) + 1); } } } if (touchesOutside || boundaryCounts.size !== 1) continue; const [targetPref] = boundaryCounts.keys(); if (targetPref < 0 || targetPref === id) continue; for (const aid of adminCounts.keys()) { if (owner.get(aid) !== targetPref) { owner.set(aid, targetPref); passChanged++; } } } changed += passChanged; if (!passChanged) break; } return changed; } export function repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, maxPasses = 5) { let changed = 0; const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; for (let pass = 0; pass < maxPasses; pass++) { const seen = new Uint8Array(SIZE); let passChanged = 0; for (let i = 0; i < SIZE; i++) { if (seen[i] || !prefectureMask[i] || sea[i] || adminId[i] < 0) continue; const id = adminId[i]; const queue = [i]; const comp = []; seen[i] = 1; let touchesOutside = false; const boundaryCounts = new Map(); for (let q = 0; q < queue.length; q++) { const cur = queue[q]; comp.push(cur); const [x, y] = xyOf(cur); if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesOutside = true; for (const [dx, dy] of dirs) { const nx = x + dx, ny = y + dy; if (!inside(nx, ny)) { touchesOutside = true; continue; } const ni = indexOf(nx, ny); if (!prefectureMask[ni] || sea[ni]) { touchesOutside = true; continue; } const nid = adminId[ni]; if (nid === id) { if (!seen[ni]) { seen[ni] = 1; queue.push(ni); } } else if (nid >= 0) { boundaryCounts.set(nid, (boundaryCounts.get(nid) || 0) + 1); } } } if (touchesOutside || boundaryCounts.size !== 1) continue; const [targetId] = boundaryCounts.keys(); if (targetId < 0 || targetId === id) continue; for (const ci of comp) adminId[ci] = targetId; passChanged += comp.length; } changed += passChanged; if (!passChanged) break; } return changed; } export function lockCompactUrbanAreasToDominantAdmin(adminId, prefectureMask, sea, landuse, populationDensity, maxCells = 950) { if (!landuse || !populationDensity) return 0; const seen = new Uint8Array(SIZE); const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; let changed = 0; const isUrban = (i) => !sea[i] && prefectureMask[i] && (populationDensity[i] > 0.34 || [2, 3, 4, 7, 8].includes(landuse[i])); for (let i = 0; i < SIZE; i++) { if (seen[i] || !isUrban(i) || adminId[i] < 0) continue; const queue = [i]; const comp = []; seen[i] = 1; const counts = new Map(); for (let q = 0; q < queue.length; q++) { const cur = queue[q]; comp.push(cur); const id = adminId[cur]; if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1 + (populationDensity[cur] || 0) * 2.0); const [x, y] = xyOf(cur); for (const [dx, dy] of dirs) { const nx = x + dx, ny = y + dy; if (!inside(nx, ny)) continue; const ni = indexOf(nx, ny); if (seen[ni] || !isUrban(ni)) continue; seen[ni] = 1; queue.push(ni); } } if (comp.length < 8 || comp.length > maxCells || counts.size <= 1 || counts.size > 7) continue; let best = -1, bestScore = -INF; let total = 0; for (const [id, score] of counts) { total += score; if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; } } if (best < 0 || bestScore / Math.max(1, total) < 0.38) continue; for (const ci of comp) { if (adminId[ci] !== best) { adminId[ci] = best; changed++; } } } return changed; } export function splitOversizedCompartmentMunicipalities(adminId, centers, compartments, prefectureMask, sea, fields = {}, seed = 0) { if (!compartments?.length) return { changedCells: 0, splitMunicipalities: 0, addedCenters: 0 }; const compOwner = new Int16Array(compartments.length); compOwner.fill(-1); for (const comp of compartments) { if (!comp || !comp.cells?.length) continue; const counts = new Map(); for (const i of comp.cells) { if (!prefectureMask[i] || sea[i]) continue; const id = adminId[i]; if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); } let best = -1, bestCount = -1; for (const [id, count] of counts) if (count > bestCount || (count === bestCount && id < best)) { best = id; bestCount = count; } compOwner[comp.id] = best; } const byOwner = new Map(); for (const comp of compartments) { if (!comp || !comp.cells?.length) continue; const owner = compOwner[comp.id]; if (owner < 0) continue; if (!byOwner.has(owner)) byOwner.set(owner, []); byOwner.get(owner).push(comp); } const areas = [...byOwner.values()].map((list) => list.reduce((sum, comp) => sum + comp.area, 0)).sort((a, b) => a - b); if (!areas.length) return { changedCells: 0, splitMunicipalities: 0, addedCenters: 0 }; const median = areas[Math.floor(areas.length / 2)] || 1; const total = areas.reduce((sum, value) => sum + value, 0); const maxArea = Math.max(360, Math.min(total * 0.11, Math.max(median * 2.65, total / Math.max(12, Math.round(total / 520))))); let changedCells = 0; let splitMunicipalities = 0; let addedCenters = 0; const elevation = fields.elevation; const slope = fields.slope; const ridgeField = fields.ridgeField; const plain = fields.plain; const agriculture = fields.agriculture; const basinField = fields.basinField; const coastalLowland = fields.coastalLowland; const populationDensity = fields.populationDensity; for (const [owner, list] of [...byOwner.entries()].sort((a, b) => a[0] - b[0])) { const area = list.reduce((sum, comp) => sum + comp.area, 0); if (area <= maxArea || list.length < 4) continue; const desiredParts = clamp(Math.ceil(area / Math.max(1, maxArea)), 2, 9); const splitCount = desiredParts - 1; if (splitCount <= 0) continue; const candidates = list.map((comp) => { let sx = 0, sy = 0, n = 0, score = 0, bestI = -1, bestScore = -INF; for (const i of comp.cells) { if (!prefectureMask[i] || sea[i]) continue; const [x, y] = xyOf(i); sx += x; sy += y; n++; const cellScore = (plain?.[i] || 0) * 0.22 + (agriculture?.[i] || 0) * 0.24 + (basinField?.[i] || 0) * 0.14 + (coastalLowland?.[i] || 0) * 0.10 + (populationDensity?.[i] || 0) * 0.24 - (slope?.[i] || 0) * 0.20 - (ridgeField?.[i] || 0) * 0.18 - Math.max(0, (elevation?.[i] || 0) - 0.62) * 0.38; score += cellScore; if (cellScore > bestScore) { bestScore = cellScore; bestI = i; } } const [x, y] = bestI >= 0 ? xyOf(bestI) : [Math.round(sx / Math.max(1, n)), Math.round(sy / Math.max(1, n))]; return { comp, x, y, score: score / Math.max(1, n) + Math.sqrt(comp.area) * 0.025 + hash2(seed + owner, comp.id) * 0.03 }; }).sort((a, b) => b.score - a.score || a.comp.id - b.comp.id); const newSeeds = []; for (const cand of candidates) { if (newSeeds.length >= splitCount) break; if (newSeeds.every((s) => Math.hypot(s.x - cand.x, s.y - cand.y) >= 9)) newSeeds.push(cand); } if (!newSeeds.length) continue; const seedIds = newSeeds.map((cand) => { const id = centers.length; centers.push({ x: cand.x, y: cand.y, score: cand.score, invisibleLowlandAdminSeed: true, seedKind: "oversizedMunicipalitySplit", splitFromAdminId: owner }); addedCenters++; return id; }); const oldCenter = centers[owner] || candidates[0] || { x: list[0].x || 0, y: list[0].y || 0, score: 0 }; const owners = [{ id: owner, x: oldCenter.x, y: oldCenter.y, score: oldCenter.score || 0 }, ...newSeeds.map((cand, k) => ({ id: seedIds[k], x: cand.x, y: cand.y, score: cand.score }))]; const targetArea = area / Math.max(1, owners.length); const claimedArea = new Map(owners.map((entry) => [entry.id, 0])); for (const cand of candidates) { let bestSeed = owner; let bestCost = INF; for (const entry of owners) { const d = Math.hypot(cand.x - entry.x, cand.y - entry.y); const pressure = Math.max(0, ((claimedArea.get(entry.id) || 0) + cand.comp.area - targetArea * 1.25) / Math.max(1, targetArea)); const cost = d + pressure * 18 - cand.score * 2.5 + hash2(entry.id, cand.comp.id) * 0.05; if (cost < bestCost) { bestCost = cost; bestSeed = entry.id; } } claimedArea.set(bestSeed, (claimedArea.get(bestSeed) || 0) + cand.comp.area); if (bestSeed === owner) continue; for (const i of cand.comp.cells) { if (!prefectureMask[i] || sea[i]) continue; if (adminId[i] !== bestSeed) { adminId[i] = bestSeed; changedCells++; } } } splitMunicipalities++; } return { changedCells, splitMunicipalities, addedCenters, maxArea }; }