239 lines
12 KiB
JavaScript
239 lines
12 KiB
JavaScript
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 };
|
|
}
|
|
|
|
|