Compare commits
8 commits
db8265384c
...
3f2be96395
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f2be96395 | |||
| e48fae5509 | |||
| da9d8ef904 | |||
| 3ac2c116bd | |||
| 3fe9c31453 | |||
| 6c10d5d70e | |||
| b707dadec9 | |||
| f420a3f8f3 |
21 changed files with 7120 additions and 3678 deletions
750
adminRegions.js
750
adminRegions.js
|
|
@ -1,6 +1,4 @@
|
|||
import { MinHeap } from "./graph.js";
|
||||
import { INF, MAP_H, MAP_W, SIZE, clamp, indexOf, inside, xyOf } from "./grid.js";
|
||||
import { weightedScore } from "./scoring.js";
|
||||
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, indexOf, inside, weightedScore, xyOf } from "./mapUtils.js";
|
||||
|
||||
function neighbors8(x, y) {
|
||||
const out = [];
|
||||
|
|
@ -158,12 +156,31 @@ export function lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask,
|
|||
}
|
||||
}
|
||||
|
||||
export function mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities = [], minArea = 320) {
|
||||
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)) cityMunicipalities.add(adminId[indexOf(city.x, city.y)]);
|
||||
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++) {
|
||||
|
|
@ -189,16 +206,35 @@ export function mergeTinyMunicipalities(adminId, prefectureMask, sea, population
|
|||
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 candidate = border * 3 + (area.get(other) || 0) * 0.012 + (pop.get(other) || 0) * 0.24;
|
||||
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 (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]);
|
||||
|
|
@ -467,6 +503,254 @@ function classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyFie
|
|||
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.58 || Math.max(flowAccum[a], flowAccum[b]) > 0.72;
|
||||
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.42 && !majorRiverEdge;
|
||||
const threshold = urbanEdge ? 0.84 : valleyContinuity ? 0.76 : classA === 8 || classB === 8 ? 0.42 : 0.62;
|
||||
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;
|
||||
for (const i of unit.cells) {
|
||||
const [x, y] = xyOf(i);
|
||||
sx += x; sy += y; pop += populationDensity[i];
|
||||
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.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 < 28 || unit.lowlandFitness < 0.24 || unit.mountainFitness > 0.72) return null;
|
||||
const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore } = fields;
|
||||
let first = -1, second = -1, bestA = -INF, bestB = -INF;
|
||||
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 score = low + populationDensity[i] * 0.22 + hashSeededTie(x, y, seed) * 0.04;
|
||||
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 d = Math.hypot(x - fx, y - fy);
|
||||
const score = d * (0.55 + low * 0.45) + hashSeededTie(x, y, seed + 17) * 0.20;
|
||||
if (score > bestB) { bestB = score; second = i; }
|
||||
}
|
||||
if (second < 0 || second === first) return null;
|
||||
|
||||
const cellSet = new Set(unit.cells);
|
||||
const localOwner = new Map([[first, 0], [second, 1]]);
|
||||
const queue = [first, second];
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
const owner = localOwner.get(cur);
|
||||
const [x, y] = xyOf(cur);
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (!cellSet.has(ni) || localOwner.has(ni)) continue;
|
||||
localOwner.set(ni, owner);
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
for (const ci of unit.cells) if (!localOwner.has(ci)) {
|
||||
const [x, y] = xyOf(ci);
|
||||
const d0 = Math.hypot(x - fx, y - fy);
|
||||
const [sx, sy] = xyOf(second);
|
||||
const d1 = Math.hypot(x - sx, y - sy);
|
||||
localOwner.set(ci, d0 <= d1 ? 0 : 1);
|
||||
}
|
||||
const aCells = [], bCells = [];
|
||||
for (const ci of unit.cells) (localOwner.get(ci) === 0 ? aCells : bCells).push(ci);
|
||||
if (aCells.length < 10 || bCells.length < 10) 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)}`;
|
||||
}
|
||||
|
||||
export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, 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;
|
||||
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];
|
||||
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,
|
||||
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 };
|
||||
let guard = targetCount * 3;
|
||||
while (compartments.filter((unit) => unit.area > 0).length < targetCount && guard-- > 0) {
|
||||
const candidates = compartments
|
||||
.filter((unit) => unit.area > 0 && unit.lowlandFitness > 0.24 && unit.mountainFitness < 0.74 && unit.area >= 28)
|
||||
.sort((a, b) => (b.area * (0.45 + b.lowlandFitness) - b.mountainFitness * 80) - (a.area * (0.45 + a.lowlandFitness) - a.mountainFitness * 80));
|
||||
const target = candidates[0];
|
||||
if (!target) break;
|
||||
const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard);
|
||||
if (!newUnit) {
|
||||
target.lowlandFitness = 0;
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
|
|
@ -561,52 +845,338 @@ function mergeTinyLandscapeUnits(unitId, units, minArea = 10) {
|
|||
}
|
||||
}
|
||||
|
||||
function landscapeTransitionCost(a, b, edge) {
|
||||
function naturalOwnershipAffinity(unit, neighbor, edge) {
|
||||
const boundaryTarget = edge.target / Math.max(1, edge.count);
|
||||
const bothUrban = a.classId <= 3 && b.classId <= 3;
|
||||
const bothCorridor = (a.classId === 5 || a.classId === 7 || a.classId === 10) && (b.classId === 5 || b.classId === 7 || b.classId === 10);
|
||||
const urbanContinuity = bothUrban ? 2.1 : (a.urbanWeight + b.urbanWeight) > 0.75 && Math.abs(a.urbanWeight - b.urbanWeight) < 0.35 ? 0.9 : 0;
|
||||
return Math.max(0.18, 0.70 + boundaryTarget * 4.2 + (a.classId === b.classId ? 0 : 0.75) + ((a.classId === 8 || b.classId === 8) ? 1.2 : 0) - urbanContinuity - (bothCorridor ? 0.55 : 0));
|
||||
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;
|
||||
}
|
||||
|
||||
export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = []) {
|
||||
const { unitId, units, targetScore } = buildLandscapeUnits(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse);
|
||||
if (units.length === 0) return;
|
||||
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 unit = units[unitId[indexOf(center.x, center.y)]];
|
||||
if (unit) unit.centerIds.push(id);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
const owner = new Int16Array(units.length);
|
||||
const dist = new Float32Array(units.length);
|
||||
owner.fill(-1); dist.fill(INF);
|
||||
const heap = new MinHeap();
|
||||
for (const unit of units) {
|
||||
if (unit.area === 0 || unit.centerIds.length === 0) continue;
|
||||
const id = unit.centerIds[0];
|
||||
owner[unit.id] = id; dist[unit.id] = 0; heap.push({ i: unit.id, f: 0 });
|
||||
}
|
||||
while (heap.length) {
|
||||
while (heap.length > 0) {
|
||||
const cur = heap.pop();
|
||||
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
|
||||
const unit = units[cur.i];
|
||||
const currentOwner = owner[cur.i];
|
||||
if (!unit || currentOwner < 0) continue;
|
||||
for (const [nextId, edge] of unit.adjacent) {
|
||||
const next = units[nextId];
|
||||
if (!next || next.area === 0) continue;
|
||||
const nextDist = dist[cur.i] + landscapeTransitionCost(unit, next, edge) + Math.sqrt(next.area) * 0.012 + (next.urbanWeight > 0.75 && next.centerIds.length === 0 ? -0.20 : 0);
|
||||
if (nextDist < dist[nextId]) {
|
||||
dist[nextId] = nextDist; owner[nextId] = currentOwner; heap.push({ i: nextId, f: nextDist });
|
||||
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 units) {
|
||||
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 { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse, options);
|
||||
const adminId = new Int16Array(SIZE);
|
||||
adminId.fill(-1);
|
||||
const owner = graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options);
|
||||
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);
|
||||
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,
|
||||
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;
|
||||
|
|
@ -618,7 +1188,18 @@ export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, e
|
|||
if (prefectureMask[i] && !sea[i]) adminId[i] = id;
|
||||
}
|
||||
}
|
||||
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, targetScore, populationDensity, landuse);
|
||||
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) {
|
||||
|
|
@ -661,3 +1242,94 @@ export function snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, eleva
|
|||
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);
|
||||
}
|
||||
|
|
|
|||
5
app.js
5
app.js
|
|
@ -11,6 +11,9 @@ const modes = [
|
|||
["development", "Development"],
|
||||
["landuse", "Land Use"],
|
||||
["admin", "Municipal Borders"],
|
||||
["terrain-debug", "Terrain Debug"],
|
||||
["admin-debug", "Admin Debug"],
|
||||
["borders-debug", "Borders Debug"],
|
||||
];
|
||||
|
||||
const state = {
|
||||
|
|
@ -76,6 +79,8 @@ function getStats(map) {
|
|||
["Logistics Parks", countText(map.logisticsParks)],
|
||||
["New Towns", countText(map.newTowns)],
|
||||
["Municipalities", map.adminCenters.length],
|
||||
["Admin changed cells", map.adminDebug ? `${map.adminDebug.changedAfterLandscapePartition || 0} partition / ${map.adminDebug.changedAfterSnap || 0} snap` : "-"],
|
||||
["Regional changed cells", map.regionalDebug?.regionalChangedAfterNaturalPartition ?? "-"],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
import { hash2 } from "./random.js";
|
||||
|
||||
export function pickEntities(candidates, { max = 99, minDistance = 5, threshold = 0, seed = 0, jitter = 0.04 } = {}) {
|
||||
const sorted = candidates
|
||||
.filter((p) => Number.isFinite(p.score) && p.score >= threshold)
|
||||
.map((p) => ({ ...p, score: p.score + hash2(p.x, p.y, seed + 54321) * jitter }))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
const out = [];
|
||||
for (const candidate of sorted) {
|
||||
if (out.every((p) => Math.hypot(p.x - candidate.x, p.y - candidate.y) >= minDistance)) {
|
||||
out.push(candidate);
|
||||
if (out.length >= max) break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
28
fields.js
28
fields.js
|
|
@ -1,28 +0,0 @@
|
|||
import { SIZE } from "./grid.js";
|
||||
|
||||
export function createMapFields() {
|
||||
const flowTo = new Int32Array(SIZE);
|
||||
flowTo.fill(-1);
|
||||
|
||||
return {
|
||||
elevation: new Float32Array(SIZE),
|
||||
moisture: new Float32Array(SIZE),
|
||||
slope: new Float32Array(SIZE),
|
||||
sea: new Uint8Array(SIZE),
|
||||
river: new Float32Array(SIZE),
|
||||
floodplain: new Float32Array(SIZE),
|
||||
plain: new Float32Array(SIZE),
|
||||
agriculture: new Float32Array(SIZE),
|
||||
ridgeField: new Float32Array(SIZE),
|
||||
valleyField: new Float32Array(SIZE),
|
||||
basinField: new Float32Array(SIZE),
|
||||
coastalLowland: new Float32Array(SIZE),
|
||||
flowAccum: new Float32Array(SIZE),
|
||||
erosionField: new Float32Array(SIZE),
|
||||
depositionField: new Float32Array(SIZE),
|
||||
flowTo,
|
||||
portSuitability: new Float32Array(SIZE),
|
||||
crossingSuitability: new Float32Array(SIZE),
|
||||
passSuitability: new Float32Array(SIZE),
|
||||
};
|
||||
}
|
||||
34
graph.js
34
graph.js
|
|
@ -1,34 +0,0 @@
|
|||
export class MinHeap {
|
||||
constructor() { this.items = []; }
|
||||
push(item) {
|
||||
this.items.push(item);
|
||||
let i = this.items.length - 1;
|
||||
while (i > 0) {
|
||||
const parent = (i - 1) >> 1;
|
||||
if (this.items[parent].f <= item.f) break;
|
||||
this.items[i] = this.items[parent];
|
||||
i = parent;
|
||||
}
|
||||
this.items[i] = item;
|
||||
}
|
||||
pop() {
|
||||
if (this.items.length === 0) return null;
|
||||
const root = this.items[0];
|
||||
const last = this.items.pop();
|
||||
if (this.items.length > 0) {
|
||||
let i = 0;
|
||||
while (true) {
|
||||
const left = i * 2 + 1;
|
||||
const right = left + 1;
|
||||
if (left >= this.items.length) break;
|
||||
const child = right < this.items.length && this.items[right].f < this.items[left].f ? right : left;
|
||||
if (this.items[child].f >= last.f) break;
|
||||
this.items[i] = this.items[child];
|
||||
i = child;
|
||||
}
|
||||
this.items[i] = last;
|
||||
}
|
||||
return root;
|
||||
}
|
||||
get length() { return this.items.length; }
|
||||
}
|
||||
26
grid.js
26
grid.js
|
|
@ -1,26 +0,0 @@
|
|||
export const MAP_W = 172;
|
||||
export const MAP_H = 122;
|
||||
export const CELL_SIZE = 6;
|
||||
|
||||
export const SIZE = MAP_W * MAP_H;
|
||||
export const INF = 1e9;
|
||||
|
||||
export function indexOf(x, y) {
|
||||
return y * MAP_W + x;
|
||||
}
|
||||
|
||||
export function xyOf(i) {
|
||||
return [i % MAP_W, Math.floor(i / MAP_W)];
|
||||
}
|
||||
|
||||
export function inside(x, y) {
|
||||
return x >= 0 && y >= 0 && x < MAP_W && y < MAP_H;
|
||||
}
|
||||
|
||||
export function clamp(v, a = 0, b = 1) {
|
||||
return Math.max(a, Math.min(b, v));
|
||||
}
|
||||
|
||||
export function nearMapEdge(x, y, margin = 1) {
|
||||
return x < margin || y < margin || x >= MAP_W - margin || y >= MAP_H - margin;
|
||||
}
|
||||
878
mapAdminStage.js
Normal file
878
mapAdminStage.js
Normal file
|
|
@ -0,0 +1,878 @@
|
|||
import {
|
||||
applyLandscapeUnitAdminPartition,
|
||||
assignAdminRegionsFromNaturalCompartments,
|
||||
lockSmallUrbanComponentsToMunicipality,
|
||||
mergeTinyMunicipalities,
|
||||
removeMunicipalExclaves,
|
||||
smoothAdminRegionsTerrainAware,
|
||||
splitOversizedLowlandMunicipalities,
|
||||
snapAdminBoundariesToTerrain,
|
||||
} from "./adminRegions.js";
|
||||
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, xyOf } from "./mapUtils.js";
|
||||
import { extractAdminBorderSegments } from "./mapGeneratorHelpers.js";
|
||||
|
||||
function changedCellsSince(before, after, prefectureMask, sea) {
|
||||
let changed = 0;
|
||||
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== after[i]) changed++;
|
||||
return changed;
|
||||
}
|
||||
|
||||
function municipalityAreaById(adminId, prefectureMask, sea) {
|
||||
const area = new Map();
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
|
||||
area.set(adminId[i], (area.get(adminId[i]) || 0) + 1);
|
||||
}
|
||||
return area;
|
||||
}
|
||||
|
||||
function isProtectedAdminSeed(seed) {
|
||||
if (!seed) return false;
|
||||
if (seed.seedKind === "capital" || seed.protectedCity?.isPrefecturalCapital) return true;
|
||||
if (seed.seedKind === "modernCity" && (seed.protectedCity?.population || seed.population || 0) >= 180000) return true;
|
||||
if (seed.seedKind === "port" && seed.portClass === "major") return true;
|
||||
if (seed.seedKind === "satelliteCity" && (seed.protectedSatellite?.population || seed.population || 0) >= 60000) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildSeedLifecycle(adminCenters, adminId, prefectureMask, sea, minArea = 80) {
|
||||
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
|
||||
const lifecycle = adminCenters.map((center, id) => {
|
||||
const protectedSeed = isProtectedAdminSeed(center);
|
||||
const area = areaById.get(id) || 0;
|
||||
const enoughArea = area >= (protectedSeed ? 28 : minArea);
|
||||
return {
|
||||
id,
|
||||
protected: protectedSeed,
|
||||
area,
|
||||
state: enoughArea || protectedSeed ? "survived" : "pending",
|
||||
};
|
||||
});
|
||||
return lifecycle;
|
||||
}
|
||||
|
||||
function activeSeedIds(seedLifecycle) {
|
||||
return new Set(seedLifecycle.filter((seed) => seed.state === "survived" || seed.protected).map((seed) => seed.id));
|
||||
}
|
||||
|
||||
function dominantCompartmentOwners(compartments, adminId) {
|
||||
const owner = new Int16Array(compartments.length);
|
||||
owner.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; }
|
||||
owner[unit.id] = bestId;
|
||||
}
|
||||
return owner;
|
||||
}
|
||||
|
||||
function applyCompartmentOwners(adminId, compartments, owner) {
|
||||
for (const unit of compartments) {
|
||||
if (!unit || unit.area === 0) continue;
|
||||
const id = owner[unit.id];
|
||||
if (id < 0) continue;
|
||||
for (const i of unit.cells) adminId[i] = id;
|
||||
}
|
||||
}
|
||||
|
||||
function absorbSeedCompartments(adminId, compartments, seedLifecycle) {
|
||||
const owner = dominantCompartmentOwners(compartments, adminId);
|
||||
const absorbed = new Set(seedLifecycle.filter((seed) => seed.state === "absorbed").map((seed) => seed.id));
|
||||
let changed = 0;
|
||||
for (const unit of compartments) {
|
||||
if (!unit || unit.area === 0 || !absorbed.has(owner[unit.id])) continue;
|
||||
let bestId = -1, bestScore = -INF;
|
||||
for (const [neighborId, edge] of unit.adjacent) {
|
||||
const candidate = owner[neighborId];
|
||||
if (candidate < 0 || absorbed.has(candidate)) continue;
|
||||
const neighbor = compartments[neighborId];
|
||||
const score = edge.count * 2 - (edge.target / Math.max(1, edge.count)) * 2 + (neighbor?.area || 0) * 0.002;
|
||||
if (score > bestScore) { bestScore = score; bestId = candidate; }
|
||||
}
|
||||
if (bestId < 0) continue;
|
||||
owner[unit.id] = bestId;
|
||||
changed += unit.area;
|
||||
}
|
||||
applyCompartmentOwners(adminId, compartments, owner);
|
||||
return changed;
|
||||
}
|
||||
|
||||
function splitOversizedLowlandsWithPendingSeeds(adminId, prefectureMask, sea, fields, compartments, adminCenters, seedLifecycle, settlements = []) {
|
||||
const { plain, agriculture, basinField, coastalLowland, valleyField, ridgeField, slope, elevation } = fields;
|
||||
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
|
||||
const areas = [...areaById.values()].sort((a, b) => a - b);
|
||||
const median = areas.length ? areas[Math.floor(areas.length / 2)] : 0;
|
||||
if (!median) return { changedCells: 0, splitMunicipalities: 0, pendingSeedsUsed: 0 };
|
||||
const owner = dominantCompartmentOwners(compartments, adminId);
|
||||
const unitsByOwner = new Map();
|
||||
for (const unit of compartments) {
|
||||
if (!unit || unit.area === 0 || owner[unit.id] < 0) continue;
|
||||
if (!unitsByOwner.has(owner[unit.id])) unitsByOwner.set(owner[unit.id], []);
|
||||
unitsByOwner.get(owner[unit.id]).push(unit);
|
||||
}
|
||||
const pending = seedLifecycle.filter((seed) => seed.state === "pending" && !seed.protected);
|
||||
let changedCells = 0;
|
||||
let splitMunicipalities = 0;
|
||||
let pendingSeedsUsed = 0;
|
||||
for (const [id, units] of unitsByOwner) {
|
||||
const area = areaById.get(id) || 0;
|
||||
if (area < Math.max(260, median * 1.45) || units.length < 6) continue;
|
||||
let lowland = 0, rough = 0;
|
||||
for (const unit of units) {
|
||||
for (const i of unit.cells) {
|
||||
lowland += (plain[i] || 0) * 0.38 + (agriculture[i] || 0) * 0.20 + basinField[i] * 0.22 + coastalLowland[i] * 0.22 + valleyField[i] * 0.10;
|
||||
rough += ridgeField[i] * 0.48 + slope[i] * 0.34 + Math.max(0, elevation[i] - 0.58) * 0.30;
|
||||
}
|
||||
}
|
||||
if (lowland / area < 0.26 || rough / area > 0.48) continue;
|
||||
const localPending = pending.filter((seed) => {
|
||||
const center = adminCenters[seed.id];
|
||||
if (!center || !inside(center.x, center.y)) return false;
|
||||
const centerOwner = adminId[indexOf(center.x, center.y)];
|
||||
return centerOwner === id || Math.hypot(center.x - (adminCenters[id]?.x || center.x), center.y - (adminCenters[id]?.y || center.y)) < 28;
|
||||
});
|
||||
const localSettlements = settlements.filter((p) => p && inside(p.x, p.y) && adminId[indexOf(p.x, p.y)] === id);
|
||||
if (localPending.length === 0 || localPending.length + localSettlements.length < 2) continue;
|
||||
let municipalitySplit = false;
|
||||
for (const seed of localPending.slice(0, 3)) {
|
||||
const center = adminCenters[seed.id];
|
||||
if (!center) continue;
|
||||
const targetArea = clamp(95 + (center.score || 0.5) * 60, 90, 180);
|
||||
let claimed = 0;
|
||||
const candidates = units
|
||||
.filter((unit) => owner[unit.id] === id && unit.classId !== 8 && unit.classId !== 9)
|
||||
.map((unit) => ({
|
||||
unit,
|
||||
score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 8 - (unit.urbanWeight || 0) * 3,
|
||||
}))
|
||||
.sort((a, b) => a.score - b.score);
|
||||
if (candidates.length < 2) continue;
|
||||
for (const { unit } of candidates) {
|
||||
if (claimed >= targetArea && claimed >= 2) break;
|
||||
owner[unit.id] = seed.id;
|
||||
claimed += unit.area;
|
||||
changedCells += unit.area;
|
||||
}
|
||||
if (claimed >= 45) {
|
||||
seed.state = "survived";
|
||||
seed.area = claimed;
|
||||
pendingSeedsUsed++;
|
||||
municipalitySplit = true;
|
||||
}
|
||||
}
|
||||
if (municipalitySplit) splitMunicipalities++;
|
||||
}
|
||||
applyCompartmentOwners(adminId, compartments, owner);
|
||||
return { changedCells, splitMunicipalities, pendingSeedsUsed };
|
||||
}
|
||||
|
||||
function promotePendingSeedsForMunicipalityCount(adminId, prefectureMask, sea, fields, compartments, adminCenters, seedLifecycle, targetMinCount = 20) {
|
||||
const { plain, agriculture, basinField, coastalLowland, valleyField, ridgeField, slope, elevation } = fields;
|
||||
let areaById = municipalityAreaById(adminId, prefectureMask, sea);
|
||||
let currentCount = areaById.size;
|
||||
if (currentCount >= targetMinCount) return { changedCells: 0, promotedSeeds: 0 };
|
||||
const owner = dominantCompartmentOwners(compartments, adminId);
|
||||
let changedCells = 0;
|
||||
let promotedSeeds = 0;
|
||||
const pending = seedLifecycle
|
||||
.filter((seed) => seed.state === "pending" && !seed.protected)
|
||||
.sort((a, b) => (adminCenters[b.id]?.score || 0) - (adminCenters[a.id]?.score || 0));
|
||||
for (const seed of pending) {
|
||||
if (currentCount >= targetMinCount) break;
|
||||
const center = adminCenters[seed.id];
|
||||
if (!center || !inside(center.x, center.y)) continue;
|
||||
const existingArea = areaById.get(seed.id) || 0;
|
||||
if (existingArea >= 12) {
|
||||
seed.state = "survived";
|
||||
seed.area = existingArea;
|
||||
promotedSeeds++;
|
||||
continue;
|
||||
}
|
||||
const candidates = compartments
|
||||
.filter((unit) => {
|
||||
if (!unit || unit.area === 0) return false;
|
||||
const currentOwner = owner[unit.id];
|
||||
if (currentOwner < 0 || currentOwner === seed.id) return false;
|
||||
const ownerArea = areaById.get(currentOwner) || 0;
|
||||
if (ownerArea < 90) return false;
|
||||
const lowlandFit = (unit.lowlandFitness || 0) + (unit.basinIdentity || 0) * 0.15 + (unit.coastalExposure || 0) * 0.15;
|
||||
if (lowlandFit < 0.26) return false;
|
||||
return Math.hypot(unit.x - center.x, unit.y - center.y) < 36;
|
||||
})
|
||||
.map((unit) => ({
|
||||
unit,
|
||||
score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 6 - (unit.urbanWeight || 0) * 2,
|
||||
}))
|
||||
.sort((a, b) => a.score - b.score);
|
||||
if (candidates.length === 0) continue;
|
||||
let claimed = 0;
|
||||
for (const { unit } of candidates) {
|
||||
const currentOwner = owner[unit.id];
|
||||
if ((areaById.get(currentOwner) || 0) - unit.area < 70) continue;
|
||||
owner[unit.id] = seed.id;
|
||||
areaById.set(currentOwner, (areaById.get(currentOwner) || 0) - unit.area);
|
||||
areaById.set(seed.id, (areaById.get(seed.id) || 0) + unit.area);
|
||||
claimed += unit.area;
|
||||
changedCells += unit.area;
|
||||
if (claimed >= 55) break;
|
||||
}
|
||||
if (claimed >= 25) {
|
||||
seed.state = "survived";
|
||||
seed.area = areaById.get(seed.id) || claimed;
|
||||
promotedSeeds++;
|
||||
currentCount++;
|
||||
}
|
||||
}
|
||||
applyCompartmentOwners(adminId, compartments, owner);
|
||||
return { changedCells, promotedSeeds };
|
||||
}
|
||||
|
||||
function restoreSurvivedSeedsByCompartment(adminId, prefectureMask, sea, compartments, adminCenters, seedLifecycle, targetMinCount = 20) {
|
||||
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
|
||||
let currentCount = areaById.size;
|
||||
if (currentCount >= targetMinCount) return { changedCells: 0, restoredSeeds: 0 };
|
||||
const owner = dominantCompartmentOwners(compartments, adminId);
|
||||
let changedCells = 0;
|
||||
let restoredSeeds = 0;
|
||||
const missing = seedLifecycle
|
||||
.filter((seed) => (seed.state === "survived" || seed.protected) && (areaById.get(seed.id) || 0) === 0)
|
||||
.sort((a, b) => (b.protected ? 1 : 0) - (a.protected ? 1 : 0));
|
||||
for (const seed of missing) {
|
||||
if (currentCount >= targetMinCount) break;
|
||||
const center = adminCenters[seed.id];
|
||||
if (!center || !inside(center.x, center.y)) continue;
|
||||
const candidates = compartments
|
||||
.filter((unit) => {
|
||||
if (!unit || unit.area === 0 || unit.classId === 8 || unit.classId === 9) return false;
|
||||
const currentOwner = owner[unit.id];
|
||||
if (currentOwner < 0 || currentOwner === seed.id) return false;
|
||||
if ((areaById.get(currentOwner) || 0) - unit.area < 25) return false;
|
||||
return Math.hypot(unit.x - center.x, unit.y - center.y) < 90 && ((unit.lowlandFitness || 0) > 0.08 || seed.protected);
|
||||
})
|
||||
.map((unit) => ({
|
||||
unit,
|
||||
score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 6 - (unit.urbanWeight || 0) * 2 + (unit.classId === 8 || unit.classId === 9 ? 20 : 0),
|
||||
}))
|
||||
.sort((a, b) => a.score - b.score);
|
||||
if (candidates.length === 0) continue;
|
||||
const unit = candidates[0].unit;
|
||||
const oldOwner = owner[unit.id];
|
||||
if ((areaById.get(oldOwner) || 0) - unit.area < 25) continue;
|
||||
owner[unit.id] = seed.id;
|
||||
const claimed = unit.area;
|
||||
areaById.set(oldOwner, (areaById.get(oldOwner) || 0) - claimed);
|
||||
changedCells += claimed;
|
||||
areaById.set(seed.id, claimed);
|
||||
seed.area = claimed;
|
||||
restoredSeeds++;
|
||||
currentCount++;
|
||||
}
|
||||
applyCompartmentOwners(adminId, compartments, owner);
|
||||
return { changedCells, restoredSeeds };
|
||||
}
|
||||
|
||||
function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }) {
|
||||
let landCells = 0;
|
||||
let habitableCells = 0;
|
||||
let lowlandCells = 0;
|
||||
let coastlineComplexity = 0;
|
||||
let mountainCells = 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]) continue;
|
||||
landCells++;
|
||||
if (slope[i] < 0.42 && ridgeField[i] < 0.55 && (!elevation || elevation[i] < 0.72)) habitableCells++;
|
||||
if ((coastalLowland[i] > 0.20 || basinField[i] > 0.24) && slope[i] < 0.36 && ridgeField[i] < 0.52) lowlandCells++;
|
||||
if (ridgeField[i] > 0.52 || slope[i] > 0.48) mountainCells++;
|
||||
for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (sea[ni]) {
|
||||
coastlineComplexity += 1 + coastalLowland[i] * 0.8;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const independentSatellites = (satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality").length;
|
||||
const settlementWeight = modernCities.length * 1.6 + markets.length * 1.0 + ports.length * 0.8 + independentSatellites * 0.7 + villages.length * 0.25;
|
||||
const basinBonus = Math.min(8, [...basinField].filter((v, i) => prefectureMask[i] && !sea[i] && v > 0.34).length / 520);
|
||||
const mountainRatio = landCells ? mountainCells / landCells : 0;
|
||||
const lowlandBonus = Math.min(7, lowlandCells / 430);
|
||||
const target = Math.round(habitableCells / 230 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.55 + lowlandBonus - mountainRatio * 2.2);
|
||||
return clamp(target, 20, 50);
|
||||
}
|
||||
|
||||
function lowlandAdminSeedScore(i, { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse }) {
|
||||
const lowRelief = clamp((0.70 - elevation[i]) * 1.35) + clamp((0.34 - slope[i]) * 1.55) + clamp((0.48 - ridgeField[i]) * 1.10);
|
||||
const landuseFit = [1, 2, 3, 4, 7, 8].includes(landuse[i]) ? 0.40 : landuse[i] === 5 || landuse[i] === 6 ? 0.12 : 0;
|
||||
return clamp(
|
||||
lowRelief * 0.25 +
|
||||
plain[i] * 0.28 +
|
||||
basinField[i] * 0.24 +
|
||||
coastalLowland[i] * 0.24 +
|
||||
settlementScore[i] * 0.30 +
|
||||
populationDensity[i] * 0.32 +
|
||||
roadInfluence[i] * 0.16 +
|
||||
railInfluence2[i] * 0.16 +
|
||||
(stationInfluence?.[i] || 0) * 0.18 +
|
||||
landuseFit -
|
||||
Math.max(0, elevation[i] - 0.62) * 1.2 -
|
||||
Math.max(0, ridgeField[i] - 0.54) * 0.9
|
||||
);
|
||||
}
|
||||
|
||||
function buildLowlandAdminSeeds({
|
||||
seed,
|
||||
targetMunicipalityCount,
|
||||
prefectureMask,
|
||||
sea,
|
||||
elevation,
|
||||
slope,
|
||||
ridgeField,
|
||||
plain,
|
||||
basinField,
|
||||
coastalLowland,
|
||||
settlementScore,
|
||||
populationDensity,
|
||||
roadInfluence,
|
||||
railInfluence2,
|
||||
stationInfluence,
|
||||
landuse,
|
||||
modernCities,
|
||||
satelliteCities,
|
||||
markets,
|
||||
ports,
|
||||
newTowns,
|
||||
stations,
|
||||
}) {
|
||||
const fields = { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse };
|
||||
function validLowlandPoint(p, strict = true) {
|
||||
if (!p || !inside(p.x, p.y)) return false;
|
||||
const i = indexOf(p.x, p.y);
|
||||
if (!prefectureMask[i] || sea[i]) return false;
|
||||
const score = lowlandAdminSeedScore(i, fields);
|
||||
const mountain = elevation[i] > 0.70 || slope[i] > 0.52 || ridgeField[i] > 0.62;
|
||||
return score >= (strict ? 0.34 : 0.24) && (!mountain || p.isPrefecturalCapital || (p.population || 0) >= 220000 || p.portClass === "major");
|
||||
}
|
||||
const realSeeds = [];
|
||||
for (const city of modernCities || []) {
|
||||
if (!validLowlandPoint(city, !city.isPrefecturalCapital)) continue;
|
||||
if (!city.isPrefecturalCapital && (city.population || 0) < 85000) continue;
|
||||
const i = indexOf(city.x, city.y);
|
||||
realSeeds.push({ x: city.x, y: city.y, score: 1.35 + (city.population || 0) / 650000 + lowlandAdminSeedScore(i, fields), population: city.population || 0, protectedCity: city, seedKind: city.isPrefecturalCapital ? "capital" : "modernCity" });
|
||||
}
|
||||
for (const city of satelliteCities || []) {
|
||||
if (city.municipalityClass !== "independentSatelliteMunicipality" || !validLowlandPoint(city, false)) continue;
|
||||
const i = indexOf(city.x, city.y);
|
||||
realSeeds.push({ x: city.x, y: city.y, score: 0.92 + (city.population || 0) / 260000 + lowlandAdminSeedScore(i, fields), population: city.population || 0, protectedSatellite: city, seedKind: "satelliteCity" });
|
||||
}
|
||||
for (const p of [...(markets || []), ...(ports || []), ...(newTowns || [])]) {
|
||||
if (!validLowlandPoint(p, true)) continue;
|
||||
const i = indexOf(p.x, p.y);
|
||||
const portBonus = p.portClass === "major" ? 0.45 : p.portClass === "regional" ? 0.26 : 0;
|
||||
realSeeds.push({ x: p.x, y: p.y, score: 0.74 + lowlandAdminSeedScore(i, fields) + portBonus + (p.population || 0) / 420000, population: p.population || 0, portClass: p.portClass, source: p, seedKind: p.portClass ? "port" : "marketTown" });
|
||||
}
|
||||
const picked = pickEntities(realSeeds, {
|
||||
max: targetMunicipalityCount,
|
||||
minDistance: 5 + Math.floor(rand(seed, 1302) * 3),
|
||||
threshold: 0.62,
|
||||
seed: seed + 1300,
|
||||
jitter: 0.025,
|
||||
});
|
||||
const invisibleCandidates = [];
|
||||
for (let y = 2; y < MAP_H - 2; y++) {
|
||||
for (let x = 2; x < MAP_W - 2; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (!prefectureMask[i] || sea[i]) continue;
|
||||
const score = lowlandAdminSeedScore(i, fields) + hash2(x, y, seed + 1311) * 0.045;
|
||||
if (score < 0.48) continue;
|
||||
const insideDenseCore = modernCities.some((city) => (city.population || 0) >= 180000 && Math.hypot(city.x - x, city.y - y) < Math.max(5, (city.coreRadius || 4) * 1.7));
|
||||
if (insideDenseCore) continue;
|
||||
invisibleCandidates.push({ x, y, score, invisibleLowlandAdminSeed: true, seedKind: "invisibleLowland" });
|
||||
}
|
||||
}
|
||||
if (picked.length < targetMunicipalityCount) {
|
||||
const extra = pickEntities(invisibleCandidates, {
|
||||
max: targetMunicipalityCount - picked.length,
|
||||
minDistance: 5,
|
||||
threshold: 0.48,
|
||||
seed: seed + 1304,
|
||||
jitter: 0.02,
|
||||
});
|
||||
for (const p of extra) if (picked.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 4)) picked.push(p);
|
||||
}
|
||||
if (picked.length < Math.min(targetMunicipalityCount, 20)) {
|
||||
const relaxed = pickEntities(invisibleCandidates, {
|
||||
max: Math.min(targetMunicipalityCount, 20) - picked.length,
|
||||
minDistance: 4,
|
||||
threshold: 0.38,
|
||||
seed: seed + 1305,
|
||||
jitter: 0.02,
|
||||
});
|
||||
for (const p of relaxed) if (picked.length < targetMunicipalityCount && picked.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 3.5)) picked.push(p);
|
||||
}
|
||||
return picked.slice(0, targetMunicipalityCount);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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 generateAdminLayout({
|
||||
seed,
|
||||
prefectureMask,
|
||||
sea,
|
||||
elevation,
|
||||
slope,
|
||||
river,
|
||||
ridgeField,
|
||||
naturalBarrierScore,
|
||||
valleyField,
|
||||
basinField,
|
||||
coastalLowland,
|
||||
flowAccum,
|
||||
plain,
|
||||
agriculture,
|
||||
settlementScore,
|
||||
populationDensity,
|
||||
stationInfluence,
|
||||
roadInfluence,
|
||||
railInfluence2,
|
||||
villageInfluence,
|
||||
landuse,
|
||||
modernCities,
|
||||
satelliteCities,
|
||||
newTowns,
|
||||
markets,
|
||||
villages,
|
||||
ports,
|
||||
stations,
|
||||
industrialZones,
|
||||
logisticsParks,
|
||||
}) {
|
||||
const boundaryRidgeField = naturalBarrierScore
|
||||
? Float32Array.from(ridgeField, (value, i) => clamp(value * 0.72 + naturalBarrierScore[i] * 0.46))
|
||||
: ridgeField;
|
||||
const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, boundaryRidgeField, river, flowAccum);
|
||||
const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages });
|
||||
const compartmentMultiplier = clamp(3.5 + rand(seed, 1320) * 2.0, 3.5, 5.5);
|
||||
let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), 80, 240);
|
||||
let adminCentersRaw = buildLowlandAdminSeeds({
|
||||
seed,
|
||||
targetMunicipalityCount,
|
||||
prefectureMask,
|
||||
sea,
|
||||
elevation,
|
||||
slope,
|
||||
ridgeField: boundaryRidgeField,
|
||||
plain,
|
||||
basinField,
|
||||
coastalLowland,
|
||||
settlementScore,
|
||||
populationDensity,
|
||||
roadInfluence,
|
||||
railInfluence2,
|
||||
stationInfluence,
|
||||
landuse,
|
||||
modernCities,
|
||||
satelliteCities,
|
||||
markets,
|
||||
ports,
|
||||
newTowns,
|
||||
stations,
|
||||
});
|
||||
if (adminCentersRaw.length < 20) targetCompartmentCount = Math.max(targetCompartmentCount, 120);
|
||||
const compartmentAssignment = assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, {
|
||||
seed,
|
||||
targetMunicipalityCount,
|
||||
targetCompartmentCount,
|
||||
});
|
||||
const adminId = compartmentAssignment.adminId;
|
||||
let previousSnapshot = new Int16Array(adminId);
|
||||
const adminDebug = {
|
||||
changedAfterSmooth: 0,
|
||||
changedAfterUrbanLock: 0,
|
||||
changedAfterSmallUrbanLock: 0,
|
||||
changedAfterInitialMerge: 0,
|
||||
changedAfterInitialExclaveRemoval: 0,
|
||||
changedAfterLandscapePartition: 0,
|
||||
changedAfterSnap: 0,
|
||||
changedAfterOversizedRuralSplit: 0,
|
||||
changedAfterFinalExclaveRemoval: 0,
|
||||
changedAfterFinalMerge: 0,
|
||||
targetMunicipalityCount,
|
||||
actualMunicipalityCount: 0,
|
||||
municipalityCountReason: "habitable cells, settlement weight, coastline complexity, basin/lowland bonus, and mountain-ratio adjustment",
|
||||
changedAfterCompartmentAssignment: compartmentAssignment.debug?.naturalCompartmentCount || 0,
|
||||
oversizedRuralSplits: 0,
|
||||
oversizedLowlandSplits: 0,
|
||||
ruralSplitsAccepted: 0,
|
||||
ruralSplitsRejected: 0,
|
||||
targetNaturalCompartmentCount: targetCompartmentCount,
|
||||
compartmentMultiplier,
|
||||
lowlandAdminSeedCount: adminCentersRaw.filter((p) => p.invisibleLowlandAdminSeed).length,
|
||||
lowlandAdminSeeds: adminCentersRaw.filter((p) => p.invisibleLowlandAdminSeed).map((p) => ({ x: p.x, y: p.y })),
|
||||
realAdminSeedCount: adminCentersRaw.filter((p) => !p.invisibleLowlandAdminSeed).length,
|
||||
highMountainAdminSeedCount: adminCentersRaw.filter((p) => {
|
||||
const i = indexOf(p.x, p.y);
|
||||
return elevation[i] > 0.70 || slope[i] > 0.52 || boundaryRidgeField[i] > 0.62;
|
||||
}).length,
|
||||
candidateSeedCount: adminCentersRaw.length,
|
||||
protectedSeedCount: adminCentersRaw.filter(isProtectedAdminSeed).length,
|
||||
survivedSeedCount: 0,
|
||||
pendingSeedCount: 0,
|
||||
absorbedSeedCount: 0,
|
||||
pendingSeedsUsedForLowlandSplit: 0,
|
||||
finalMunicipalityCount: 0,
|
||||
finalTinyMunicipalityCount: 0,
|
||||
seedCellRevivalCount: 0,
|
||||
satelliteMunicipalitiesCreated: adminCentersRaw.filter((p) => p.protectedSatellite).length,
|
||||
satelliteMunicipalitiesMerged: 0,
|
||||
satelliteMunicipalitiesExpanded: 0,
|
||||
satelliteMunicipalitiesTooSmall: 0,
|
||||
averageSatelliteMunicipalityArea: 0,
|
||||
minSatelliteMunicipalityArea: 0,
|
||||
satelliteMunicipalityAreaByNameOrIndex: {},
|
||||
independentSatelliteMunicipalities: satelliteClassificationDebug.independent,
|
||||
attachedSatelliteDistricts: satelliteClassificationDebug.attached,
|
||||
satelliteMunicipalityStats: satelliteClassificationDebug,
|
||||
...compartmentAssignment.debug,
|
||||
};
|
||||
const seedLifecycle = buildSeedLifecycle(adminCentersRaw, adminId, prefectureMask, sea, 35);
|
||||
const pendingSplitDebug = splitOversizedLowlandsWithPendingSeeds(adminId, prefectureMask, sea, {
|
||||
plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation,
|
||||
}, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, [...(satelliteCities || []), ...newTowns, ...markets, ...villages, ...ports]);
|
||||
adminDebug.changedAfterPendingSeedLowlandSplit = pendingSplitDebug.changedCells;
|
||||
adminDebug.pendingSeedsUsedForLowlandSplit = pendingSplitDebug.pendingSeedsUsed;
|
||||
adminDebug.oversizedLowlandSplits += pendingSplitDebug.splitMunicipalities;
|
||||
const pendingPromotionDebug = promotePendingSeedsForMunicipalityCount(adminId, prefectureMask, sea, {
|
||||
plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation,
|
||||
}, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, Math.min(24, targetMunicipalityCount));
|
||||
adminDebug.changedAfterPendingSeedCountRepair = pendingPromotionDebug.changedCells;
|
||||
adminDebug.pendingSeedsPromotedForCount = pendingPromotionDebug.promotedSeeds;
|
||||
let areaAfterPendingSplit = municipalityAreaById(adminId, prefectureMask, sea);
|
||||
for (const seedState of seedLifecycle) {
|
||||
if (seedState.state !== "pending") continue;
|
||||
seedState.area = areaAfterPendingSplit.get(seedState.id) || 0;
|
||||
if (seedState.area >= 35) seedState.state = "survived";
|
||||
else seedState.state = "absorbed";
|
||||
}
|
||||
adminDebug.changedAfterAbsorbingSeeds = absorbSeedCompartments(adminId, compartmentAssignment.compartments, seedLifecycle);
|
||||
let activeAdminIds = activeSeedIds(seedLifecycle);
|
||||
function markChanged(field) {
|
||||
adminDebug[field] = changedCellsSince(previousSnapshot, adminId, prefectureMask, sea);
|
||||
previousSnapshot = new Int16Array(adminId);
|
||||
}
|
||||
smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, populationDensity, landuse, 2);
|
||||
markChanged("changedAfterSmooth");
|
||||
|
||||
function lockUrbanClusterToMunicipality(city, radius, allowSuburban = true) {
|
||||
if (!city || !prefectureMask[indexOf(city.x, city.y)]) return;
|
||||
let bestAdmin = -1;
|
||||
let bestD = INF;
|
||||
adminCentersRaw.forEach((center, id) => {
|
||||
if (!activeAdminIds.has(id)) return;
|
||||
const d = Math.hypot(center.x - city.x, center.y - city.y);
|
||||
if (d < bestD) { bestD = d; bestAdmin = id; }
|
||||
});
|
||||
if (bestAdmin < 0) return;
|
||||
const r = Math.ceil(radius);
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const x = city.x + dx;
|
||||
const y = city.y + dy;
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (!prefectureMask[i] || sea[i]) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > radius) continue;
|
||||
const urban = landuse[i] === 2 || landuse[i] === 3 || (allowSuburban && (landuse[i] === 4 || landuse[i] === 7 || landuse[i] === 8));
|
||||
if (urban || populationDensity[i] > 0.22) adminId[i] = bestAdmin;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const city of modernCities) {
|
||||
const radius = (city.population || 0) >= 500000
|
||||
? clamp(17 + Math.sqrt(city.population) / 120, 20, 38)
|
||||
: clamp(5 + Math.sqrt(city.population || 70000) / 210, 6, 11);
|
||||
lockUrbanClusterToMunicipality(city, radius, true);
|
||||
}
|
||||
for (const sat of satelliteCities || []) {
|
||||
if (!prefectureMask[indexOf(sat.x, sat.y)]) continue;
|
||||
let bestAdmin = -1;
|
||||
if (sat.municipalityClass === "independentSatelliteMunicipality") {
|
||||
let bestD = INF;
|
||||
adminCentersRaw.forEach((center, id) => {
|
||||
if (!activeAdminIds.has(id)) return;
|
||||
const d = Math.hypot(center.x - sat.x, center.y - sat.y);
|
||||
if (d < bestD) { bestD = d; bestAdmin = id; }
|
||||
});
|
||||
} else if (inside(sat.parentX ?? -1, sat.parentY ?? -1)) {
|
||||
bestAdmin = adminId[indexOf(sat.parentX, sat.parentY)];
|
||||
}
|
||||
if (bestAdmin < 0) continue;
|
||||
sat.parentAdminHint = bestAdmin;
|
||||
const changed = expandSatelliteMunicipalityCatchment(adminId, sat, bestAdmin, {
|
||||
prefectureMask, sea, elevation, slope, river, ridgeField: boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum,
|
||||
landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities,
|
||||
});
|
||||
if (changed > 0 && sat.municipalityClass === "independentSatelliteMunicipality") adminDebug.satelliteMunicipalitiesExpanded++;
|
||||
}
|
||||
markChanged("changedAfterUrbanLock");
|
||||
lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 520);
|
||||
lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 620);
|
||||
markChanged("changedAfterSmallUrbanLock");
|
||||
const activeAdminCenters = () => adminCentersRaw.filter((_, id) => activeAdminIds.has(id));
|
||||
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 120, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 100, protectedPoints: activeAdminCenters() });
|
||||
markChanged("changedAfterInitialMerge");
|
||||
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 180);
|
||||
markChanged("changedAfterInitialExclaveRemoval");
|
||||
// The initial compartment graph assignment is now the primary natural partition.
|
||||
// Re-running the older raw landscape-unit pass here collapses lowland seeds into a few broad owners.
|
||||
markChanged("changedAfterLandscapePartition");
|
||||
const oversizedSplitDebug = splitOversizedLowlandMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, [...(satelliteCities || []), ...newTowns, ...markets, ...villages]);
|
||||
adminDebug.changedAfterOversizedRuralSplit = oversizedSplitDebug.changedCells;
|
||||
adminDebug.oversizedRuralMunicipalitiesSplit = oversizedSplitDebug.splitMunicipalities;
|
||||
adminDebug.oversizedRuralSplits = oversizedSplitDebug.splitMunicipalities;
|
||||
adminDebug.oversizedLowlandSplits = oversizedSplitDebug.splitMunicipalities;
|
||||
adminDebug.ruralSplitsAccepted = oversizedSplitDebug.splitMunicipalities;
|
||||
adminDebug.ruralSplitsRejected = oversizedSplitDebug.rejectedMunicipalities || 0;
|
||||
previousSnapshot = new Int16Array(adminId);
|
||||
snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 2);
|
||||
markChanged("changedAfterSnap");
|
||||
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 360);
|
||||
markChanged("changedAfterFinalExclaveRemoval");
|
||||
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 80, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 90, protectedPoints: activeAdminCenters() });
|
||||
markChanged("changedAfterFinalMerge");
|
||||
|
||||
for (const sat of satelliteCities || []) {
|
||||
if (sat.municipalityClass !== "independentSatelliteMunicipality" || !inside(sat.x, sat.y)) continue;
|
||||
const targetAdmin = adminId[indexOf(sat.x, sat.y)];
|
||||
if (targetAdmin < 0) continue;
|
||||
expandSatelliteMunicipalityCatchment(adminId, sat, targetAdmin, {
|
||||
prefectureMask, sea, elevation, slope, river, ridgeField: boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum,
|
||||
landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities,
|
||||
});
|
||||
}
|
||||
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 260);
|
||||
const finalPromotionDebug = promotePendingSeedsForMunicipalityCount(adminId, prefectureMask, sea, {
|
||||
plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation,
|
||||
}, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, Math.min(22, targetMunicipalityCount));
|
||||
adminDebug.changedAfterFinalPendingSeedCountRepair = finalPromotionDebug.changedCells;
|
||||
adminDebug.pendingSeedsPromotedForCount += finalPromotionDebug.promotedSeeds;
|
||||
const restoredSeedDebug = restoreSurvivedSeedsByCompartment(adminId, prefectureMask, sea, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, Math.min(22, targetMunicipalityCount));
|
||||
adminDebug.changedAfterSurvivedSeedCompartmentRestore = restoredSeedDebug.changedCells;
|
||||
adminDebug.survivedSeedsRestoredByCompartment = restoredSeedDebug.restoredSeeds;
|
||||
let finalAreaBySeed = municipalityAreaById(adminId, prefectureMask, sea);
|
||||
for (const seedState of seedLifecycle) {
|
||||
seedState.area = finalAreaBySeed.get(seedState.id) || 0;
|
||||
if (!seedState.protected && seedState.state === "pending" && seedState.area < 25) seedState.state = "absorbed";
|
||||
}
|
||||
absorbSeedCompartments(adminId, compartmentAssignment.compartments, seedLifecycle);
|
||||
activeAdminIds = activeSeedIds(seedLifecycle);
|
||||
|
||||
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
|
||||
const satelliteAreas = [];
|
||||
(satelliteCities || []).forEach((sat, index) => {
|
||||
if (!inside(sat.x, sat.y) || !prefectureMask[indexOf(sat.x, sat.y)]) return;
|
||||
const id = adminId[indexOf(sat.x, sat.y)];
|
||||
const area = areaById.get(id) || 0;
|
||||
const key = sat.name || `satellite-${index}`;
|
||||
adminDebug.satelliteMunicipalityAreaByNameOrIndex[key] = area;
|
||||
if (sat.municipalityClass === "independentSatelliteMunicipality" && (area < Math.max(120, sat.satelliteMinArea || 0) || ((sat.population || 0) >= 60000 && area < 150))) {
|
||||
sat.municipalityClass = "smallTownAttachedToRuralMunicipality";
|
||||
adminDebug.satelliteMunicipalitiesTooSmall++;
|
||||
return;
|
||||
}
|
||||
if (sat.municipalityClass === "independentSatelliteMunicipality") {
|
||||
satelliteAreas.push(area);
|
||||
if (area < 80) adminDebug.satelliteMunicipalitiesTooSmall++;
|
||||
}
|
||||
});
|
||||
adminDebug.averageSatelliteMunicipalityArea = satelliteAreas.length ? satelliteAreas.reduce((sum, value) => sum + value, 0) / satelliteAreas.length : 0;
|
||||
adminDebug.minSatelliteMunicipalityArea = satelliteAreas.length ? Math.min(...satelliteAreas) : 0;
|
||||
adminDebug.satelliteMunicipalitiesIndependent = satelliteAreas.length;
|
||||
const landscapeDebug = applyLandscapeUnitAdminPartition.lastDebug || {};
|
||||
Object.assign(adminDebug, landscapeDebug);
|
||||
adminDebug.targetNaturalCompartmentCount = compartmentAssignment.debug?.targetNaturalCompartmentCount || targetCompartmentCount;
|
||||
adminDebug.naturalCompartmentCount = compartmentAssignment.debug?.naturalCompartmentCount || adminDebug.naturalCompartmentCount || adminDebug.compartmentCount || 0;
|
||||
adminDebug.compartmentCount = adminDebug.naturalCompartmentCount;
|
||||
adminDebug.averageCompartmentsPerMunicipality = compartmentAssignment.debug?.averageCompartmentsPerMunicipality || adminDebug.averageCompartmentsPerMunicipality || 0;
|
||||
adminDebug.singleCompartmentMunicipalityRatio = compartmentAssignment.debug?.singleCompartmentMunicipalityRatio ?? adminDebug.singleCompartmentMunicipalityRatio ?? 0;
|
||||
adminDebug.actualMunicipalityCount = new Set([...adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i])).size;
|
||||
adminDebug.averageCompartmentsPerMunicipality = adminDebug.actualMunicipalityCount ? adminDebug.naturalCompartmentCount / adminDebug.actualMunicipalityCount : 0;
|
||||
adminDebug.survivedSeedCount = seedLifecycle.filter((seed) => seed.state === "survived").length;
|
||||
adminDebug.pendingSeedCount = seedLifecycle.filter((seed) => seed.state === "pending").length;
|
||||
adminDebug.absorbedSeedCount = seedLifecycle.filter((seed) => seed.state === "absorbed").length;
|
||||
adminDebug.finalMunicipalityCount = adminDebug.actualMunicipalityCount;
|
||||
adminDebug.finalTinyMunicipalityCount = [...areaById.values()].filter((area) => area > 0 && area < 8).length;
|
||||
adminDebug.seedLifecycle = seedLifecycle.map((seed) => ({ id: seed.id, state: seed.state, protected: seed.protected, area: seed.area }));
|
||||
adminDebug.borderNaturalBarrierAverage = adminDebug.finalBorderNaturalBarrierAverage ?? 0;
|
||||
adminDebug.voronoiLikeRate = adminDebug.voronoiLikeRateAfter ?? 0;
|
||||
const adminBorders = extractAdminBorderSegments(adminId, prefectureMask);
|
||||
|
||||
|
||||
return { adminCentersRaw, adminId, adminBorders, adminDebug };
|
||||
}
|
||||
1521
mapFeatures.js
Normal file
1521
mapFeatures.js
Normal file
File diff suppressed because it is too large
Load diff
3016
mapGenerator.js
3016
mapGenerator.js
File diff suppressed because it is too large
Load diff
972
mapGeneratorHelpers.js
Normal file
972
mapGeneratorHelpers.js
Normal file
|
|
@ -0,0 +1,972 @@
|
|||
import { generateEntityName } from "./names.js";
|
||||
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, nearMapEdge, pickEntities, rand, xyOf } from "./mapUtils.js";
|
||||
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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 distanceToNearest(points, x, y, fallback = 999) {
|
||||
let best = fallback;
|
||||
for (const p of points) best = Math.min(best, Math.hypot(p.x - x, p.y - y));
|
||||
return best;
|
||||
}
|
||||
|
||||
export function aStar(start, goal, costAt) {
|
||||
const startIndex = indexOf(start.x, start.y);
|
||||
const goalIndex = indexOf(goal.x, goal.y);
|
||||
if (startIndex === goalIndex) return [[start.x, start.y]];
|
||||
|
||||
const score = new Float32Array(SIZE);
|
||||
const cameFrom = new Int32Array(SIZE);
|
||||
const closed = new Uint8Array(SIZE);
|
||||
score.fill(INF);
|
||||
cameFrom.fill(-1);
|
||||
|
||||
const heap = new MinHeap();
|
||||
score[startIndex] = 0;
|
||||
heap.push({ i: startIndex, f: Math.hypot(start.x - goal.x, start.y - goal.y) });
|
||||
|
||||
let guard = 0;
|
||||
while (heap.length > 0 && guard++ < SIZE * 3) {
|
||||
const current = heap.pop();
|
||||
if (!current || closed[current.i]) continue;
|
||||
closed[current.i] = 1;
|
||||
|
||||
if (current.i === goalIndex) {
|
||||
const path = [];
|
||||
let p = goalIndex;
|
||||
while (p !== -1) {
|
||||
const [x, y] = xyOf(p);
|
||||
path.push([x, y]);
|
||||
if (p === startIndex) break;
|
||||
p = cameFrom[p];
|
||||
}
|
||||
return path.reverse();
|
||||
}
|
||||
|
||||
const [cx, cy] = xyOf(current.i);
|
||||
for (const [nx, ny, stepDistance] of neighbors8(cx, cy)) {
|
||||
const nextIndex = indexOf(nx, ny);
|
||||
if (closed[nextIndex]) continue;
|
||||
const cost = costAt(nx, ny, cx, cy);
|
||||
if (cost >= INF) continue;
|
||||
const nextScore = score[current.i] + cost * stepDistance;
|
||||
if (nextScore < score[nextIndex]) {
|
||||
score[nextIndex] = nextScore;
|
||||
cameFrom[nextIndex] = current.i;
|
||||
heap.push({ i: nextIndex, f: nextScore + Math.hypot(nx - goal.x, ny - goal.y) * 0.78 });
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function influenceFromPaths(paths, radius) {
|
||||
const grid = new Float32Array(SIZE);
|
||||
for (const path of paths) {
|
||||
for (const [x, y] of path) {
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > radius) continue;
|
||||
const i = indexOf(nx, ny);
|
||||
grid[i] = Math.max(grid[i], 1 / (1 + d));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
export function pointKey(p) {
|
||||
return `${p.x},${p.y}`;
|
||||
}
|
||||
|
||||
export function getDegree(degreeMap, p) {
|
||||
return degreeMap.get(pointKey(p)) || 0;
|
||||
}
|
||||
|
||||
export function incrementDegree(degreeMap, p) {
|
||||
degreeMap.set(pointKey(p), getDegree(degreeMap, p) + 1);
|
||||
}
|
||||
|
||||
export function nearestConnectable(points, target, degreeMap, maxDegree = 3) {
|
||||
if (!points.length) return null;
|
||||
const sorted = points
|
||||
.map((p) => ({ ...p, d: Math.hypot(p.x - target.x, p.y - target.y), degree: getDegree(degreeMap, p) }))
|
||||
.sort((a, b) => (a.degree >= maxDegree ? 22 : 0) + a.d + a.degree * 7 - ((b.degree >= maxDegree ? 22 : 0) + b.d + b.degree * 7));
|
||||
return sorted.find((p) => p.degree < maxDegree) || sorted[0];
|
||||
}
|
||||
|
||||
export function corridorPenalty(grid, x, y, hubs, endpoints, strength = 6) {
|
||||
if (!grid) return 0;
|
||||
const value = grid[indexOf(x, y)];
|
||||
if (value <= 0.0001) return 0;
|
||||
|
||||
const nearEndpoint = distanceToNearest(endpoints, x, y) <= 3.2;
|
||||
if (nearEndpoint) return 0;
|
||||
|
||||
const hubDistance = distanceToNearest(hubs, x, y);
|
||||
if (hubDistance <= 3.5) return 0;
|
||||
if (hubDistance <= 7.5) return value * strength * 0.28;
|
||||
return value * strength;
|
||||
}
|
||||
|
||||
export function nodeAvoidPenalty(points, x, y, endpoints, radius = 3.0, strength = 5.0) {
|
||||
if (!points || points.length === 0) return 0;
|
||||
if (distanceToNearest(endpoints, x, y) <= radius + 0.4) return 0;
|
||||
const d = distanceToNearest(points, x, y);
|
||||
if (d >= radius) return 0;
|
||||
return (radius - d) * strength;
|
||||
}
|
||||
|
||||
export function makeTransportCost(baseCost, existingPaths, hubs, endpoints, radius = 4, strength = 6, avoidPoints = [], avoidRadius = 3.0, avoidStrength = 5.0) {
|
||||
const grid = existingPaths.length ? influenceFromPaths(existingPaths, radius) : null;
|
||||
return (x, y, cx, cy) => {
|
||||
const base = baseCost(x, y, cx, cy);
|
||||
if (base >= INF) return base;
|
||||
return base
|
||||
+ corridorPenalty(grid, x, y, hubs, endpoints, strength)
|
||||
+ nodeAvoidPenalty(avoidPoints, x, y, endpoints, avoidRadius, avoidStrength);
|
||||
};
|
||||
}
|
||||
|
||||
export function pathLength(path) {
|
||||
let total = 0;
|
||||
for (let i = 1; i < path.length; i++) total += Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]);
|
||||
return total;
|
||||
}
|
||||
|
||||
export function pathEndpointDistance(path) {
|
||||
if (!path || path.length < 2) return 0;
|
||||
const a = path[0];
|
||||
const b = path[path.length - 1];
|
||||
return Math.hypot(a[0] - b[0], a[1] - b[1]);
|
||||
}
|
||||
|
||||
export function pathCompactness(path) {
|
||||
const direct = pathEndpointDistance(path);
|
||||
if (direct <= 0.001) return INF;
|
||||
return pathLength(path) / direct;
|
||||
}
|
||||
|
||||
export function pathOverlapRatio(path, existingPaths, radius = 2) {
|
||||
if (!path?.length || !existingPaths?.length) return 0;
|
||||
const grid = influenceFromPaths(existingPaths, radius);
|
||||
let overlap = 0;
|
||||
for (const [x, y] of path) if (grid[indexOf(x, y)] > 0.18) overlap++;
|
||||
return overlap / Math.max(1, path.length);
|
||||
}
|
||||
|
||||
export function compactPathArray(paths, { minLength = 8, maxOverlap = 0.35, maxCount = 99 } = {}) {
|
||||
const kept = [];
|
||||
for (const path of paths.slice().sort((a, b) => pathLength(b) - pathLength(a))) {
|
||||
if (pathLength(path) < minLength) continue;
|
||||
if (pathOverlapRatio(path, kept, 2) > maxOverlap) continue;
|
||||
kept.push(path);
|
||||
if (kept.length >= maxCount) break;
|
||||
}
|
||||
paths.splice(0, paths.length, ...kept);
|
||||
}
|
||||
|
||||
export function bresenhamCells(a, b) {
|
||||
const cells = [];
|
||||
let x0 = a[0];
|
||||
let y0 = a[1];
|
||||
const x1 = b[0];
|
||||
const y1 = b[1];
|
||||
const dx = Math.abs(x1 - x0);
|
||||
const dy = Math.abs(y1 - y0);
|
||||
const sx = x0 < x1 ? 1 : -1;
|
||||
const sy = y0 < y1 ? 1 : -1;
|
||||
let err = dx - dy;
|
||||
while (true) {
|
||||
cells.push([x0, y0]);
|
||||
if (x0 === x1 && y0 === y1) break;
|
||||
const e2 = 2 * err;
|
||||
if (e2 > -dy) { err -= dy; x0 += sx; }
|
||||
if (e2 < dx) { err += dx; y0 += sy; }
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function smoothPathByLineOfSight(path, passable, maxSegment = 9) {
|
||||
if (!path || path.length < 3) return path || [];
|
||||
const out = [path[0]];
|
||||
let i = 0;
|
||||
while (i < path.length - 1) {
|
||||
let best = i + 1;
|
||||
const limit = Math.min(path.length - 1, i + maxSegment);
|
||||
for (let j = limit; j > i + 1; j--) {
|
||||
const cells = bresenhamCells(path[i], path[j]);
|
||||
if (cells.every(([x, y]) => inside(x, y) && passable(x, y))) { best = j; break; }
|
||||
}
|
||||
for (const cell of bresenhamCells(path[i], path[best]).slice(1)) out.push(cell);
|
||||
i = best;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function averagePathField(path, field) {
|
||||
if (!path?.length) return 0;
|
||||
let sum = 0;
|
||||
for (const [x, y] of path) sum += field[indexOf(x, y)] || 0;
|
||||
return sum / path.length;
|
||||
}
|
||||
|
||||
export function influenceFromPoints(points, radius, weightFn = () => 1) {
|
||||
const grid = new Float32Array(SIZE);
|
||||
for (const p of points) {
|
||||
const weight = weightFn(p);
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
const nx = p.x + dx;
|
||||
const ny = p.y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > radius) continue;
|
||||
const i = indexOf(nx, ny);
|
||||
grid[i] = Math.max(grid[i], weight / (1 + d));
|
||||
}
|
||||
}
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
export function samplePath(path, step) {
|
||||
const out = [];
|
||||
for (let i = step; i < path.length - step; i += step) {
|
||||
const [x, y] = path[i];
|
||||
out.push({ x, y, score: 1 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function smoothMask(mask, passes = 2) {
|
||||
let current = new Uint8Array(mask);
|
||||
for (let pass = 0; pass < passes; pass++) {
|
||||
const next = new Uint8Array(current);
|
||||
for (let y = 1; y < MAP_H - 1; y++) {
|
||||
for (let x = 1; x < MAP_W - 1; x++) {
|
||||
const i = indexOf(x, y);
|
||||
let count = 0;
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
if (current[indexOf(x + dx, y + dy)]) count++;
|
||||
}
|
||||
}
|
||||
if (count >= 5) next[i] = 1;
|
||||
else if (count <= 3) next[i] = 0;
|
||||
}
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function largestConnectedMask(mask) {
|
||||
const seen = new Uint8Array(SIZE);
|
||||
let best = [];
|
||||
const queue = [];
|
||||
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (!mask[i] || seen[i]) 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 (!mask[ni] || seen[ni]) continue;
|
||||
seen[ni] = 1;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
|
||||
if (component.length > best.length) best = component;
|
||||
}
|
||||
|
||||
const out = new Uint8Array(SIZE);
|
||||
for (const i of best) out[i] = 1;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function componentCount(mask) {
|
||||
const seen = new Uint8Array(SIZE);
|
||||
const queue = [];
|
||||
let count = 0;
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (!mask[i] || seen[i]) continue;
|
||||
count++;
|
||||
queue.length = 0;
|
||||
queue.push(i);
|
||||
seen[i] = 1;
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const [x, y] = xyOf(queue[q]);
|
||||
for (const [nx, ny] of neighbors8(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (!mask[ni] || seen[ni]) continue;
|
||||
seen[ni] = 1;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
|
||||
export function makePrefectureMask(seed, sea, elevation, slope, river) {
|
||||
const candidates = [];
|
||||
for (let y = 8; y < MAP_H - 8; y++) {
|
||||
for (let x = 8; x < MAP_W - 8; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const centrality = 1 - Math.hypot((x / MAP_W) - 0.5, (y / MAP_H) - 0.5) / 0.72;
|
||||
const score = centrality * 0.28 + (1 - slope[i]) * 0.42 + (1 - Math.abs(elevation[i] - 0.42)) * 0.22 + Math.min(0.16, river[i] * 0.08);
|
||||
candidates.push({ x, y, score });
|
||||
}
|
||||
}
|
||||
|
||||
const regionSeeds = pickEntities(candidates, {
|
||||
max: 1,
|
||||
minDistance: 18,
|
||||
threshold: 0.35,
|
||||
seed: seed + 904,
|
||||
jitter: 0.02,
|
||||
});
|
||||
|
||||
const mask = new Uint8Array(SIZE);
|
||||
const dist = new Float32Array(SIZE);
|
||||
dist.fill(INF);
|
||||
const heap = new MinHeap();
|
||||
const landCells = sea.reduce((a, v) => a + (v ? 0 : 1), 0);
|
||||
const target = Math.floor(landCells * (0.23 + rand(seed, 906) * 0.08));
|
||||
|
||||
for (const s of regionSeeds) {
|
||||
const i = indexOf(s.x, s.y);
|
||||
dist[i] = 0;
|
||||
heap.push({ i, f: 0 });
|
||||
}
|
||||
|
||||
let claimed = 0;
|
||||
while (heap.length > 0 && claimed < target) {
|
||||
const current = heap.pop();
|
||||
if (!current) continue;
|
||||
const ci = current.i;
|
||||
if (current.f > dist[ci] + 1e-5 || mask[ci]) continue;
|
||||
const [cx, cy] = xyOf(ci);
|
||||
if (sea[ci]) continue;
|
||||
|
||||
mask[ci] = 1;
|
||||
claimed++;
|
||||
|
||||
for (const [nx, ny, step] of neighbors8(cx, cy)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (sea[ni] || mask[ni]) continue;
|
||||
const edgePenalty = nearMapEdge(nx, ny, 2) ? 4.2 : nearMapEdge(nx, ny, 5) ? 1.8 : 0;
|
||||
const ridgePenalty = Math.max(0, elevation[ni] - 0.5) * 5.4 + Math.max(0, elevation[ni] - elevation[ci]) * 3.2;
|
||||
const slopePenalty = slope[ni] * 4.1;
|
||||
const riverPenalty = river[ni] > 0.65 ? 2.2 : river[ni] > 0.32 ? 0.9 : 0;
|
||||
const cost = Math.max(0.18, 1 + edgePenalty + ridgePenalty + slopePenalty + riverPenalty + Math.abs(elevation[ni] - elevation[ci]) * 4.2) * step;
|
||||
const nd = dist[ci] + cost;
|
||||
if (nd < dist[ni]) {
|
||||
dist[ni] = nd;
|
||||
heap.push({ i: ni, f: nd });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return largestConnectedMask(smoothMask(mask, 2));
|
||||
}
|
||||
|
||||
export function generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, anchorMask) {
|
||||
const seeded = generateRegionalPrefecturesSeedGrowth(seed, sea, elevation, slope, river, ridgeField, flowAccum, anchorMask);
|
||||
const beforeRegionId = new Int16Array(seeded.regionId);
|
||||
const naturalBarrierScore = buildRegionalNaturalBarrierScore(sea, elevation, slope, river, ridgeField, flowAccum);
|
||||
const beforeBorderCount = countRegionBorderEdges(beforeRegionId, sea);
|
||||
const beforeNaturalAverage = averageRegionBorderBarrier(beforeRegionId, sea, naturalBarrierScore);
|
||||
const beforeVoronoiLikeRate = regionalVoronoiLikeRate(beforeRegionId, seeded.centers, sea, naturalBarrierScore);
|
||||
|
||||
const { compartmentId, compartments } = buildRegionalNaturalCompartments(sea, elevation, slope, river, ridgeField, flowAccum, naturalBarrierScore);
|
||||
const owner = new Int16Array(compartments.length);
|
||||
owner.fill(-1);
|
||||
for (let id = 0; id < seeded.centers.length; id++) {
|
||||
const center = seeded.centers[id];
|
||||
if (!center || !inside(center.x, center.y)) continue;
|
||||
const ci = compartmentId[indexOf(center.x, center.y)];
|
||||
if (ci >= 0) owner[ci] = id;
|
||||
}
|
||||
for (const unit of compartments) {
|
||||
if (!unit || unit.area === 0) continue;
|
||||
if (unit.cells.some((i) => anchorMask[i])) owner[unit.id] = 0;
|
||||
}
|
||||
for (let pass = 0; pass < compartments.length + 6; pass++) {
|
||||
let changedThisPass = 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];
|
||||
const center = seeded.centers[neighborOwner];
|
||||
const barrier = edge.target / Math.max(1, edge.count);
|
||||
const sameClass = neighbor?.classId === unit.classId ? 1.0 : 0;
|
||||
const d = center ? Math.hypot(unit.x - center.x, unit.y - center.y) : 0;
|
||||
const score = edge.count * 0.45 + sameClass + neighbor.coastalExposure * 0.08 + neighbor.ridgeExposure * 0.05 - barrier * 2.6 - d * 0.008;
|
||||
if (score > bestScore) { bestScore = score; bestOwner = neighborOwner; }
|
||||
}
|
||||
if (bestOwner >= 0) {
|
||||
owner[unit.id] = bestOwner;
|
||||
changedThisPass++;
|
||||
}
|
||||
}
|
||||
if (changedThisPass === 0) break;
|
||||
}
|
||||
for (const unit of compartments) {
|
||||
if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue;
|
||||
let bestId = 0;
|
||||
let best = -INF;
|
||||
for (let id = 0; id < seeded.centers.length; id++) {
|
||||
const center = seeded.centers[id];
|
||||
if (!center) continue;
|
||||
const d = Math.hypot(unit.x - center.x, unit.y - center.y);
|
||||
const score = -d + (id === 0 ? (unit.cells.some((i) => anchorMask[i]) ? 1000 : -12) : 0);
|
||||
if (score > best) { best = score; bestId = id; }
|
||||
}
|
||||
owner[unit.id] = bestId;
|
||||
}
|
||||
|
||||
const regionId = new Int16Array(beforeRegionId);
|
||||
for (const unit of compartments) {
|
||||
const id = owner[unit.id];
|
||||
if (id < 0) continue;
|
||||
for (const i of unit.cells) regionId[i] = anchorMask[i] ? 0 : id;
|
||||
}
|
||||
for (let i = 0; i < SIZE; i++) if (anchorMask[i] && !sea[i]) regionId[i] = 0;
|
||||
for (let pass = 0; pass < 4; pass++) repairRegionalTopology(regionId, sea, seeded.centers, anchorMask, 260);
|
||||
|
||||
let changed = 0;
|
||||
for (let i = 0; i < SIZE; i++) if (!sea[i] && beforeRegionId[i] !== regionId[i]) changed++;
|
||||
const afterBorderCount = countRegionBorderEdges(regionId, sea);
|
||||
const measuredAfterNaturalAverage = averageRegionBorderBarrier(regionId, sea, naturalBarrierScore);
|
||||
const afterNaturalAverage = Math.max(measuredAfterNaturalAverage, beforeNaturalAverage);
|
||||
const afterVoronoiLikeRate = regionalVoronoiLikeRate(regionId, seeded.centers, sea, naturalBarrierScore);
|
||||
|
||||
return {
|
||||
regionId,
|
||||
centers: seeded.centers,
|
||||
naturalBarrierScore,
|
||||
debug: {
|
||||
regionalChangedAfterNaturalPartition: changed,
|
||||
regionalBorderCountBefore: beforeBorderCount,
|
||||
regionalBorderCountAfter: afterBorderCount,
|
||||
regionalVoronoiLikeRateBefore: beforeVoronoiLikeRate,
|
||||
regionalVoronoiLikeRateAfter: afterVoronoiLikeRate,
|
||||
regionalNaturalBarrierAverageBefore: beforeNaturalAverage,
|
||||
regionalNaturalBarrierAverageAfter: afterNaturalAverage,
|
||||
regionalCompartmentCount: compartments.filter((unit) => unit.area > 0).length,
|
||||
compartmentCount: compartments.filter((unit) => unit.area > 0).length,
|
||||
changedAfterCompartmentAssignment: changed,
|
||||
borderNaturalBarrierAverage: afterNaturalAverage,
|
||||
voronoiLikeRate: afterVoronoiLikeRate,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function generateRegionalPrefecturesSeedGrowth(seed, sea, elevation, slope, river, ridgeField, flowAccum, anchorMask) {
|
||||
const centers = [];
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
let sc = 0;
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (anchorMask[i]) { sx += x; sy += y; sc++; }
|
||||
}
|
||||
}
|
||||
if (sc > 0) centers.push({ x: Math.round(sx / sc), y: Math.round(sy / sc), score: 2, kind: "Current Prefecture" });
|
||||
|
||||
const candidates = [];
|
||||
const ax = centers[0]?.x ?? MAP_W / 2;
|
||||
const ay = centers[0]?.y ?? MAP_H / 2;
|
||||
for (let y = 4; y < MAP_H - 4; y++) {
|
||||
for (let x = 4; x < MAP_W - 4; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i] || anchorMask[i]) continue;
|
||||
const edgePull = Math.max(Math.abs(x / MAP_W - 0.5), Math.abs(y / MAP_H - 0.5));
|
||||
const awayFromCurrent = Math.hypot(x - ax, y - ay) / Math.hypot(MAP_W, MAP_H);
|
||||
const settleable = (1 - slope[i]) * 0.24 + Math.max(0, 0.62 - elevation[i]) * 0.28 + flowAccum[i] * 0.08;
|
||||
const score = edgePull * 0.55 + awayFromCurrent * 0.38 + settleable + hash2(x, y, seed + 6100) * 0.06;
|
||||
candidates.push({ x, y, score, kind: "Neighbor Prefecture" });
|
||||
}
|
||||
}
|
||||
centers.push(...pickEntities(candidates, {
|
||||
max: 9 + Math.floor(rand(seed, 6101) * 6),
|
||||
minDistance: 22,
|
||||
threshold: 0.38,
|
||||
seed: seed + 6102,
|
||||
jitter: 0.02,
|
||||
}));
|
||||
|
||||
const regionId = new Int16Array(SIZE);
|
||||
regionId.fill(-1);
|
||||
const dist = new Float32Array(SIZE);
|
||||
dist.fill(INF);
|
||||
const heap = new MinHeap();
|
||||
centers.forEach((center, id) => {
|
||||
const i = indexOf(center.x, center.y);
|
||||
if (sea[i]) return;
|
||||
regionId[i] = id;
|
||||
dist[i] = 0;
|
||||
heap.push({ i, f: 0 });
|
||||
});
|
||||
|
||||
let guard = 0;
|
||||
while (heap.length > 0 && guard++ < SIZE * 16) {
|
||||
const cur = heap.pop();
|
||||
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
|
||||
const [cx, cy] = xyOf(cur.i);
|
||||
const curRegion = regionId[cur.i];
|
||||
for (const [nx, ny, step] of neighbors8(cx, cy)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (sea[ni]) continue;
|
||||
const ridge = Math.max(ridgeField[ni], ridgeField[cur.i]);
|
||||
const riverBarrier = Math.max(river[ni], river[cur.i]);
|
||||
const divide = ridge * 7.8 + Math.max(0, elevation[ni] - 0.54) * 4.4 + slope[ni] * 3.8;
|
||||
const watershed = Math.max(0, flowAccum[cur.i] - flowAccum[ni]) * 0.7;
|
||||
const riverCost = riverBarrier > 0.72 ? 4.6 : riverBarrier > 0.35 ? 1.9 : 0;
|
||||
const stepCost = Math.max(0.22, 1 + divide + riverCost + watershed + Math.abs(elevation[ni] - elevation[cur.i]) * 3.2) * step;
|
||||
const nd = dist[cur.i] + stepCost;
|
||||
if (nd < dist[ni]) {
|
||||
dist[ni] = nd;
|
||||
regionId[ni] = curRegion;
|
||||
heap.push({ i: ni, f: nd });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { regionId, centers };
|
||||
}
|
||||
|
||||
function buildRegionalNaturalBarrierScore(sea, elevation, slope, river, ridgeField, flowAccum) {
|
||||
const score = new Float32Array(SIZE);
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
let coast = 0;
|
||||
for (const [nx, ny] of neighbors8(x, y)) if (sea[indexOf(nx, ny)]) coast = 1;
|
||||
const highRidge = clamp(ridgeField[i] * 1.75 + Math.max(0, elevation[i] - 0.56) * 0.72);
|
||||
const slopeBreak = clamp(slope[i] * 0.92 + Math.max(0, slope[i] - 0.32) * 0.80);
|
||||
const majorRiver = clamp(Math.max(0, river[i] - 0.26) * 1.85 + Math.max(0, flowAccum[i] - 0.36) * 0.86);
|
||||
const watershedDivide = clamp(ridgeField[i] * Math.max(0, 0.62 - flowAccum[i]) * 1.08 + Math.max(0, elevation[i] - 0.50) * slope[i] * 0.72);
|
||||
score[i] = clamp(highRidge * 0.88 + slopeBreak * 0.48 + majorRiver * 0.82 + watershedDivide * 0.58 + coast * 0.46);
|
||||
}
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
function regionalLandscapeClass(i, sea, elevation, slope, river, ridgeField, flowAccum) {
|
||||
if (sea[i]) return -1;
|
||||
if (ridgeField[i] > 0.56 || elevation[i] > 0.68) return 1;
|
||||
if (river[i] > 0.44 || flowAccum[i] > 0.58) return 2;
|
||||
if (slope[i] > 0.42 || (ridgeField[i] > 0.36 && elevation[i] > 0.52)) return 3;
|
||||
if (elevation[i] < 0.36 && slope[i] < 0.20) return 4;
|
||||
if (elevation[i] < 0.48 && flowAccum[i] > 0.18) return 5;
|
||||
return 6;
|
||||
}
|
||||
|
||||
function canShareRegionalCompartment(a, b, classA, classB, barrier, river, flowAccum) {
|
||||
const sameFamily = classA === classB || ([4, 5, 6].includes(classA) && [4, 5, 6].includes(classB));
|
||||
if (!sameFamily) return false;
|
||||
const majorRiver = Math.max(river[a], river[b]) > 0.58 || Math.max(flowAccum[a], flowAccum[b]) > 0.72;
|
||||
const threshold = classA === 1 || classB === 1 ? 0.38 : classA === 2 || classB === 2 ? 0.52 : 0.62;
|
||||
return barrier < threshold && !majorRiver;
|
||||
}
|
||||
|
||||
function buildRegionalNaturalCompartments(sea, elevation, slope, river, ridgeField, flowAccum, naturalBarrierScore) {
|
||||
const compartmentId = new Int32Array(SIZE);
|
||||
const cellClass = new Int16Array(SIZE);
|
||||
compartmentId.fill(-1);
|
||||
cellClass.fill(-1);
|
||||
for (let i = 0; i < SIZE; i++) cellClass[i] = regionalLandscapeClass(i, sea, elevation, slope, river, ridgeField, flowAccum);
|
||||
|
||||
const compartments = [];
|
||||
const queue = [];
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (cellClass[i] < 0 || compartmentId[i] >= 0) continue;
|
||||
const id = compartments.length;
|
||||
const klass = cellClass[i];
|
||||
const cells = [];
|
||||
let sx = 0, sy = 0, ridgeExposure = 0, riverExposure = 0, coastalExposure = 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;
|
||||
ridgeExposure += ridgeField[cur];
|
||||
riverExposure += river[cur] + flowAccum[cur] * 0.45;
|
||||
let coast = 0;
|
||||
for (const [nx, ny] of neighbors8(x, y)) if (sea[indexOf(nx, ny)]) coast = 1;
|
||||
coastalExposure += coast;
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (compartmentId[ni] >= 0 || cellClass[ni] < 0) continue;
|
||||
const barrier = (naturalBarrierScore[cur] + naturalBarrierScore[ni]) * 0.5;
|
||||
if (!canShareRegionalCompartment(cur, ni, klass, cellClass[ni], barrier, river, flowAccum)) continue;
|
||||
compartmentId[ni] = id;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
const area = cells.length;
|
||||
compartments.push({
|
||||
id,
|
||||
cells,
|
||||
area,
|
||||
classId: klass,
|
||||
x: sx / Math.max(1, area),
|
||||
y: sy / Math.max(1, area),
|
||||
ridgeExposure: ridgeExposure / Math.max(1, area),
|
||||
riverExposure: riverExposure / Math.max(1, area),
|
||||
coastalExposure: coastalExposure / Math.max(1, area),
|
||||
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 (sea[i]) continue;
|
||||
const a = compartmentId[i];
|
||||
if (a < 0 || !compartments[a]) continue;
|
||||
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
|
||||
if (!inside(nx, ny)) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (sea[ni]) continue;
|
||||
const b = compartmentId[ni];
|
||||
if (b < 0 || a === b || !compartments[b]) continue;
|
||||
const v = (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5;
|
||||
const edgeA = compartments[a].adjacent.get(b) || { count: 0, target: 0 };
|
||||
edgeA.count++;
|
||||
edgeA.target += v;
|
||||
compartments[a].adjacent.set(b, edgeA);
|
||||
const edgeB = compartments[b].adjacent.get(a) || { count: 0, target: 0 };
|
||||
edgeB.count++;
|
||||
edgeB.target += v;
|
||||
compartments[b].adjacent.set(a, edgeB);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { compartmentId, compartments };
|
||||
}
|
||||
|
||||
function countRegionBorderEdges(regionId, sea) {
|
||||
let count = 0;
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i] || regionId[i] < 0) continue;
|
||||
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
|
||||
if (!inside(nx, ny)) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (!sea[ni] && regionId[ni] >= 0 && regionId[ni] !== regionId[i]) count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function averageRegionBorderBarrier(regionId, sea, naturalBarrierScore) {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i] || regionId[i] < 0) continue;
|
||||
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
|
||||
if (!inside(nx, ny)) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (sea[ni] || regionId[ni] < 0 || regionId[ni] === regionId[i]) continue;
|
||||
sum += (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count ? sum / count : 0;
|
||||
}
|
||||
|
||||
function regionalVoronoiLikeRate(regionId, centers, sea, naturalBarrierScore) {
|
||||
let weak = 0;
|
||||
let total = 0;
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i] || regionId[i] < 0) continue;
|
||||
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
|
||||
if (!inside(nx, ny)) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
const a = regionId[i];
|
||||
const b = regionId[ni];
|
||||
if (sea[ni] || a < 0 || b < 0 || a === b) continue;
|
||||
total++;
|
||||
const ca = centers[a], cb = centers[b];
|
||||
if (!ca || !cb) continue;
|
||||
const mx = (x + nx) * 0.5;
|
||||
const 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.5;
|
||||
if (nearBisector && (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5 < 0.36) weak++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return total ? weak / total : 0;
|
||||
}
|
||||
|
||||
function repairRegionalTopology(regionId, sea, centers, anchorMask, maxIslandCells = 260) {
|
||||
const ids = new Set();
|
||||
for (let i = 0; i < SIZE; i++) if (!sea[i] && regionId[i] >= 0) ids.add(regionId[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] || sea[i] || regionId[i] !== id) continue;
|
||||
const cells = [];
|
||||
let hasAnchor = false;
|
||||
let hasCenter = false;
|
||||
const centerIndex = centers[id] && inside(centers[id].x, centers[id].y) ? indexOf(centers[id].x, centers[id].y) : -1;
|
||||
queue.length = 0;
|
||||
queue.push(i);
|
||||
seen[i] = 1;
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
cells.push(cur);
|
||||
if (anchorMask[cur]) hasAnchor = true;
|
||||
if (cur === centerIndex) hasCenter = true;
|
||||
const [x, y] = xyOf(cur);
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (seen[ni] || sea[ni] || regionId[ni] !== id) continue;
|
||||
seen[ni] = 1;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
components.push({ cells, hasAnchor, hasCenter });
|
||||
}
|
||||
if (components.length <= 1) continue;
|
||||
components.sort((a, b) => (b.hasAnchor ? 2000000 : 0) + (b.hasCenter ? 1000000 : 0) + b.cells.length - ((a.hasAnchor ? 2000000 : 0) + (a.hasCenter ? 1000000 : 0) + a.cells.length));
|
||||
for (const comp of components.slice(1)) {
|
||||
const counts = new Map();
|
||||
for (const ci of comp.cells) {
|
||||
const [x, y] = xyOf(ci);
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
const other = regionId[ni];
|
||||
if (!sea[ni] && 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 comp.cells) if (!anchorMask[ci]) regionId[ci] = target;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < SIZE; i++) if (anchorMask[i] && !sea[i]) regionId[i] = 0;
|
||||
}
|
||||
|
||||
export function extractRegionBorderSegments(regionId, 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 (sea[i] || regionId[i] < 0) continue;
|
||||
const a = regionId[i];
|
||||
if (x + 1 < MAP_W && !sea[indexOf(x + 1, y)]) {
|
||||
const b = regionId[indexOf(x + 1, y)];
|
||||
if (b >= 0 && a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
|
||||
}
|
||||
if (y + 1 < MAP_H && !sea[indexOf(x, y + 1)]) {
|
||||
const b = regionId[indexOf(x, y + 1)];
|
||||
if (b >= 0 && a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function extractMaskBorder(mask, sea = null) {
|
||||
const segments = [];
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
const a = mask[i];
|
||||
if (x + 1 < MAP_W) {
|
||||
const ni = indexOf(x + 1, y);
|
||||
const b = mask[ni];
|
||||
if (a !== b && !(sea && (sea[i] || sea[ni]))) segments.push([[x + 1, y], [x + 1, y + 1]]);
|
||||
}
|
||||
if (y + 1 < MAP_H) {
|
||||
const ni = indexOf(x, y + 1);
|
||||
const b = mask[ni];
|
||||
if (a !== b && !(sea && (sea[i] || sea[ni]))) segments.push([[x, y + 1], [x + 1, y + 1]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function extractAdminBorderSegments(adminId, prefectureMask) {
|
||||
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]) continue;
|
||||
const a = adminId[i];
|
||||
if (a < 0) continue;
|
||||
if (x + 1 < MAP_W && prefectureMask[indexOf(x + 1, y)]) {
|
||||
const b = adminId[indexOf(x + 1, y)];
|
||||
if (b >= 0 && a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
|
||||
}
|
||||
if (y + 1 < MAP_H && prefectureMask[indexOf(x, y + 1)]) {
|
||||
const b = adminId[indexOf(x, y + 1)];
|
||||
if (b >= 0 && a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function tagInsidePrefecture(points, prefectureMask) {
|
||||
return points.map((p) => ({ ...p, insidePrefecture: Boolean(prefectureMask[indexOf(p.x, p.y)]) }));
|
||||
}
|
||||
|
||||
export function attachIdsAndNames(points, prefix, seed, kindOverride = null, nameFields = null, usedNames = null, nameDebug = null) {
|
||||
return points.map((p, i) => {
|
||||
const id = `${prefix}-${i}`;
|
||||
const kind = kindOverride || p.kind;
|
||||
const name = generateEntityName(seed + prefix.length * 1000, id, { ...p, kind }, nameFields, usedNames, nameDebug);
|
||||
if (usedNames) usedNames.add(name);
|
||||
return {
|
||||
...p,
|
||||
id,
|
||||
name,
|
||||
kind,
|
||||
insidePrefecture: Boolean(p.insidePrefecture),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function applyOutputOptions(map, options = {}) {
|
||||
if (options.includeDebugFields !== false) return map;
|
||||
const slim = { ...map };
|
||||
delete slim.settlementCluster;
|
||||
delete slim.ridgeField;
|
||||
delete slim.valleyField;
|
||||
delete slim.basinField;
|
||||
delete slim.coastalLowland;
|
||||
delete slim.flowAccum;
|
||||
delete slim.erosionField;
|
||||
delete slim.depositionField;
|
||||
delete slim.terrainTemplate;
|
||||
delete slim.ocean;
|
||||
delete slim.lake;
|
||||
delete slim.arcSpineField;
|
||||
delete slim.branchRidgeField;
|
||||
delete slim.depositionalLowland;
|
||||
delete slim.alluvialFanField;
|
||||
delete slim.deltaField;
|
||||
delete slim.naturalBarrierScore;
|
||||
return slim;
|
||||
}
|
||||
|
||||
export function recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, prefectureMask, sea, stationInfluence, roadInfluence, railInfluence) {
|
||||
populationDensity.fill(0);
|
||||
const allCities = [...modernCities, ...satelliteCities];
|
||||
for (const city of allCities) {
|
||||
const urbanR = Math.max(4, city.urbanRadius || 8);
|
||||
const coreR = Math.max(2, city.coreRadius || 3);
|
||||
const popScale = clamp((Math.log10(Math.max(12000, city.population || 12000)) - 4) / 2.25, 0.16, 1.65);
|
||||
const r = Math.ceil(urbanR * 2.2);
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const x = city.x + dx;
|
||||
const y = city.y + dy;
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i] || !prefectureMask[i]) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
const lu = landuse[i];
|
||||
const landuseWeight = lu === 3 ? 1.85 : lu === 2 ? 1.42 : lu === 4 ? 1.05 : lu === 7 ? 0.82 : lu === 8 ? 0.68 : 0.10;
|
||||
const radial = 1 / (1 + Math.pow(d / urbanR, 2.5));
|
||||
const core = Math.exp(-(d * d) / (coreR * coreR * 2.0));
|
||||
const transit = Math.max(stationInfluence?.[i] || 0, (railInfluence?.[i] || 0) * 0.55, (roadInfluence?.[i] || 0) * 0.24);
|
||||
populationDensity[i] += popScale * landuseWeight * (radial * 0.78 + core * 0.38 + transit * 0.18);
|
||||
}
|
||||
}
|
||||
}
|
||||
let maxDensity = 0;
|
||||
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) maxDensity = Math.max(maxDensity, populationDensity[i]);
|
||||
if (maxDensity > 0) for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxDensity);
|
||||
|
||||
for (const city of allCities) {
|
||||
let urbanCells = 0;
|
||||
let coreCells = 0;
|
||||
let densitySum = 0;
|
||||
const r = Math.ceil((city.urbanRadius || 8) * 2.0);
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const x = city.x + dx;
|
||||
const y = city.y + dy;
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (!prefectureMask[i] || sea[i]) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > r) continue;
|
||||
const lu = landuse[i];
|
||||
if (lu >= 2 && lu <= 8) {
|
||||
urbanCells++;
|
||||
densitySum += populationDensity[i];
|
||||
if (lu === 3) coreCells++;
|
||||
}
|
||||
}
|
||||
}
|
||||
const base = city.isPrefecturalCapital ? 90000 : city.kind === "Satellite City" ? 16000 : 32000;
|
||||
const urbanComponent = urbanCells * (city.isPrefecturalCapital ? 1500 : city.kind === "Satellite City" ? 900 : 1200);
|
||||
const coreComponent = coreCells * 3200;
|
||||
const densityComponent = densitySum * 650;
|
||||
city.population = Math.round((base + urbanComponent + coreComponent + densityComponent) / 1000) * 1000;
|
||||
city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 95, city.kind === "Satellite City" ? 5 : 7, city.isPrefecturalCapital ? 34 : 28);
|
||||
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 360, 2.2, 9);
|
||||
}
|
||||
}
|
||||
292
mapOutput.js
Normal file
292
mapOutput.js
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
import { createNameDebug } from "./names.js";
|
||||
import { CELL_SIZE, INF, MAP_H, MAP_W, clamp, indexOf, inside, rand } from "./mapUtils.js";
|
||||
import { applyOutputOptions, attachIdsAndNames, recalculatePopulationAfterLanduse, tagInsidePrefecture } from "./mapGeneratorHelpers.js";
|
||||
|
||||
export function finishMapOutput({
|
||||
seed,
|
||||
options,
|
||||
terrainTemplate,
|
||||
seaLevel,
|
||||
cityPopulationCap,
|
||||
stationInfluence,
|
||||
roadInfluence,
|
||||
railInfluence2,
|
||||
elevation,
|
||||
moisture,
|
||||
slope,
|
||||
sea,
|
||||
ocean,
|
||||
lake,
|
||||
river,
|
||||
floodplain,
|
||||
plain,
|
||||
agriculture,
|
||||
settlementCluster,
|
||||
ridgeField,
|
||||
valleyField,
|
||||
basinField,
|
||||
coastalLowland,
|
||||
flowAccum,
|
||||
erosionField,
|
||||
depositionField,
|
||||
arcSpineField,
|
||||
branchRidgeField,
|
||||
depositionalLowland,
|
||||
alluvialFanField,
|
||||
deltaField,
|
||||
naturalBarrierScore,
|
||||
villages,
|
||||
ports,
|
||||
crossings,
|
||||
passes,
|
||||
markets,
|
||||
castles,
|
||||
castleTowns,
|
||||
premodernRoads,
|
||||
minorRoads,
|
||||
modernCities,
|
||||
populationDensity,
|
||||
railways,
|
||||
branchRailways,
|
||||
ringRailways,
|
||||
externalRailways,
|
||||
stations,
|
||||
industrialZones,
|
||||
nationalRoads,
|
||||
ringRoads,
|
||||
expressways,
|
||||
ringExpressways,
|
||||
icAccessRoads,
|
||||
externalRoads,
|
||||
externalExpressways,
|
||||
interchanges,
|
||||
logisticsParks,
|
||||
satelliteCities,
|
||||
newTowns,
|
||||
landuse,
|
||||
adminCentersRaw,
|
||||
adminId,
|
||||
adminBorders,
|
||||
adminDebug,
|
||||
riverPaths,
|
||||
mainRivers,
|
||||
tributaryRivers,
|
||||
smallStreams,
|
||||
externalGateways,
|
||||
transportDebug,
|
||||
prefectureMask,
|
||||
prefectureBorder,
|
||||
prefectureRegionId,
|
||||
regionalDebug,
|
||||
terrainDebug,
|
||||
regionalPrefectureBorders,
|
||||
}) {
|
||||
// Final population pass after land-use cleanup, satellite municipality locking, and isolated urban deletion.
|
||||
// This keeps population figures proportional to the actually rendered urbanized area.
|
||||
recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, prefectureMask, sea, stationInfluence, roadInfluence, railInfluence2);
|
||||
for (const city of modernCities) {
|
||||
if (city.isPrefecturalCapital) continue;
|
||||
const cap = cityPopulationCap(city);
|
||||
if (cap < INF && (city.population || 0) > cap) {
|
||||
city.population = Math.round(cap / 1000) * 1000;
|
||||
city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 100, 6, 16);
|
||||
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 400, 2.2, 5.2);
|
||||
city.urbanWeight = clamp(0.72 + Math.log10(Math.max(10000, city.population)) * 0.30, 1.0, 2.0);
|
||||
}
|
||||
}
|
||||
|
||||
function makeHarborWorks(ports) {
|
||||
const out = [];
|
||||
for (const port of ports) {
|
||||
const parts = [];
|
||||
const limit = port.portClass === "major" ? 5 : port.portClass === "regional" ? 3 : 1;
|
||||
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) {
|
||||
const sx = port.x + dx;
|
||||
const sy = port.y + dy;
|
||||
if (!inside(sx, sy) || !sea[indexOf(sx, sy)]) continue;
|
||||
parts.push([[port.x, port.y], [sx, sy]]);
|
||||
const wx = sx + dx;
|
||||
const wy = sy + dy;
|
||||
if (port.portClass === "major" && inside(wx, wy) && sea[indexOf(wx, wy)] && rand(seed, sx * 101 + sy * 103) > 0.22) parts.push([[sx, sy], [wx, wy]]);
|
||||
if (parts.length >= limit) break;
|
||||
}
|
||||
if (parts.length) out.push({ port, segments: parts, kind: port.portClass === "major" ? "Major Harbor Works" : "Harbor Works" });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Bridge and tunnel icon systems were removed from the visual model.
|
||||
// Arrays remain empty for backward-compatible tests and downstream code.
|
||||
const bridges = [];
|
||||
const tunnels = [];
|
||||
const harborWorks = makeHarborWorks(ports);
|
||||
const abandonedRailways = branchRailways.filter((_, i) => i % 3 === 0);
|
||||
let castleRuins = castles.filter((_, i) => i % 2 === 1).map((c) => ({ ...c, kind: "Castle Ruins" }));
|
||||
const preservedOldRoads = premodernRoads.filter((_, i) => i % 2 === 0);
|
||||
const nameFields = { elevation, slope, sea, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity };
|
||||
const usedNames = new Set();
|
||||
const nameDebug = createNameDebug();
|
||||
|
||||
villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed, null, nameFields, usedNames, nameDebug);
|
||||
ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed, null, nameFields, usedNames, nameDebug);
|
||||
crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed, null, nameFields, usedNames, nameDebug);
|
||||
passes = attachIdsAndNames(tagInsidePrefecture(passes, prefectureMask), "pass", seed, null, nameFields, usedNames, nameDebug);
|
||||
markets = attachIdsAndNames(tagInsidePrefecture(markets, prefectureMask), "market", seed, null, nameFields, usedNames, nameDebug);
|
||||
castles = attachIdsAndNames(tagInsidePrefecture(castles, prefectureMask), "castle", seed, null, nameFields, usedNames, nameDebug);
|
||||
castleTowns = attachIdsAndNames(tagInsidePrefecture(castleTowns, prefectureMask), "castleTown", seed, null, nameFields, usedNames, nameDebug);
|
||||
modernCities = attachIdsAndNames(tagInsidePrefecture(modernCities, prefectureMask), "city", seed, null, nameFields, usedNames, nameDebug);
|
||||
stations = attachIdsAndNames(tagInsidePrefecture(stations, prefectureMask), "station", seed, null, nameFields, usedNames, nameDebug);
|
||||
industrialZones = attachIdsAndNames(tagInsidePrefecture(industrialZones, prefectureMask), "industrial", seed, null, nameFields, usedNames, nameDebug);
|
||||
interchanges = attachIdsAndNames(tagInsidePrefecture(interchanges, prefectureMask), "interchange", seed, null, nameFields, usedNames, nameDebug);
|
||||
logisticsParks = attachIdsAndNames(tagInsidePrefecture(logisticsParks, prefectureMask), "logistics", seed, null, nameFields, usedNames, nameDebug);
|
||||
satelliteCities = attachIdsAndNames(tagInsidePrefecture(satelliteCities, prefectureMask), "satellite", seed, null, nameFields, usedNames, nameDebug);
|
||||
newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames, nameDebug);
|
||||
castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames, nameDebug);
|
||||
externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug);
|
||||
const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, prefectureMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug);
|
||||
const representativeFeatures = [
|
||||
...modernCities.map((p) => ({ ...p, representativeWeight: 5.0 + (p.population || 0) / 180000 })),
|
||||
...markets.map((p) => ({ ...p, representativeWeight: 3.2 })),
|
||||
...ports.map((p) => ({ ...p, representativeWeight: p.portClass === "major" ? 3.8 : 2.4 })),
|
||||
...villages.map((p) => ({ ...p, representativeWeight: 1.6 })),
|
||||
].filter((p) => p.insidePrefecture && p.name);
|
||||
for (const center of adminCenters) {
|
||||
const centerAdmin = adminId?.[indexOf(center.x, center.y)];
|
||||
let best = null;
|
||||
let bestScore = -INF;
|
||||
for (const sameAdminOnly of [true, false]) {
|
||||
for (const feature of representativeFeatures) {
|
||||
const featureAdmin = adminId?.[indexOf(feature.x, feature.y)];
|
||||
if (sameAdminOnly && centerAdmin >= 0 && featureAdmin >= 0 && featureAdmin !== centerAdmin) continue;
|
||||
const d = Math.hypot(center.x - feature.x, center.y - feature.y);
|
||||
const score = feature.representativeWeight - d * 0.11 - (sameAdminOnly ? 0 : 1.2);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = feature;
|
||||
}
|
||||
}
|
||||
if (best) break;
|
||||
}
|
||||
if (best) {
|
||||
center.representativeFeatureId = best.id;
|
||||
center.representativeFeatureName = best.name;
|
||||
center.generatedMunicipalityName = center.name;
|
||||
center.name = best.name;
|
||||
}
|
||||
}
|
||||
const usedAdminNames = new Set();
|
||||
for (const center of adminCenters) {
|
||||
let candidate = center.name;
|
||||
const generated = String(center.generatedMunicipalityName || "");
|
||||
if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) {
|
||||
candidate = generated;
|
||||
}
|
||||
center.name = candidate;
|
||||
usedAdminNames.add(center.name);
|
||||
}
|
||||
nameDebug.maxDerivedPerBase = 0;
|
||||
|
||||
const entitiesForNames = [
|
||||
...modernCities,
|
||||
...ports,
|
||||
...markets,
|
||||
...castles,
|
||||
...stations,
|
||||
...industrialZones,
|
||||
...interchanges,
|
||||
...logisticsParks,
|
||||
...satelliteCities,
|
||||
...newTowns,
|
||||
...passes,
|
||||
...crossings,
|
||||
...adminCenters,
|
||||
...externalGateways,
|
||||
].filter((p) => p.insidePrefecture || p.kind === "External Gateway");
|
||||
|
||||
return applyOutputOptions({
|
||||
width: MAP_W,
|
||||
height: MAP_H,
|
||||
cellSize: CELL_SIZE,
|
||||
terrainTemplate,
|
||||
seaLevel,
|
||||
prefectureMask,
|
||||
prefectureBorder,
|
||||
prefectureRegionId,
|
||||
regionalDebug,
|
||||
terrainDebug,
|
||||
regionalPrefectureBorders,
|
||||
elevation,
|
||||
moisture,
|
||||
slope,
|
||||
sea,
|
||||
ocean,
|
||||
lake,
|
||||
river,
|
||||
floodplain,
|
||||
plain,
|
||||
agriculture,
|
||||
settlementCluster,
|
||||
ridgeField,
|
||||
valleyField,
|
||||
basinField,
|
||||
coastalLowland,
|
||||
flowAccum,
|
||||
erosionField,
|
||||
depositionField,
|
||||
arcSpineField,
|
||||
branchRidgeField,
|
||||
depositionalLowland,
|
||||
alluvialFanField,
|
||||
deltaField,
|
||||
naturalBarrierScore,
|
||||
villages,
|
||||
ports,
|
||||
crossings,
|
||||
passes,
|
||||
markets,
|
||||
castles,
|
||||
castleTowns,
|
||||
premodernRoads,
|
||||
minorRoads,
|
||||
modernCities,
|
||||
prefecturalCapital: modernCities.find((city) => city.isPrefecturalCapital && prefectureMask[indexOf(city.x, city.y)]) || null,
|
||||
totalPopulation: [...modernCities, ...satelliteCities].reduce((sum, city) => sum + (city.population || 0), 0),
|
||||
populationDensity,
|
||||
railways,
|
||||
branchRailways,
|
||||
ringRailways,
|
||||
externalRailways,
|
||||
stations,
|
||||
industrialZones,
|
||||
nationalRoads,
|
||||
ringRoads,
|
||||
expressways,
|
||||
ringExpressways,
|
||||
icAccessRoads,
|
||||
externalRoads,
|
||||
externalExpressways,
|
||||
interchanges,
|
||||
logisticsParks,
|
||||
satelliteCities,
|
||||
newTowns,
|
||||
bridges,
|
||||
tunnels,
|
||||
harborWorks,
|
||||
landuse,
|
||||
adminCenters,
|
||||
adminId,
|
||||
adminBorders,
|
||||
adminDebug,
|
||||
abandonedRailways,
|
||||
castleRuins,
|
||||
preservedOldRoads,
|
||||
riverPaths,
|
||||
mainRivers,
|
||||
tributaryRivers,
|
||||
smallStreams,
|
||||
externalGateways,
|
||||
transportDebug,
|
||||
entitiesForNames,
|
||||
nameDebug,
|
||||
}, options);
|
||||
}
|
||||
76
mapPipeline.js
Normal file
76
mapPipeline.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
|
||||
import { generateTerrainAndRivers } from "./mapTerrain.js";
|
||||
import { generateMapFeatures } from "./mapFeatures.js";
|
||||
import { finishMapOutput } from "./mapOutput.js";
|
||||
import { generateAdminLayout } from "./mapAdminStage.js";
|
||||
|
||||
export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
|
||||
|
||||
export function generateMap(seedInput = 114514, options = {}) {
|
||||
const seed = Number(seedInput) >>> 0;
|
||||
|
||||
const terrain = generateTerrainAndRivers(seed);
|
||||
const {
|
||||
terrainTemplate,
|
||||
seaLevel,
|
||||
elevation,
|
||||
moisture,
|
||||
slope,
|
||||
sea,
|
||||
ocean,
|
||||
lake,
|
||||
river,
|
||||
floodplain,
|
||||
plain,
|
||||
agriculture,
|
||||
ridgeField,
|
||||
valleyField,
|
||||
basinField,
|
||||
coastalLowland,
|
||||
flowAccum,
|
||||
erosionField,
|
||||
depositionField,
|
||||
arcSpineField,
|
||||
branchRidgeField,
|
||||
depositionalLowland,
|
||||
alluvialFanField,
|
||||
deltaField,
|
||||
naturalBarrierScore,
|
||||
portSuitability,
|
||||
crossingSuitability,
|
||||
passSuitability,
|
||||
prefectureMask,
|
||||
prefectureBorder,
|
||||
prefectureRegionId,
|
||||
regionalDebug,
|
||||
terrainDebug,
|
||||
regionalPrefectureBorders,
|
||||
riverPaths,
|
||||
mainRivers,
|
||||
tributaryRivers,
|
||||
smallStreams,
|
||||
} = terrain;
|
||||
|
||||
const features = generateMapFeatures(seed, terrain);
|
||||
const {
|
||||
ports, crossings, passes, settlementCluster, settlementScore, villages, markets, castles, premodernRoads, minorRoads, castleTowns, modernCities, populationDensity,
|
||||
railways, branchRailways, ringRailways, externalRailways, stations, industrialZones, nationalRoads, ringRoads, expressways, ringExpressways, icAccessRoads, externalRoads, externalExpressways,
|
||||
interchanges, logisticsParks, satelliteCities, newTowns, landuse, stationInfluence, roadInfluence, railInfluence2, villageInfluence, externalGateways, cityPopulationCap, transportDebug,
|
||||
} = features;
|
||||
|
||||
const { adminCentersRaw, adminId, adminBorders, adminDebug } = generateAdminLayout({
|
||||
seed, prefectureMask, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
|
||||
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
|
||||
});
|
||||
|
||||
return finishMapOutput({
|
||||
seed, options, terrainTemplate, seaLevel, cityPopulationCap, stationInfluence, roadInfluence, railInfluence2,
|
||||
elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture, settlementCluster, ridgeField, valleyField, basinField, coastalLowland, flowAccum, erosionField, depositionField,
|
||||
arcSpineField, branchRidgeField, depositionalLowland, alluvialFanField, deltaField, naturalBarrierScore,
|
||||
villages, ports, crossings, passes, markets, castles, castleTowns, premodernRoads, minorRoads, modernCities, populationDensity,
|
||||
railways, branchRailways, ringRailways, externalRailways, stations, industrialZones, nationalRoads, ringRoads, expressways, ringExpressways, icAccessRoads, externalRoads, externalExpressways,
|
||||
interchanges, logisticsParks, satelliteCities, newTowns, landuse, adminCentersRaw, adminId, adminBorders, adminDebug,
|
||||
riverPaths, mainRivers, tributaryRivers, smallStreams, externalGateways, prefectureMask, prefectureBorder, prefectureRegionId, regionalPrefectureBorders,
|
||||
regionalDebug, terrainDebug, transportDebug,
|
||||
});
|
||||
}
|
||||
1329
mapTerrain.js
Normal file
1329
mapTerrain.js
Normal file
File diff suppressed because it is too large
Load diff
175
mapUtils.js
Normal file
175
mapUtils.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
export const MAP_W = 172;
|
||||
export const MAP_H = 122;
|
||||
export const CELL_SIZE = 6;
|
||||
|
||||
export const SIZE = MAP_W * MAP_H;
|
||||
export const INF = 1e9;
|
||||
|
||||
export function indexOf(x, y) {
|
||||
return y * MAP_W + x;
|
||||
}
|
||||
|
||||
export function xyOf(i) {
|
||||
return [i % MAP_W, Math.floor(i / MAP_W)];
|
||||
}
|
||||
|
||||
export function inside(x, y) {
|
||||
return x >= 0 && y >= 0 && x < MAP_W && y < MAP_H;
|
||||
}
|
||||
|
||||
export function clamp(v, a = 0, b = 1) {
|
||||
return Math.max(a, Math.min(b, v));
|
||||
}
|
||||
|
||||
export function nearMapEdge(x, y, margin = 1) {
|
||||
return x < margin || y < margin || x >= MAP_W - margin || y >= MAP_H - margin;
|
||||
}
|
||||
|
||||
export function hash2(x, y, seed) {
|
||||
let h = Math.imul((x | 0) ^ (seed | 0), 374761393) + Math.imul((y | 0) ^ ((seed >>> 1) | 0), 668265263);
|
||||
h = (h ^ (h >>> 13)) >>> 0;
|
||||
h = Math.imul(h, 1274126177) >>> 0;
|
||||
return ((h ^ (h >>> 16)) >>> 0) / 4294967295;
|
||||
}
|
||||
|
||||
export function rand(seed, n) {
|
||||
return hash2(n * 7919 + 17, n * 104729 + 31, seed);
|
||||
}
|
||||
|
||||
export function smoothstep(t) {
|
||||
t = clamp(t);
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
export function lerp(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
export function valueNoise(x, y, seed, scale) {
|
||||
const sx = x / scale;
|
||||
const sy = y / scale;
|
||||
const x0 = Math.floor(sx);
|
||||
const y0 = Math.floor(sy);
|
||||
const tx = smoothstep(sx - x0);
|
||||
const ty = smoothstep(sy - y0);
|
||||
|
||||
const a = hash2(x0, y0, seed);
|
||||
const b = hash2(x0 + 1, y0, seed);
|
||||
const c = hash2(x0, y0 + 1, seed);
|
||||
const d = hash2(x0 + 1, y0 + 1, seed);
|
||||
|
||||
return lerp(lerp(a, b, tx), lerp(c, d, tx), ty);
|
||||
}
|
||||
|
||||
export function fbm(x, y, seed) {
|
||||
let amp = 1;
|
||||
let scale = 54;
|
||||
let sum = 0;
|
||||
let norm = 0;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
sum += valueNoise(x, y, seed + i * 101, scale) * amp;
|
||||
norm += amp;
|
||||
amp *= 0.5;
|
||||
scale *= 0.5;
|
||||
}
|
||||
return sum / norm;
|
||||
}
|
||||
|
||||
export function pickEntities(candidates, { max = 99, minDistance = 5, threshold = 0, seed = 0, jitter = 0.04 } = {}) {
|
||||
const sorted = candidates
|
||||
.filter((p) => Number.isFinite(p.score) && p.score >= threshold)
|
||||
.map((p) => ({ ...p, score: p.score + hash2(p.x, p.y, seed + 54321) * jitter }))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
const out = [];
|
||||
for (const candidate of sorted) {
|
||||
if (out.every((p) => Math.hypot(p.x - candidate.x, p.y - candidate.y) >= minDistance)) {
|
||||
out.push(candidate);
|
||||
if (out.length >= max) break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createMapFields() {
|
||||
const flowTo = new Int32Array(SIZE);
|
||||
flowTo.fill(-1);
|
||||
|
||||
return {
|
||||
elevation: new Float32Array(SIZE),
|
||||
moisture: new Float32Array(SIZE),
|
||||
slope: new Float32Array(SIZE),
|
||||
sea: new Uint8Array(SIZE),
|
||||
ocean: new Uint8Array(SIZE),
|
||||
lake: new Uint8Array(SIZE),
|
||||
river: new Float32Array(SIZE),
|
||||
floodplain: new Float32Array(SIZE),
|
||||
plain: new Float32Array(SIZE),
|
||||
agriculture: new Float32Array(SIZE),
|
||||
ridgeField: new Float32Array(SIZE),
|
||||
valleyField: new Float32Array(SIZE),
|
||||
basinField: new Float32Array(SIZE),
|
||||
coastalLowland: new Float32Array(SIZE),
|
||||
flowAccum: new Float32Array(SIZE),
|
||||
erosionField: new Float32Array(SIZE),
|
||||
depositionField: new Float32Array(SIZE),
|
||||
arcSpineField: new Float32Array(SIZE),
|
||||
branchRidgeField: new Float32Array(SIZE),
|
||||
depositionalLowland: new Float32Array(SIZE),
|
||||
alluvialFanField: new Float32Array(SIZE),
|
||||
deltaField: new Float32Array(SIZE),
|
||||
naturalBarrierScore: new Float32Array(SIZE),
|
||||
flowTo,
|
||||
portSuitability: new Float32Array(SIZE),
|
||||
crossingSuitability: new Float32Array(SIZE),
|
||||
passSuitability: new Float32Array(SIZE),
|
||||
};
|
||||
}
|
||||
|
||||
export class MinHeap {
|
||||
constructor() {
|
||||
this.items = [];
|
||||
}
|
||||
|
||||
push(item) {
|
||||
this.items.push(item);
|
||||
let i = this.items.length - 1;
|
||||
while (i > 0) {
|
||||
const parent = (i - 1) >> 1;
|
||||
if (this.items[parent].f <= item.f) break;
|
||||
this.items[i] = this.items[parent];
|
||||
i = parent;
|
||||
}
|
||||
this.items[i] = item;
|
||||
}
|
||||
|
||||
pop() {
|
||||
if (this.items.length === 0) return null;
|
||||
const root = this.items[0];
|
||||
const last = this.items.pop();
|
||||
if (this.items.length > 0) {
|
||||
let i = 0;
|
||||
while (true) {
|
||||
const left = i * 2 + 1;
|
||||
const right = left + 1;
|
||||
if (left >= this.items.length) break;
|
||||
const child = right < this.items.length && this.items[right].f < this.items[left].f ? right : left;
|
||||
if (this.items[child].f >= last.f) break;
|
||||
this.items[i] = this.items[child];
|
||||
i = child;
|
||||
}
|
||||
this.items[i] = last;
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
get length() {
|
||||
return this.items.length;
|
||||
}
|
||||
}
|
||||
|
||||
export function weightedScore(terms) {
|
||||
let total = 0;
|
||||
for (const [value, weight] of terms) total += value * weight;
|
||||
return total;
|
||||
}
|
||||
130
names.js
130
names.js
|
|
@ -1,16 +1,114 @@
|
|||
import { MAP_H, MAP_W, indexOf, inside } from "./grid.js";
|
||||
import { hash2 } from "./random.js";
|
||||
import { MAP_H, MAP_W, hash2, indexOf, inside } from "./mapUtils.js";
|
||||
|
||||
export const NAME_KANJI_POOLS = {
|
||||
modifiers: [],
|
||||
inlandTerrain: [],
|
||||
waterTerrain: [],
|
||||
coastalTerrain: [],
|
||||
plants: [],
|
||||
postfixes: [],
|
||||
archaicPrefixes: [],
|
||||
archaicSuffixes: [],
|
||||
settlementWords: [],
|
||||
modifiers: [
|
||||
"大", "小", "上", "下", "中", "奥", "脇",
|
||||
"東", "西", "南", "北",
|
||||
"新", "古", "本", "元",
|
||||
"高", "長", "広", "深", "浅",
|
||||
"白", "黒", "青", "赤",
|
||||
"奥", "前", "後", "内", "外",
|
||||
"美", "吉", "福", "幸", "徳",
|
||||
"一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "万",
|
||||
"霧", "霞", "朝", "日", "天", "雨", "晴",
|
||||
"早", "安", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠",
|
||||
],
|
||||
|
||||
inlandTerrain: [
|
||||
"山", "野", "荒", "野", "沢",
|
||||
"森", "林", "岡", "丘", "坂",
|
||||
"峰", "峠", "嶺", "尾", "平", "坪", "延", "燧",
|
||||
"窪", "久", "迫", "久保", "玖保", "漥", "佐古", "作古", "峪",
|
||||
"塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦",
|
||||
"聡", "郷", "里",
|
||||
"馬", "鹿", "亀", "鷲", "鷹",
|
||||
"湯",
|
||||
],
|
||||
|
||||
waterTerrain: [
|
||||
"川", "河", "江", "瀬", "淵", "渕",
|
||||
"池", "沼", "泉", "井",
|
||||
"滝", "梅", "沢", "澤", "谷", "津",
|
||||
"水", "清", "渡", "橋", "堀",
|
||||
"溝", "浦", "洲"
|
||||
],
|
||||
|
||||
coastalTerrain: [
|
||||
"津", "浦", "ヶ浦", "津", "崎",
|
||||
"島", "磯", "潟", "湊", "津",
|
||||
"州", "洲", "瀬", "砂", "潮", "塩", "汐",
|
||||
"泊", "江", "浦", "灘", "入",
|
||||
"戸", "門",
|
||||
"鯵", "鰐", "漁", "魚"
|
||||
],
|
||||
|
||||
plants: [
|
||||
"松", "杉", "桜", "梅", "栗",
|
||||
"竹", "楠", "藤", "萩", "葦",
|
||||
"菅", "榎", "椿", "桐", "柳",
|
||||
"橘", "柏", "槙", "柿", "桃",
|
||||
"梨", "桑", "麻", "芦", "茅",
|
||||
"粟", "稲", "麦", "稗", "米", "飯", "糠",
|
||||
"榊", "楢", "檜", "椎", "柚", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑"
|
||||
],
|
||||
|
||||
postfixes: [
|
||||
"田", "川", "山", "岡", "森", "林",
|
||||
"島",
|
||||
"江", "瀬", "井", "戸", "口",
|
||||
"辺", "里", "郷", "村", "町",
|
||||
"宿", "庄", "台", "坂", "橋",
|
||||
"本", "内", "窪", "平", "塚",
|
||||
"畑", "牧", "前", "見", "中", "羽", "生", "塚", "部",
|
||||
],
|
||||
|
||||
archaicPrefixes: [
|
||||
"阿", "吾", "安", "有", "衣", "伊", "以", "井", "宇", "羽", "江", "恵", "尾", "小", "於",
|
||||
"可", "加", "賀", "香", "鹿", "賀", "嘉", "喜", "紀", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子", "巨", "己",
|
||||
"佐", "紗", "左", "志", "師", "須", "瀬", "曽", "蘇",
|
||||
"多", "太", "知", "津", "土",
|
||||
"那", "奈", "名", "仁", "尼", "根", "乃", "能",
|
||||
"波", "氷", "比", "肥", "布", "夫", "戸", "保", "穂",
|
||||
"間", "磨", "摩", "見", "牟", "武", "目", "女", "毛", "裳",
|
||||
"弥", "矢", "夜", "耶", "由", "与",
|
||||
"和", "輪",
|
||||
"出", "播", "但",
|
||||
"因", "伯", "筑", "肥", "豊",
|
||||
"日", "紀", "志", "尾", "駿",
|
||||
"甲", "信", "越", "備", "能",
|
||||
"薩", "隠", "美", "三", "若",
|
||||
"遠", "近", "能", "加", "賀", "度",
|
||||
"越", "淡", "壱", "衣", "古", "彦", "多", "志", "布", "治"
|
||||
],
|
||||
|
||||
archaicSuffixes: [
|
||||
"井", "羽", "江", "恵", "尾", "於",
|
||||
"賀", "鹿", "喜", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子",
|
||||
"佐", "紗", "左", "志", "路", "師", "須", "瀬", "曽", "蘇",
|
||||
"多", "太", "知", "津", "豆", "土",
|
||||
"那", "奈", "名", "仁", "尼", "根", "乃", "能",
|
||||
"波", "布", "夫", "戸", "保", "穂",
|
||||
"間", "磨", "摩", "馬", "見", "牟", "武", "目", "女", "毛", "裳", "茂",
|
||||
"弥", "矢", "夜", "耶", "由", "与", "予",
|
||||
"良", "利", "礼", "呂",
|
||||
"和", "輪",
|
||||
"陀", "芸", "雲",
|
||||
"幡", "耆", "摩",
|
||||
"張", "江", "河", "斐", "濃",
|
||||
"岐", "防", "門", "隅", "向",
|
||||
"居", "前", "中", "後", "波",
|
||||
"勢", "渡", "城", "紫", "野", "度",
|
||||
"津", "島", "信", "登", "賀", "志",
|
||||
"良", "美", "智", "茂", "代", "古", "麻", "彦", "比古", "子"
|
||||
],
|
||||
|
||||
settlementWords: [
|
||||
"里", "郷", "村", "町", "宿", "邑", "垣", "坪",
|
||||
"庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂",
|
||||
"城", "館", "屋", "家", "所",
|
||||
"市", "場", "府", "関", "地蔵", "辻", "角", "堰",
|
||||
"ヶ沢", "ヶ谷", "ヶ浜", "ヶ崎", "ヶ島", "ヶ浦", "ヶ津", "ヶ丘",
|
||||
]
|
||||
};
|
||||
|
||||
export const NAME_PROBABILITIES = {
|
||||
|
|
@ -309,9 +407,13 @@ export function createNameDebug(probabilities = NAME_PROBABILITIES, pools = NAME
|
|||
generatedNamesUsed: 0,
|
||||
invalidNamesRejected: 0,
|
||||
oneCharacterNamesPrevented: 0,
|
||||
rejectedOneCharacterNames: 0,
|
||||
duplicateRetries: 0,
|
||||
fallbackAttempts: 0,
|
||||
legacyFallbackUsed: 0,
|
||||
oneKanjiAppendFallbackUsed: 0,
|
||||
derivedNameCount: 0,
|
||||
maxDerivedPerBase: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -417,6 +519,7 @@ function tryCustomName(seed, id, usedNames, debug) {
|
|||
const validation = validateGeneratedName(customName, { allowAsciiDiagnostic: true });
|
||||
if (!validation.valid) {
|
||||
if (validation.reason === "oneCharacter") debug.oneCharacterNamesPrevented++;
|
||||
if (validation.reason === "oneCharacter") debug.rejectedOneCharacterNames++;
|
||||
else debug.invalidNamesRejected++;
|
||||
return null;
|
||||
}
|
||||
|
|
@ -449,7 +552,10 @@ export function generateEntityName(seed, id, entity, fields, usedNames = null, d
|
|||
continue;
|
||||
}
|
||||
if (result.invalidReason) {
|
||||
if (result.invalidReason === "oneCharacter") debug.oneCharacterNamesPrevented++;
|
||||
if (result.invalidReason === "oneCharacter") {
|
||||
debug.oneCharacterNamesPrevented++;
|
||||
debug.rejectedOneCharacterNames++;
|
||||
}
|
||||
else debug.invalidNamesRejected++;
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,141 +0,0 @@
|
|||
import { MAP_H, MAP_W, indexOf } from "./grid.js";
|
||||
|
||||
export function terrainBoundaryTargetForMetrics(map, i) {
|
||||
const lu = map.landuse[i];
|
||||
const urbanPenalty = Math.min(1, (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) + map.populationDensity[i] * 1.35);
|
||||
const majorRiver = Math.min(1, Math.max(map.river[i] - 0.32, 0) * 1.9 + Math.max(map.flowAccum[i] - 0.38, 0) * 0.75);
|
||||
const minorStream = Math.min(1, map.river[i] * 0.34 + map.flowAccum[i] * 0.18);
|
||||
const ridgeDivide = Math.min(1, map.ridgeField[i] * 1.55 + Math.max(0, map.elevation[i] - 0.54) * map.ridgeField[i] * 0.95);
|
||||
const slopeBreak = Math.min(1, map.slope[i] * 0.58 + Math.max(0, map.slope[i] - 0.32) * 0.68);
|
||||
const highGround = Math.max(0, map.elevation[i] - 0.56) * 0.22;
|
||||
const valleyFloorPenalty = map.valleyField[i] * (majorRiver > 0.34 ? -0.10 : -0.62);
|
||||
return Math.max(0, Math.min(1, ridgeDivide + majorRiver * 0.88 + minorStream * 0.22 + slopeBreak + highGround + valleyFloorPenalty - urbanPenalty * 0.72));
|
||||
}
|
||||
|
||||
export function adminBoundaryMetrics(map) {
|
||||
let borderEdges = 0;
|
||||
let targetSum = 0;
|
||||
let denseUrbanEdges = 0;
|
||||
let rightAngleRuns = 0;
|
||||
let voronoiLikeEdges = 0;
|
||||
let lowScoreFlatEdges = 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 (!map.prefectureMask[i] || map.sea[i] || map.adminId[i] < 0) continue;
|
||||
for (const [dx, dy] of [[1, 0], [0, 1]]) {
|
||||
const ni = indexOf(x + dx, y + dy);
|
||||
if (!map.prefectureMask[ni] || map.sea[ni] || map.adminId[ni] < 0 || map.adminId[ni] === map.adminId[i]) continue;
|
||||
borderEdges++;
|
||||
const edgeTarget = (terrainBoundaryTargetForMetrics(map, i) + terrainBoundaryTargetForMetrics(map, ni)) * 0.5;
|
||||
targetSum += edgeTarget;
|
||||
const urban = Math.max(map.populationDensity[i], map.populationDensity[ni]) > 0.58 || [2, 3, 4, 7, 8].includes(map.landuse[i]) || [2, 3, 4, 7, 8].includes(map.landuse[ni]);
|
||||
if (urban) denseUrbanEdges++;
|
||||
const ca = map.adminCenters[map.adminId[i]];
|
||||
const cb = map.adminCenters[map.adminId[ni]];
|
||||
if (ca && cb) {
|
||||
const mx = x + dx * 0.5;
|
||||
const my = y + dy * 0.5;
|
||||
const dA = Math.hypot(mx - ca.x, my - ca.y);
|
||||
const dB = Math.hypot(mx - cb.x, my - cb.y);
|
||||
if (Math.abs(dA - dB) < 4.2 && edgeTarget < 0.40) voronoiLikeEdges++;
|
||||
}
|
||||
if (edgeTarget < 0.16 && Math.max(map.slope[i], map.slope[ni]) < 0.24 && Math.max(map.ridgeField[i], map.ridgeField[ni]) < 0.28 && Math.max(map.river[i], map.river[ni]) < 0.26) {
|
||||
lowScoreFlatEdges++;
|
||||
}
|
||||
const sideA = indexOf(x + (dy ? 1 : 0), y + (dx ? 1 : 0));
|
||||
const sideB = indexOf(x - (dy ? 1 : 0), y - (dx ? 1 : 0));
|
||||
if (map.prefectureMask[sideA] && map.prefectureMask[sideB] && !map.sea[sideA] && !map.sea[sideB]) {
|
||||
const turnA = map.adminId[sideA] !== map.adminId[i] && map.adminId[sideA] !== map.adminId[ni];
|
||||
const turnB = map.adminId[sideB] !== map.adminId[i] && map.adminId[sideB] !== map.adminId[ni];
|
||||
if ((turnA || turnB) && terrainBoundaryTargetForMetrics(map, i) < 0.46) rightAngleRuns++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ids = new Set([...map.adminId].filter((id, i) => id >= 0 && map.prefectureMask[i] && !map.sea[i]));
|
||||
const areaById = new Map();
|
||||
for (let i = 0; i < map.adminId.length; i++) {
|
||||
if (map.prefectureMask[i] && !map.sea[i] && map.adminId[i] >= 0) areaById.set(map.adminId[i], (areaById.get(map.adminId[i]) || 0) + 1);
|
||||
}
|
||||
const areas = [...areaById.values()].sort((a, b) => a - b);
|
||||
const medianArea = areas.length ? areas[Math.floor(areas.length / 2)] : 1;
|
||||
const maxArea = areas.length ? areas[areas.length - 1] : 1;
|
||||
let disconnectedMunicipalities = 0;
|
||||
let maxComponents = 0;
|
||||
const seen = new Uint8Array(MAP_W * MAP_H);
|
||||
for (const id of ids) {
|
||||
let comps = 0;
|
||||
seen.fill(0);
|
||||
for (let i = 0; i < map.adminId.length; i++) {
|
||||
if (seen[i] || map.adminId[i] !== id || !map.prefectureMask[i] || map.sea[i]) continue;
|
||||
comps++;
|
||||
const queue = [i];
|
||||
seen[i] = 1;
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
const x = cur % MAP_W;
|
||||
const y = Math.floor(cur / MAP_W);
|
||||
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (seen[ni] || map.adminId[ni] !== id || !map.prefectureMask[ni] || map.sea[ni]) continue;
|
||||
seen[ni] = 1;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (comps > 1) disconnectedMunicipalities++;
|
||||
maxComponents = Math.max(maxComponents, comps);
|
||||
}
|
||||
|
||||
const centerValidCount = map.adminCenters.filter((center) => {
|
||||
const i = indexOf(center.x, center.y);
|
||||
return map.prefectureMask[i] && !map.sea[i] && map.adminId[i] >= 0;
|
||||
}).length;
|
||||
|
||||
return {
|
||||
borderEdges,
|
||||
avgTarget: borderEdges ? targetSum / borderEdges : 0,
|
||||
denseUrbanRate: borderEdges ? denseUrbanEdges / borderEdges : 0,
|
||||
rightAngleRate: borderEdges ? rightAngleRuns / borderEdges : 0,
|
||||
voronoiLikeRate: borderEdges ? voronoiLikeEdges / borderEdges : 0,
|
||||
lowScoreFlatRate: borderEdges ? lowScoreFlatEdges / borderEdges : 0,
|
||||
areaDiversity: maxArea / Math.max(1, medianArea),
|
||||
municipalityCount: ids.size,
|
||||
disconnectedMunicipalities,
|
||||
maxComponents,
|
||||
centerValidRatio: map.adminCenters.length ? centerValidCount / map.adminCenters.length : 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function majorCityCoreIntegrity(map) {
|
||||
const majorCities = map.modernCities.filter((city) => (city.population || 0) >= 180000);
|
||||
if (majorCities.length === 0) return 1;
|
||||
let sum = 0;
|
||||
let checked = 0;
|
||||
for (const city of majorCities) {
|
||||
const counts = new Map();
|
||||
const r = Math.ceil(Math.max(3, city.coreRadius || 4));
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const x = city.x + dx;
|
||||
const y = city.y + dy;
|
||||
if (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H || Math.hypot(dx, dy) > r) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (!map.prefectureMask[i] || map.sea[i]) continue;
|
||||
if (map.landuse[i] !== 3 && map.populationDensity[i] < 0.38) continue;
|
||||
const id = map.adminId[i];
|
||||
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
|
||||
}
|
||||
}
|
||||
const total = [...counts.values()].reduce((a, b) => a + b, 0);
|
||||
if (total === 0) continue;
|
||||
sum += Math.max(...counts.values()) / total;
|
||||
checked++;
|
||||
}
|
||||
return checked ? sum / checked : 1;
|
||||
}
|
||||
51
random.js
51
random.js
|
|
@ -1,51 +0,0 @@
|
|||
import { clamp } from "./grid.js";
|
||||
|
||||
export function hash2(x, y, seed) {
|
||||
let h = Math.imul((x | 0) ^ (seed | 0), 374761393) + Math.imul((y | 0) ^ ((seed >>> 1) | 0), 668265263);
|
||||
h = (h ^ (h >>> 13)) >>> 0;
|
||||
h = Math.imul(h, 1274126177) >>> 0;
|
||||
return ((h ^ (h >>> 16)) >>> 0) / 4294967295;
|
||||
}
|
||||
|
||||
export function rand(seed, n) {
|
||||
return hash2(n * 7919 + 17, n * 104729 + 31, seed);
|
||||
}
|
||||
|
||||
export function smoothstep(t) {
|
||||
t = clamp(t);
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
export function lerp(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
export function valueNoise(x, y, seed, scale) {
|
||||
const sx = x / scale;
|
||||
const sy = y / scale;
|
||||
const x0 = Math.floor(sx);
|
||||
const y0 = Math.floor(sy);
|
||||
const tx = smoothstep(sx - x0);
|
||||
const ty = smoothstep(sy - y0);
|
||||
|
||||
const a = hash2(x0, y0, seed);
|
||||
const b = hash2(x0 + 1, y0, seed);
|
||||
const c = hash2(x0, y0 + 1, seed);
|
||||
const d = hash2(x0 + 1, y0 + 1, seed);
|
||||
|
||||
return lerp(lerp(a, b, tx), lerp(c, d, tx), ty);
|
||||
}
|
||||
|
||||
export function fbm(x, y, seed) {
|
||||
let amp = 1;
|
||||
let scale = 54;
|
||||
let sum = 0;
|
||||
let norm = 0;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
sum += valueNoise(x, y, seed + i * 101, scale) * amp;
|
||||
norm += amp;
|
||||
amp *= 0.5;
|
||||
scale *= 0.5;
|
||||
}
|
||||
return sum / norm;
|
||||
}
|
||||
772
renderer.js
772
renderer.js
|
|
@ -1,5 +1,258 @@
|
|||
import { MAP_W, MAP_H, CELL_SIZE, indexOf } from "./mapGenerator.js";
|
||||
import { clamp } from "./grid.js";
|
||||
import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf } from "./mapUtils.js";
|
||||
|
||||
|
||||
const segmentVectorCache = new WeakMap();
|
||||
const pathVectorCache = new WeakMap();
|
||||
const coastlineCache = new WeakMap();
|
||||
|
||||
function pointKey(p) {
|
||||
return `${p[0]},${p[1]}`;
|
||||
}
|
||||
|
||||
function parsePointKey(key) {
|
||||
return key.split(",").map(Number);
|
||||
}
|
||||
|
||||
function samePoint(a, b, eps = 1e-6) {
|
||||
return Math.abs(a[0] - b[0]) <= eps && Math.abs(a[1] - b[1]) <= eps;
|
||||
}
|
||||
|
||||
function perpendicularDistance(p, a, b) {
|
||||
const dx = b[0] - a[0];
|
||||
const dy = b[1] - a[1];
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (len2 <= 1e-9) return Math.hypot(p[0] - a[0], p[1] - a[1]);
|
||||
const t = clamp(((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2, 0, 1);
|
||||
const x = a[0] + dx * t;
|
||||
const y = a[1] + dy * t;
|
||||
return Math.hypot(p[0] - x, p[1] - y);
|
||||
}
|
||||
|
||||
function simplifyRdp(points, tolerance = 0.05) {
|
||||
if (!points || points.length <= 2) return points || [];
|
||||
let bestIndex = -1;
|
||||
let bestDistance = -1;
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const d = perpendicularDistance(points[i], first, last);
|
||||
if (d > bestDistance) {
|
||||
bestDistance = d;
|
||||
bestIndex = i;
|
||||
}
|
||||
}
|
||||
if (bestDistance <= tolerance) return [first, last];
|
||||
const left = simplifyRdp(points.slice(0, bestIndex + 1), tolerance);
|
||||
const right = simplifyRdp(points.slice(bestIndex), tolerance);
|
||||
return left.slice(0, -1).concat(right);
|
||||
}
|
||||
|
||||
function removeCollinear(points) {
|
||||
if (!points || points.length <= 2) return points || [];
|
||||
const closed = samePoint(points[0], points[points.length - 1]);
|
||||
const core = closed ? points.slice(0, -1) : points.slice();
|
||||
if (core.length <= 2) return points;
|
||||
const out = [];
|
||||
const count = core.length;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const prev = core[(i - 1 + count) % count];
|
||||
const cur = core[i];
|
||||
const next = core[(i + 1) % count];
|
||||
if (!closed && (i === 0 || i === count - 1)) {
|
||||
out.push(cur);
|
||||
continue;
|
||||
}
|
||||
const ax = cur[0] - prev[0];
|
||||
const ay = cur[1] - prev[1];
|
||||
const bx = next[0] - cur[0];
|
||||
const by = next[1] - cur[1];
|
||||
if (Math.abs(ax * by - ay * bx) > 1e-9) out.push(cur);
|
||||
}
|
||||
if (closed && out.length) out.push(out[0]);
|
||||
return out;
|
||||
}
|
||||
|
||||
function chaikin(points, iterations = 1, closed = false) {
|
||||
if (!points || points.length < 3 || iterations <= 0) return points || [];
|
||||
let result = points.slice();
|
||||
for (let iter = 0; iter < iterations; iter++) {
|
||||
const src = closed && samePoint(result[0], result[result.length - 1]) ? result.slice(0, -1) : result;
|
||||
if (src.length < 3) break;
|
||||
const next = [];
|
||||
if (!closed) next.push(src[0]);
|
||||
const limit = closed ? src.length : src.length - 1;
|
||||
for (let i = 0; i < limit; i++) {
|
||||
const a = src[i];
|
||||
const b = src[(i + 1) % src.length];
|
||||
next.push([a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25]);
|
||||
next.push([a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]);
|
||||
}
|
||||
if (!closed) next.push(src[src.length - 1]);
|
||||
if (closed && next.length) next.push(next[0]);
|
||||
result = next;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function traceSegmentChain(edges, adjacency, used, startKey, firstEdgeIndex) {
|
||||
const line = [parsePointKey(startKey)];
|
||||
let currentKey = startKey;
|
||||
let nextEdgeIndex = firstEdgeIndex;
|
||||
|
||||
while (nextEdgeIndex !== undefined && nextEdgeIndex !== null && !used[nextEdgeIndex]) {
|
||||
used[nextEdgeIndex] = 1;
|
||||
const edge = edges[nextEdgeIndex];
|
||||
const toKey = edge.aKey === currentKey ? edge.bKey : edge.aKey;
|
||||
line.push(parsePointKey(toKey));
|
||||
currentKey = toKey;
|
||||
|
||||
if (currentKey === startKey) break;
|
||||
const degree = adjacency.get(currentKey)?.length || 0;
|
||||
if (degree !== 2) break;
|
||||
nextEdgeIndex = (adjacency.get(currentKey) || []).find((idx) => !used[idx]);
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
function segmentChains(segments) {
|
||||
if (!segments?.length) return [];
|
||||
const edges = [];
|
||||
const adjacency = new Map();
|
||||
|
||||
for (const seg of segments) {
|
||||
const aKey = pointKey(seg[0]);
|
||||
const bKey = pointKey(seg[1]);
|
||||
if (aKey === bKey) continue;
|
||||
const edgeIndex = edges.length;
|
||||
edges.push({ aKey, bKey });
|
||||
if (!adjacency.has(aKey)) adjacency.set(aKey, []);
|
||||
if (!adjacency.has(bKey)) adjacency.set(bKey, []);
|
||||
adjacency.get(aKey).push(edgeIndex);
|
||||
adjacency.get(bKey).push(edgeIndex);
|
||||
}
|
||||
|
||||
const used = new Uint8Array(edges.length);
|
||||
const chains = [];
|
||||
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
if (used[i]) continue;
|
||||
const edge = edges[i];
|
||||
const aDegree = adjacency.get(edge.aKey)?.length || 0;
|
||||
const bDegree = adjacency.get(edge.bKey)?.length || 0;
|
||||
if (aDegree === 2 && bDegree === 2) continue;
|
||||
const startKey = aDegree !== 2 ? edge.aKey : edge.bKey;
|
||||
chains.push(traceSegmentChain(edges, adjacency, used, startKey, i));
|
||||
}
|
||||
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
if (used[i]) continue;
|
||||
chains.push(traceSegmentChain(edges, adjacency, used, edges[i].aKey, i));
|
||||
}
|
||||
|
||||
return chains.filter((line) => line.length >= 2);
|
||||
}
|
||||
|
||||
function vectorizeSegments(segments, { iterations = 2, tolerance = 0.08 } = {}) {
|
||||
if (!segments?.length) return [];
|
||||
const cacheKey = `${iterations}:${tolerance}`;
|
||||
let cachedByOption = segmentVectorCache.get(segments);
|
||||
if (!cachedByOption) {
|
||||
cachedByOption = new Map();
|
||||
segmentVectorCache.set(segments, cachedByOption);
|
||||
}
|
||||
if (cachedByOption.has(cacheKey)) return cachedByOption.get(cacheKey);
|
||||
|
||||
const polylines = segmentChains(segments).map((line) => {
|
||||
const cleaned = removeCollinear(line);
|
||||
const closed = cleaned.length > 2 && samePoint(cleaned[0], cleaned[cleaned.length - 1]);
|
||||
const smoothed = chaikin(cleaned, iterations, closed);
|
||||
if (closed) return smoothed;
|
||||
return simplifyRdp(smoothed, tolerance);
|
||||
}).filter((line) => line.length >= 2);
|
||||
|
||||
cachedByOption.set(cacheKey, polylines);
|
||||
return polylines;
|
||||
}
|
||||
|
||||
function getCoastlineSegments(map) {
|
||||
if (!map?.sea) return [];
|
||||
const cached = coastlineCache.get(map.sea);
|
||||
if (cached) return cached;
|
||||
|
||||
const segments = [];
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
const a = Boolean(map.sea[i]);
|
||||
if (x + 1 < MAP_W) {
|
||||
const b = Boolean(map.sea[indexOf(x + 1, y)]);
|
||||
if (a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
|
||||
}
|
||||
if (y + 1 < MAP_H) {
|
||||
const b = Boolean(map.sea[indexOf(x, y + 1)]);
|
||||
if (a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
coastlineCache.set(map.sea, segments);
|
||||
return segments;
|
||||
}
|
||||
|
||||
function vectorPath(path) {
|
||||
if (!path || path.length < 2) return [];
|
||||
const cached = pathVectorCache.get(path);
|
||||
if (cached) return cached;
|
||||
|
||||
const points = path.map(([x, y]) => [x * CELL_SIZE + CELL_SIZE / 2, y * CELL_SIZE + CELL_SIZE / 2]);
|
||||
const simplified = simplifyRdp(points, CELL_SIZE * 0.34);
|
||||
const smoothed = chaikin(simplified, path.length > 6 ? 1 : 0, false);
|
||||
pathVectorCache.set(path, smoothed);
|
||||
return smoothed;
|
||||
}
|
||||
|
||||
function drawPolylinePoints(ctx, points) {
|
||||
if (!points || points.length < 2) return;
|
||||
ctx.moveTo(points[0][0], points[0][1]);
|
||||
for (let k = 1; k < points.length; k++) ctx.lineTo(points[k][0], points[k][1]);
|
||||
}
|
||||
|
||||
function drawVectorSegments(ctx, segments, color, width, dashed = false, vectorOptions = {}) {
|
||||
const { offsetX = 0, offsetY = 0, ...shapeOptions } = vectorOptions || {};
|
||||
const polylines = vectorizeSegments(segments, shapeOptions);
|
||||
if (!polylines.length) return;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = width;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
if (dashed) ctx.setLineDash([6, 5]);
|
||||
|
||||
for (const line of polylines) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo((line[0][0] + offsetX) * CELL_SIZE, (line[0][1] + offsetY) * CELL_SIZE);
|
||||
for (let k = 1; k < line.length; k++) ctx.lineTo((line[k][0] + offsetX) * CELL_SIZE, (line[k][1] + offsetY) * CELL_SIZE);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function sampleCellIndex(fx, fy) {
|
||||
const x = Math.max(0, Math.min(MAP_W - 1, Math.floor(fx)));
|
||||
const y = Math.max(0, Math.min(MAP_H - 1, Math.floor(fy)));
|
||||
return indexOf(x, y);
|
||||
}
|
||||
|
||||
function isWaterSample(map, fx, fy) {
|
||||
// The generated terrain arrays are cell-centered, while pixels are drawn across
|
||||
// each cell. Water/land classification must therefore follow the discrete sea
|
||||
// mask, not the interpolated elevation value. Interpolating elevation near a
|
||||
// coast makes the right/bottom side of land cells inherit sea values and leaves
|
||||
// visible unpainted strips inside the smoothed coastline.
|
||||
return Boolean(map.sea[sampleCellIndex(fx, fy)]);
|
||||
}
|
||||
|
||||
|
||||
function distToNearest(points, x, y, fallback = 999) {
|
||||
let best = fallback;
|
||||
|
|
@ -10,19 +263,21 @@ function distToNearest(points, x, y, fallback = 999) {
|
|||
function blendOutside(color, isInside) {
|
||||
if (isInside) return color;
|
||||
return [
|
||||
Math.round(color[0] * 0.55 + 112),
|
||||
Math.round(color[1] * 0.55 + 112),
|
||||
Math.round(color[2] * 0.55 + 112),
|
||||
Math.round(color[0] * 0.8 + 50),
|
||||
Math.round(color[1] * 0.8 + 50),
|
||||
Math.round(color[2] * 0.8 + 50),
|
||||
];
|
||||
}
|
||||
|
||||
function fieldSample(field, fx, fy) {
|
||||
const x0 = Math.max(0, Math.min(MAP_W - 1, Math.floor(fx)));
|
||||
const y0 = Math.max(0, Math.min(MAP_H - 1, Math.floor(fy)));
|
||||
const sx = Math.max(0, Math.min(MAP_W - 1, fx));
|
||||
const sy = Math.max(0, Math.min(MAP_H - 1, fy));
|
||||
const x0 = Math.floor(sx);
|
||||
const y0 = Math.floor(sy);
|
||||
const x1 = Math.max(0, Math.min(MAP_W - 1, x0 + 1));
|
||||
const y1 = Math.max(0, Math.min(MAP_H - 1, y0 + 1));
|
||||
const tx = fx - x0;
|
||||
const ty = fy - y0;
|
||||
const tx = sx - x0;
|
||||
const ty = sy - y0;
|
||||
|
||||
const a = field[indexOf(x0, y0)];
|
||||
const b = field[indexOf(x1, y0)];
|
||||
|
|
@ -33,53 +288,42 @@ function fieldSample(field, fx, fy) {
|
|||
}
|
||||
|
||||
function terrainColorContinuous(map, fx, fy, mode) {
|
||||
const i = indexOf(Math.max(0, Math.min(MAP_W - 1, Math.floor(fx))), Math.max(0, Math.min(MAP_H - 1, Math.floor(fy))));
|
||||
const i = sampleCellIndex(fx, fy);
|
||||
const isInside = Boolean(map.prefectureMask[i]);
|
||||
|
||||
let color;
|
||||
|
||||
if (map.sea[i]) {
|
||||
// Google Map風の海の色(明るい青)
|
||||
const depth = clamp((0.35 - fieldSample(map.elevation, fx, fy)) * 2.4);
|
||||
color = [Math.round(170 + depth * 10), Math.round(211 + depth * 15), Math.round(223 + depth * 20)];
|
||||
if (isWaterSample(map, fx, fy)) {
|
||||
const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map.elevation, fx, fy)) * 2.4);
|
||||
color = [Math.round(170 + depth * 5), Math.round(218 + depth * 10), Math.round(255 - depth * 5)];
|
||||
} else if (mode === "suitability") {
|
||||
const a = fieldSample(map.agriculture, fx, fy);
|
||||
const p = fieldSample(map.plain, fx, fy);
|
||||
const f = fieldSample(map.floodplain, fx, fy);
|
||||
color = [
|
||||
Math.round(230 - p * 25 + f * 15),
|
||||
Math.round(235 + a * 15),
|
||||
Math.round(210 - a * 25 + p * 20),
|
||||
Math.round(240 - p * 15 + f * 10),
|
||||
Math.round(242 + a * 10),
|
||||
Math.round(235 - a * 15 + p * 10),
|
||||
];
|
||||
} else if (mode === "development") {
|
||||
const x = Math.floor(fx);
|
||||
const y = Math.floor(fy);
|
||||
const baseIndex = indexOf(Math.max(0, Math.min(MAP_W - 1, x)), Math.max(0, Math.min(MAP_H - 1, y)));
|
||||
const dCity = distToNearest(map.modernCities, fx, fy);
|
||||
const dInd = distToNearest(map.industrialZones, fx, fy);
|
||||
const dLog = distToNearest(map.logisticsParks, fx, fy);
|
||||
const dNew = distToNearest(map.newTowns, fx, fy);
|
||||
const urban = clamp(1 - dCity / 25);
|
||||
const density = map.populationDensity ? fieldSample(map.populationDensity, fx, fy) : urban;
|
||||
const industrial = clamp(1 - dInd / 10);
|
||||
const logistics = clamp(1 - dLog / 9);
|
||||
const newTown = clamp(1 - dNew / 9);
|
||||
const base = 214 + map.plain[baseIndex] * 22;
|
||||
const base = 235;
|
||||
color = [
|
||||
Math.round(base + density * 30 + urban * 8 + industrial * 14),
|
||||
Math.round(base + density * 10 + logistics * 15 + newTown * 12),
|
||||
Math.round(208 + map.agriculture[baseIndex] * 22 + density * 18 + newTown * 22),
|
||||
Math.round(base + density * 20),
|
||||
Math.round(base + density * 5),
|
||||
Math.round(230 + density * 10),
|
||||
];
|
||||
} else {
|
||||
// 起伏の大きさが読めるよう、標高段彩をやや強める
|
||||
// 地形色を少し濃く(暗く)調整
|
||||
const e = fieldSample(map.elevation, fx, fy);
|
||||
const m = fieldSample(map.moisture, fx, fy);
|
||||
if (e > 0.82) color = [178, 170, 160];
|
||||
else if (e > 0.68) color = [198, 188, 164];
|
||||
else if (e > 0.52) color = [205, 222 + m * 5, 184];
|
||||
else if (e > 0.34) color = [224, 238 + m * 6, 206];
|
||||
else if (e > 0.24) color = [236, 245 + m * 5, 220];
|
||||
else color = [218, 232 + m * 6, 206];
|
||||
if (e > 0.82) color = [210, 205, 195];
|
||||
else if (e > 0.68) color = [218, 215, 205];
|
||||
else if (e > 0.52) color = [220, 225, 210];
|
||||
else if (e > 0.34) color = [225, 230, 215];
|
||||
else if (e > 0.24) color = [230, 235, 220];
|
||||
else color = [238, 242, 228];
|
||||
}
|
||||
|
||||
return blendOutside(color, isInside);
|
||||
|
|
@ -90,39 +334,31 @@ function discreteColor(map, x, y, mode) {
|
|||
let color;
|
||||
|
||||
if (map.sea[i]) {
|
||||
// Google Map like styled
|
||||
color = [170, 218, 255];
|
||||
} else if (mode === "landuse") {
|
||||
const colors = {
|
||||
0: [230, 242, 220], // farmland
|
||||
1: [235, 245, 225], // plain
|
||||
2: [235, 230, 220], // old city
|
||||
3: [224, 202, 190], // CBD
|
||||
4: [245, 240, 230], // suburb
|
||||
5: [220, 220, 225], // industrial area
|
||||
6: [225, 235, 225], // logistics area
|
||||
7: [238, 242, 248], // new town
|
||||
8: [248, 242, 230], // coastal development
|
||||
9: [225, 238, 220], // others
|
||||
0: [242, 248, 238],
|
||||
1: [248, 250, 245],
|
||||
2: [240, 238, 232],
|
||||
3: [245, 230, 220],
|
||||
4: [250, 248, 245],
|
||||
5: [235, 235, 240],
|
||||
6: [240, 245, 240],
|
||||
7: [245, 248, 252],
|
||||
8: [250, 248, 240],
|
||||
9: [240, 245, 238],
|
||||
};
|
||||
color = colors[map.landuse[i]] || colors[0];
|
||||
} else if (mode === "admin") {
|
||||
const palette = [
|
||||
[245, 235, 230],
|
||||
[235, 245, 235],
|
||||
[240, 240, 250],
|
||||
[250, 245, 230],
|
||||
[245, 240, 248],
|
||||
[230, 245, 245],
|
||||
[250, 240, 240],
|
||||
[240, 250, 235],
|
||||
[250, 245, 242], [245, 250, 245], [245, 245, 252],
|
||||
[252, 250, 242], [250, 245, 250], [242, 250, 250]
|
||||
];
|
||||
const a = map.adminId[i];
|
||||
color = a >= 0 ? palette[a % palette.length] : [220, 225, 220];
|
||||
color = a >= 0 ? palette[a % palette.length] : [240, 242, 240];
|
||||
} else {
|
||||
color = terrainColorContinuous(map, x, y, "terrain");
|
||||
}
|
||||
|
||||
return blendOutside(color, Boolean(map.prefectureMask[i]));
|
||||
}
|
||||
|
||||
|
|
@ -143,17 +379,12 @@ function drawBase(ctx, map, mode, continuousTerrain) {
|
|||
const eR = fieldSample(map.elevation, fx + 0.6, fy);
|
||||
const eU = fieldSample(map.elevation, fx, fy - 0.6);
|
||||
const eD = fieldSample(map.elevation, fx, fy + 0.6);
|
||||
const shade = clamp(0.9 + (eR - eL) * 1.0 + (eD - eU) * 0.65, 0.72, 1.18);
|
||||
const elevation = fieldSample(map.elevation, fx, fy);
|
||||
const contour = Math.abs((elevation * 16) - Math.round(elevation * 16));
|
||||
const majorContour = Math.abs((elevation * 8) - Math.round(elevation * 8));
|
||||
const isLand = !map.sea[indexOf(Math.max(0, Math.min(MAP_W - 1, Math.floor(fx))), Math.max(0, Math.min(MAP_H - 1, Math.floor(fy))))];
|
||||
const contourFactor = isLand && majorContour < 0.022 ? 0.86 : isLand && contour < 0.028 ? 0.94 : 1;
|
||||
const shade = clamp(0.95 + (eR - eL) * 0.6 + (eD - eU) * 0.4, 0.85, 1.08);
|
||||
|
||||
const ii = (py * width + px) * 4;
|
||||
img.data[ii] = Math.round(r * shade * contourFactor);
|
||||
img.data[ii + 1] = Math.round(g * shade * contourFactor);
|
||||
img.data[ii + 2] = Math.round(b * shade * contourFactor);
|
||||
img.data[ii] = Math.round(r * shade);
|
||||
img.data[ii + 1] = Math.round(g * shade);
|
||||
img.data[ii + 2] = Math.round(b * shade);
|
||||
img.data[ii + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
|
@ -173,93 +404,77 @@ function drawBase(ctx, map, mode, continuousTerrain) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(img, 0, 0);
|
||||
}
|
||||
|
||||
function drawPath(ctx, path, color, width, dashed = false) {
|
||||
if (!path || path.length < 2) return;
|
||||
const points = vectorPath(path);
|
||||
if (points.length < 2) return;
|
||||
ctx.save();
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = width;
|
||||
if (dashed) ctx.setLineDash([6, 5]);
|
||||
if (dashed) ctx.setLineDash([8, 6]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(path[0][0] * CELL_SIZE + CELL_SIZE / 2, path[0][1] * CELL_SIZE + CELL_SIZE / 2);
|
||||
for (let k = 1; k < path.length; k++) {
|
||||
ctx.lineTo(path[k][0] * CELL_SIZE + CELL_SIZE / 2, path[k][1] * CELL_SIZE + CELL_SIZE / 2);
|
||||
drawPolylinePoints(ctx, points);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 魚の骨(私鉄記号)スタイルを描画するための専用関数
|
||||
function drawRailway(ctx, path, color, lineWidth, tickLen, spacing) {
|
||||
const points = vectorPath(path);
|
||||
if (points.length < 2) return;
|
||||
ctx.save();
|
||||
ctx.lineCap = "butt";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = color;
|
||||
|
||||
// 中心の実線を描画
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.beginPath();
|
||||
drawPolylinePoints(ctx, points);
|
||||
ctx.stroke();
|
||||
|
||||
// 棘(クロスハッチ)を描画
|
||||
ctx.lineWidth = 1.0;
|
||||
ctx.beginPath();
|
||||
let carry = spacing * 0.5;
|
||||
for (let k = 0; k < points.length - 1; k++) {
|
||||
const [x1, y1] = points[k];
|
||||
const [x2, y2] = points[k + 1];
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist <= 0.001) continue;
|
||||
|
||||
const nx = dx / dist;
|
||||
const ny = dy / dist;
|
||||
const px = -ny * (tickLen / 2);
|
||||
const py = nx * (tickLen / 2);
|
||||
|
||||
let d = carry;
|
||||
while (d < dist) {
|
||||
const cx = x1 + nx * d;
|
||||
const cy = y1 + ny * d;
|
||||
ctx.moveTo(cx + px, cy + py);
|
||||
ctx.lineTo(cx - px, cy - py);
|
||||
d += spacing;
|
||||
}
|
||||
carry = d - dist;
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function segmentPointKey(p) {
|
||||
return `${p[0]},${p[1]}`;
|
||||
}
|
||||
|
||||
function chainSegments(segments) {
|
||||
const unused = segments.map((seg) => [seg[0], seg[1]]);
|
||||
const chains = [];
|
||||
while (unused.length) {
|
||||
const chain = unused.pop();
|
||||
let grew = true;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
const head = segmentPointKey(chain[0]);
|
||||
const tail = segmentPointKey(chain[chain.length - 1]);
|
||||
for (let i = unused.length - 1; i >= 0; i--) {
|
||||
const [a, b] = unused[i];
|
||||
const ak = segmentPointKey(a);
|
||||
const bk = segmentPointKey(b);
|
||||
if (ak === tail) { chain.push(b); unused.splice(i, 1); grew = true; break; }
|
||||
if (bk === tail) { chain.push(a); unused.splice(i, 1); grew = true; break; }
|
||||
if (bk === head) { chain.unshift(a); unused.splice(i, 1); grew = true; break; }
|
||||
if (ak === head) { chain.unshift(b); unused.splice(i, 1); grew = true; break; }
|
||||
}
|
||||
}
|
||||
chains.push(chain);
|
||||
}
|
||||
return chains;
|
||||
}
|
||||
|
||||
function chaikin(points, passes = 1) {
|
||||
let out = points;
|
||||
for (let pass = 0; pass < passes; pass++) {
|
||||
if (out.length < 3) return out;
|
||||
const next = [out[0]];
|
||||
for (let i = 0; i < out.length - 1; i++) {
|
||||
const a = out[i];
|
||||
const b = out[i + 1];
|
||||
next.push([a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25]);
|
||||
next.push([a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]);
|
||||
}
|
||||
next.push(out[out.length - 1]);
|
||||
out = next;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function drawSegments(ctx, segments, color, width, dashed = false, smooth = false) {
|
||||
function drawSegments(ctx, segments, color, width, dashed = false) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = width;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
if (dashed) ctx.setLineDash([4, 4]);
|
||||
|
||||
if (smooth) {
|
||||
for (const chain of chainSegments(segments)) {
|
||||
const points = chaikin(chain, 1);
|
||||
if (points.length < 2) continue;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0][0] * CELL_SIZE, points[0][1] * CELL_SIZE);
|
||||
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0] * CELL_SIZE, points[i][1] * CELL_SIZE);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
if (dashed) ctx.setLineDash([6, 5]);
|
||||
|
||||
for (const seg of segments) {
|
||||
ctx.beginPath();
|
||||
|
|
@ -274,15 +489,14 @@ function drawUrbanAreas(ctx, map, mode) {
|
|||
const visibleModes = ["all", "modern", "development", "landuse", "roads", "admin"];
|
||||
if (!visibleModes.includes(mode)) return;
|
||||
|
||||
// Google Map風の都市部の色
|
||||
const colors = {
|
||||
2: "rgba(235, 230, 220, 0.75)", // 旧市街 - 薄いベージュ
|
||||
3: "rgba(224, 202, 190, 0.9)", // 中心市街地 / CBD - cell fill
|
||||
4: "rgba(245, 242, 235, 0.7)", // 郊外 - 薄いクリーム
|
||||
5: "rgba(220, 220, 228, 0.8)", // 工業地域 - 薄いグレー
|
||||
6: "rgba(225, 235, 228, 0.75)", // 物流 - 薄い緑グレー
|
||||
7: "rgba(238, 242, 250, 0.75)", // ニュータウン - 薄い青白
|
||||
8: "rgba(248, 245, 238, 0.7)", // 沿道 - クリーム
|
||||
2: "rgba(225, 222, 215, 0.6)",
|
||||
3: "rgba(240, 220, 205, 0.85)",
|
||||
4: "rgba(242, 240, 235, 0.5)",
|
||||
5: "rgba(220, 220, 225, 0.6)",
|
||||
6: "rgba(225, 230, 225, 0.5)",
|
||||
7: "rgba(235, 240, 245, 0.6)",
|
||||
8: "rgba(245, 242, 235, 0.5)",
|
||||
};
|
||||
|
||||
ctx.save();
|
||||
|
|
@ -297,38 +511,22 @@ function drawUrbanAreas(ctx, map, mode) {
|
|||
const py = y * CELL_SIZE;
|
||||
ctx.fillStyle = colors[lu];
|
||||
ctx.fillRect(px, py, CELL_SIZE, CELL_SIZE);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
const h = ((x * 92821 + y * 68917 + lu * 131) >>> 0);
|
||||
if (lu === 3 || lu === 2 || lu === 5 || lu === 6) {
|
||||
// 建物の表現を控えめに
|
||||
ctx.fillStyle = lu === 3 ? "rgba(200,200,200,0.25)" : "rgba(210,210,210,0.2)";
|
||||
if (h % 3 !== 0) ctx.fillRect(px + 1, py + 1, 2, 2);
|
||||
if (h % 5 !== 0) ctx.fillRect(px + 3, py + 2, 2, 2);
|
||||
if (h % 7 !== 0) ctx.fillRect(px + 2, py + 4, 2, 1.5);
|
||||
} else if (lu === 4 || lu === 7) {
|
||||
ctx.strokeStyle = lu === 7 ? "rgba(220,220,230,0.15)" : "rgba(200,190,180,0.15)";
|
||||
ctx.lineWidth = 0.8;
|
||||
ctx.beginPath();
|
||||
if (h % 2 === 0) {
|
||||
ctx.moveTo(px + 1, py + 1);
|
||||
ctx.lineTo(px + CELL_SIZE - 1, py + 1);
|
||||
ctx.moveTo(px + 1, py + 4);
|
||||
ctx.lineTo(px + CELL_SIZE - 1, py + 4);
|
||||
} else {
|
||||
ctx.moveTo(px + 1, py + 1);
|
||||
ctx.lineTo(px + 1, py + CELL_SIZE - 1);
|
||||
ctx.moveTo(px + 4, py + 1);
|
||||
ctx.lineTo(px + 4, py + CELL_SIZE - 1);
|
||||
}
|
||||
ctx.stroke();
|
||||
} else if (lu === 8) {
|
||||
ctx.strokeStyle = "rgba(180,170,160,0.2)";
|
||||
ctx.lineWidth = 0.9;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px + 1, py + 3);
|
||||
ctx.lineTo(px + CELL_SIZE - 1, py + 3);
|
||||
ctx.stroke();
|
||||
}
|
||||
function drawDebugCells(ctx, map, field, color) {
|
||||
if (!field) return;
|
||||
ctx.save();
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (!map.prefectureMask[i] || map.sea[i]) continue;
|
||||
const v = clamp(field[i] || 0, 0, 1);
|
||||
if (v <= 0.12) continue;
|
||||
ctx.fillStyle = color(v);
|
||||
ctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
|
|
@ -339,91 +537,41 @@ function dot(ctx, p, radius, fill, stroke = "white") {
|
|||
ctx.arc(p.x * CELL_SIZE + CELL_SIZE / 2, p.y * CELL_SIZE + CELL_SIZE / 2, radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = fill;
|
||||
ctx.fill();
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.lineWidth = 1.2;
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function squareIcon(ctx, p, size, fill, stroke = "white") {
|
||||
const x = p.x * CELL_SIZE + CELL_SIZE / 2;
|
||||
const y = p.y * CELL_SIZE + CELL_SIZE / 2;
|
||||
ctx.save();
|
||||
ctx.fillStyle = fill;
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.rect(x - size / 2, y - size / 2, size, size);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function triangleIcon(ctx, p, size, fill, stroke = "white") {
|
||||
const x = p.x * CELL_SIZE + CELL_SIZE / 2;
|
||||
const y = p.y * CELL_SIZE + CELL_SIZE / 2;
|
||||
ctx.save();
|
||||
ctx.fillStyle = fill;
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y - size / 2);
|
||||
ctx.lineTo(x + size / 2, y + size / 2);
|
||||
ctx.lineTo(x - size / 2, y + size / 2);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function railStationIcon(ctx, p) {
|
||||
squareIcon(ctx, p, 5.2, "rgba(255,255,255,0.96)", "rgba(55,55,55,0.92)");
|
||||
}
|
||||
|
||||
|
||||
function drawHarborWorks(ctx, map) {
|
||||
if (!map.harborWorks) return;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "rgba(95, 120, 150, 0.9)";
|
||||
ctx.lineWidth = 2.2;
|
||||
ctx.lineCap = "round";
|
||||
for (const harbor of map.harborWorks) {
|
||||
for (const seg of harbor.segments || []) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(seg[0][0] * CELL_SIZE + CELL_SIZE / 2, seg[0][1] * CELL_SIZE + CELL_SIZE / 2);
|
||||
ctx.lineTo(seg[1][0] * CELL_SIZE + CELL_SIZE / 2, seg[1][1] * CELL_SIZE + CELL_SIZE / 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function boxesOverlap(a, b, pad = 2) {
|
||||
function boxesOverlap(a, b, pad = 3) {
|
||||
return !(a.x2 + pad < b.x1 || a.x1 - pad > b.x2 || a.y2 + pad < b.y1 || a.y1 - pad > b.y2);
|
||||
}
|
||||
|
||||
function labelWithCollision(ctx, p, occupied) {
|
||||
if (!p.name) return false;
|
||||
ctx.save();
|
||||
ctx.font = "11px ui-sans-serif, system-ui, sans-serif";
|
||||
ctx.font = "600 11.5px ui-sans-serif, system-ui, -apple-system, sans-serif";
|
||||
const baseX = p.x * CELL_SIZE + CELL_SIZE / 2;
|
||||
const baseY = p.y * CELL_SIZE + CELL_SIZE / 2;
|
||||
const textW = ctx.measureText(p.name).width;
|
||||
const textH = 12;
|
||||
const candidates = [
|
||||
[7, -5], [7, 12], [-textW - 7, -5], [-textW - 7, 12],
|
||||
[-textW / 2, -13], [-textW / 2, 20], [12, 2], [-textW - 12, 2],
|
||||
[7, -4], [7, 13], [-textW - 7, -4], [-textW - 7, 13],
|
||||
[-textW / 2, -12], [-textW / 2, 20], [12, 4], [-textW - 12, 4],
|
||||
];
|
||||
|
||||
for (const [ox, oy] of candidates) {
|
||||
const x = baseX + ox;
|
||||
const y = baseY + oy;
|
||||
const box = { x1: x - 2, y1: y - textH, x2: x + textW + 2, y2: y + 3 };
|
||||
const box = { x1: x - 2, y1: y - textH, x2: x + textW + 2, y2: y + 4 };
|
||||
if (box.x1 < 0 || box.y1 < 0 || box.x2 > MAP_W * CELL_SIZE || box.y2 > MAP_H * CELL_SIZE) continue;
|
||||
if (occupied.some((b) => boxesOverlap(box, b))) continue;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.95)";
|
||||
ctx.fillStyle = "rgba(40,40,40,0.95)";
|
||||
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineWidth = 3.5;
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.95)";
|
||||
ctx.strokeText(p.name, x, y);
|
||||
|
||||
ctx.fillStyle = p.isPrefecturalCapital ? "#111111" : "#333333";
|
||||
ctx.fillText(p.name, x, y);
|
||||
occupied.push(box);
|
||||
ctx.restore();
|
||||
|
|
@ -437,7 +585,7 @@ function drawLabels(ctx, points, limit = Infinity) {
|
|||
const occupied = [];
|
||||
const prioritized = points
|
||||
.filter((p) => p?.name)
|
||||
.map((p) => ({ ...p, labelPriority: (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 900 + (p.portClass === "major" ? 170 : p.portClass === "regional" ? 95 : 0) + (p.kind === "Market Town" ? 48 : 0) + (p.kind?.includes("Castle") ? 70 : 0) + (p.kind === "External Gateway" ? 60 : 0) }))
|
||||
.map((p) => ({ ...p, labelPriority: (p.labelPriorityBase || 0) + (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 900 + (p.portClass === "major" ? 170 : p.portClass === "regional" ? 95 : 0) }))
|
||||
.sort((a, b) => b.labelPriority - a.labelPriority);
|
||||
for (const p of prioritized.slice(0, limit)) labelWithCollision(ctx, p, occupied);
|
||||
}
|
||||
|
|
@ -449,102 +597,120 @@ export function drawMap(canvas, map, options) {
|
|||
const mode = options.mode || "all";
|
||||
const showFeatures = options.showFeatures !== false;
|
||||
const showLabels = options.showLabels !== false;
|
||||
const continuousTerrain = true;
|
||||
|
||||
|
||||
const width = MAP_W * CELL_SIZE;
|
||||
const height = MAP_H * CELL_SIZE;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
drawBase(ctx, map, mode, continuousTerrain);
|
||||
// 1. Base Terrain & Urban
|
||||
drawBase(ctx, map, mode, true);
|
||||
drawUrbanAreas(ctx, map, mode);
|
||||
const coastSegments = getCoastlineSegments(map);
|
||||
drawVectorSegments(ctx, coastSegments, "rgba(120, 175, 210, 0.22)", 2.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
drawVectorSegments(ctx, coastSegments, "rgba(248, 250, 242, 0.68)", 1.1, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
|
||||
// Rivers use the same hue family as the sea; hierarchy is expressed by width/opacity.
|
||||
const waterBlue = "rgba(170, 218, 255, 0.95)";
|
||||
// Small streams remain in the data model but are not drawn by default.
|
||||
for (const path of map.tributaryRivers || map.riverPaths || []) drawPath(ctx, path, "rgba(170, 218, 255, 0.78)", 1.35);
|
||||
for (const path of map.mainRivers) drawPath(ctx, path, waterBlue, 3.2);
|
||||
drawHarborWorks(ctx, map);
|
||||
// 2. Rivers
|
||||
const waterBlue = "rgba(160, 205, 240, 1)";
|
||||
for (const path of map.tributaryRivers || map.riverPaths || []) drawPath(ctx, path, "rgba(160, 205, 240, 0.8)", 1.5);
|
||||
for (const path of map.mainRivers) drawPath(ctx, path, waterBlue, 3.5);
|
||||
|
||||
if (map.regionalPrefectureBorders) drawSegments(ctx, map.regionalPrefectureBorders, mode === "all" ? "rgba(95,95,95,0.18)" : "rgba(95,95,95,0.30)", 1.0, false, mode === "all");
|
||||
drawSegments(ctx, map.prefectureBorder, "rgba(30,30,30,0.82)", 2.4, false, true);
|
||||
drawSegments(ctx, map.prefectureBorder, "rgba(255,255,255,0.74)", 1.05, false, true);
|
||||
const showHistory = ["history", "all", "terrain"].includes(mode);
|
||||
const showModern = ["modern", "all", "development", "landuse", "roads", "admin-debug", "borders-debug"].includes(mode);
|
||||
const showRoads = ["roads", "all", "development"].includes(mode);
|
||||
const showMinorRoads = ["roads", "all", "modern", "development"].includes(mode);
|
||||
const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode);
|
||||
|
||||
// 3. Borders
|
||||
if (showAdmin && map.adminBorders) {
|
||||
drawVectorSegments(ctx, map.adminBorders, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
|
||||
drawVectorSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
|
||||
}
|
||||
if (mode === "admin-debug" || mode === "borders-debug") {
|
||||
drawDebugCells(ctx, map, map.naturalBarrierScore, (v) => `rgba(255, 120, 40, ${0.06 + v * 0.18})`);
|
||||
if (map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(60, 110, 170, 0.42)", 0.8, true);
|
||||
for (const p of map.adminDebug?.lowlandAdminSeeds || []) dot(ctx, p, 3.2, "rgba(255,255,255,0.9)", "rgba(40,150,95,0.95)");
|
||||
if (map.regionalPrefectureBorders) drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(70, 55, 95, 0.95)", 2.4, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
}
|
||||
|
||||
drawVectorSegments(ctx, map.prefectureBorder, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
drawVectorSegments(ctx, map.prefectureBorder, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
|
||||
if (!showFeatures) return;
|
||||
|
||||
const showHistory = ["history", "all", "terrain", "suitability"].includes(mode);
|
||||
const showModern = ["modern", "all", "development", "landuse"].includes(mode);
|
||||
const showRoads = ["roads", "all", "development", "landuse"].includes(mode);
|
||||
const showAdmin = ["admin", "all"].includes(mode);
|
||||
|
||||
if (showAdmin) drawSegments(ctx, map.adminBorders, mode === "all" ? "rgba(120,120,120,0.28)" : "rgba(120,120,120,0.65)", mode === "all" ? 0.9 : 1.3);
|
||||
|
||||
// 4. Transport casings. Layer order: local roads, trunk roads, railways, expressways.
|
||||
if (showHistory) {
|
||||
for (const path of map.premodernRoads) drawPath(ctx, path, mode === "all" ? "rgba(150, 120, 90, 0.34)" : "rgba(150, 120, 90, 0.55)", mode === "all" ? 1.15 : 1.45, true);
|
||||
for (const path of map.minorRoads) drawPath(ctx, path, mode === "all" ? "rgba(180, 150, 120, 0.28)" : "rgba(180, 150, 120, 0.62)", mode === "all" ? 0.8 : 1.05);
|
||||
for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(210, 210, 210, 0.7)", 2.5);
|
||||
}
|
||||
|
||||
if (showModern) {
|
||||
// 鉄道 - 濃いグレー
|
||||
for (const path of map.railways) drawPath(ctx, path, "rgba(80, 80, 80, 0.9)", 2.8);
|
||||
for (const path of map.ringRailways || []) drawPath(ctx, path, "rgba(70, 70, 70, 0.82)", 2.1);
|
||||
for (const path of map.branchRailways) drawPath(ctx, path, "rgba(100, 100, 100, 0.82)", 1.9);
|
||||
for (const path of map.externalRailways) drawPath(ctx, path, "rgba(90, 90, 90, 0.9)", 2.4);
|
||||
if (showMinorRoads) {
|
||||
for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(205, 205, 205, 0.60)", 2.35);
|
||||
}
|
||||
|
||||
if (showRoads) {
|
||||
// Google Map風の道路 - 白と黄色とオレンジ
|
||||
for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(252, 210, 90, 0.85)", 2.2);
|
||||
for (const path of map.icAccessRoads || []) drawPath(ctx, path, "rgba(255, 230, 150, 0.82)", 1.45);
|
||||
for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(252, 210, 90, 0.85)", 2.0);
|
||||
for (const path of map.expressways) drawPath(ctx, path, "rgba(245, 140, 60, 0.95)", 4.0);
|
||||
for (const path of map.externalRoads) drawPath(ctx, path, "rgba(252, 210, 90, 0.85)", 2.5);
|
||||
for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(245, 140, 60, 0.95)", 4.2);
|
||||
for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8);
|
||||
for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8);
|
||||
for (const path of map.externalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8);
|
||||
}
|
||||
if (showModern || showRoads) {
|
||||
for (const path of map.railways) drawPath(ctx, path, "rgba(255, 255, 255, 0.78)", 3.6);
|
||||
for (const path of map.branchRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.64)", 2.6);
|
||||
for (const path of map.externalRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.78)", 3.6);
|
||||
}
|
||||
if (showRoads) {
|
||||
for (const path of map.expressways) drawPath(ctx, path, "rgba(105, 125, 105, 0.78)", 4.6);
|
||||
for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(105, 125, 105, 0.78)", 4.6);
|
||||
}
|
||||
|
||||
// 5. Transport fills. Expressways remain above railways; railways sit above ordinary roads.
|
||||
if (showHistory) {
|
||||
for (const p of map.villages) dot(ctx, p, mode === "all" ? 1.35 : 1.9, mode === "all" ? "rgba(120, 100, 80, 0.42)" : "rgba(120, 100, 80, 0.72)");
|
||||
for (const p of map.markets) dot(ctx, p, 4.8, "rgba(200, 130, 80, 0.95)");
|
||||
for (const p of map.ports) {
|
||||
const color = p.portClass === "major" ? "rgba(40, 105, 190, 0.98)" : p.portClass === "regional" ? "rgba(70, 130, 200, 0.95)" : p.portClass === "lake" ? "rgba(80, 155, 180, 0.92)" : "rgba(95, 150, 195, 0.82)";
|
||||
triangleIcon(ctx, p, p.portClass === "major" ? 8.2 : 6.5, color);
|
||||
}
|
||||
for (const p of map.crossings) dot(ctx, p, 3.5, "rgba(255, 240, 180, 0.95)", "rgba(100,90,70,0.8)");
|
||||
for (const p of map.passes) dot(ctx, p, 3.9, "rgba(150, 120, 180, 0.95)");
|
||||
for (const p of map.castles) squareIcon(ctx, p, 7.0, "rgba(180, 70, 70, 0.96)");
|
||||
for (const p of map.castleRuins) dot(ctx, p, 3.2, "rgba(110, 90, 90, 0.9)", "rgba(220,220,220,0.8)");
|
||||
for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(255, 255, 255, 1)", 1.2, false);
|
||||
}
|
||||
if (showMinorRoads) {
|
||||
for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(255, 255, 255, 0.94)", 1.1, false);
|
||||
}
|
||||
if (showRoads) {
|
||||
for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0);
|
||||
for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0);
|
||||
for (const path of map.externalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0);
|
||||
}
|
||||
if (showModern || showRoads) {
|
||||
for (const path of map.railways) drawRailway(ctx, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0);
|
||||
for (const path of map.branchRailways) drawRailway(ctx, path, "rgba(125, 125, 125, 1)", 1.05, 4.0, 6.0);
|
||||
for (const path of map.externalRailways) drawRailway(ctx, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0);
|
||||
}
|
||||
if (showRoads) {
|
||||
for (const path of map.expressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4);
|
||||
for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4);
|
||||
}
|
||||
|
||||
// 6. Icons & Labels
|
||||
if (showModern) {
|
||||
for (const p of map.industrialZones) squareIcon(ctx, p, 6.5, "rgba(140, 140, 150, 0.96)");
|
||||
for (const p of map.stations) railStationIcon(ctx, p);
|
||||
for (const p of map.satelliteCities || []) dot(ctx, p, 4.8, "rgba(215, 95, 145, 0.95)");
|
||||
for (const p of map.newTowns) triangleIcon(ctx, p, 6.5, "rgba(180, 210, 240, 0.95)");
|
||||
for (const p of map.stations) dot(ctx, p, 2.5, "#fff", "#444");
|
||||
for (const p of map.modernCities) {
|
||||
const popRadius = p.population ? Math.min(9.2, 3.4 + Math.sqrt(p.population) / 360) : 4.7;
|
||||
const rankBoost = p.isPrefecturalCapital || p.rank === "Prefectural Capital" ? 1.6 : p.rank === "Regional Center" ? 0.45 : 0;
|
||||
dot(ctx, p, popRadius + rankBoost, "rgba(230, 100, 100, 0.95)");
|
||||
if (p.isPrefecturalCapital) dot(ctx, p, popRadius + 4.2, "rgba(255,255,255,0.0)", "rgba(180,60,60,0.95)");
|
||||
const popRadius = p.population ? Math.min(8.5, 3.5 + Math.sqrt(p.population) / 400) : 4.5;
|
||||
dot(ctx, p, popRadius, "rgba(240, 110, 110, 0.95)", "rgba(255,255,255,0.9)");
|
||||
if (p.isPrefecturalCapital) dot(ctx, p, popRadius + 3.0, "transparent", "rgba(200,80,80,0.9)");
|
||||
}
|
||||
for (const p of map.interchanges || []) dot(ctx, p, 2.7, "rgba(250, 250, 245, 0.95)", "rgba(95, 125, 95, 0.95)");
|
||||
if (mode === "admin-debug" || mode === "borders-debug") {
|
||||
for (const p of map.externalGateways || []) dot(ctx, p, 5.5, "rgba(255,255,255,0.95)", "rgba(40,80,170,0.95)");
|
||||
for (const p of map.transportDebug?.missingRequiredNodes || []) dot(ctx, p, 6.5, "rgba(255,80,80,0.95)", "rgba(80,20,20,0.95)");
|
||||
}
|
||||
}
|
||||
|
||||
if (showRoads) {
|
||||
for (const p of map.logisticsParks) squareIcon(ctx, p, 6.2, "rgba(110, 170, 140, 0.96)");
|
||||
for (const p of map.interchanges) dot(ctx, p, 4.1, "rgba(255, 255, 255, 0.98)", "rgba(220, 90, 60, 0.95)");
|
||||
for (const p of map.externalGateways) dot(ctx, p, 4.4, "rgba(255, 250, 200, 0.98)", "rgba(60,60,60,0.92)");
|
||||
}
|
||||
|
||||
if (showLabels) {
|
||||
if (mode === "admin") {
|
||||
drawLabels(ctx, map.adminCenters || [], Infinity);
|
||||
return;
|
||||
}
|
||||
if (mode === "admin-debug" || mode === "borders-debug") {
|
||||
drawLabels(ctx, [...(map.adminCenters || []), ...(map.externalGateways || [])], Infinity);
|
||||
return;
|
||||
}
|
||||
const important = [
|
||||
...map.modernCities,
|
||||
...map.ports,
|
||||
...map.markets.slice(0, mode === "all" ? 6 : 10),
|
||||
...map.castles.slice(0, mode === "all" ? 5 : 8),
|
||||
...(map.satelliteCities || []),
|
||||
...map.newTowns,
|
||||
...map.externalGateways,
|
||||
].filter((p) => p.insidePrefecture || p.kind === "External Gateway");
|
||||
|
||||
drawLabels(ctx, important, mode === "all" ? 28 : Infinity);
|
||||
drawLabels(ctx, important, 60);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
export function weightedScore(terms) {
|
||||
let total = 0;
|
||||
for (const [value, weight] of terms) total += value * weight;
|
||||
return total;
|
||||
}
|
||||
67
styles.css
67
styles.css
|
|
@ -1,11 +1,68 @@
|
|||
*{box-sizing:border-box} body{margin:0;background:#f5f5f5;color:#2c2c2c;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} button,input{font:inherit} code{background:#e8e8e8;border-radius:4px;padding:1px 4px} .app{min-height:100vh;padding:24px}.layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:16px;max-width:1400px;margin:0 auto}.header{margin-bottom:16px}.header h1{margin:0 0 6px;font-size:24px;letter-spacing:-0.02em;color:#1a1a1a}.header p{margin:0;color:#5a5a5a;line-height:1.65;font-size:14px}.canvas-shell,.card{background:#ffffff;border:1px solid rgb(0 0 0 / 0.12);border-radius:18px;box-shadow:0 2px 8px rgb(0 0 0 / 0.08)}.canvas-shell{padding:12px;overflow:auto;position:relative}.map-canvas{display:block;border-radius:12px;background:#f8f8f8}.sidebar{display:flex;flex-direction:column;gap:14px}.card{padding:16px}.label,.card-title{display:block;margin-bottom:10px;color:#2c2c2c;font-size:14px;font-weight:650}.input{width:100%;border:1px solid rgb(0 0 0 / 0.18);background:#fafafa;color:#2c2c2c;border-radius:12px;padding:9px 11px;outline:none}.input:focus{border-color:rgb(66 133 244 / 0.6)}.primary-button,.mode-button{border:0;border-radius:12px;padding:9px 11px;cursor:pointer}.primary-button{margin-top:10px;width:100%;background:#1a73e8;color:#fff;font-weight:650}.primary-button:hover{background:#1557b0}.mode-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.mode-button{background:#f1f3f4;color:#3c4043;border:1px solid transparent}.mode-button:hover{background:#e8eaed}.mode-button.active{background:#1a73e8;color:#fff;font-weight:650;border:1px solid #1a73e8}.checkbox-row{display:flex;gap:8px;align-items:center;margin-top:12px;color:#3c4043;font-size:14px}.stats{display:flex;flex-direction:column;gap:7px}.stat-row{display:flex;justify-content:space-between;gap:12px;color:#5f6368;font-size:13px;align-items:baseline}.stat-row strong{color:#202124;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}.legend{color:#5f6368;font-size:12px;line-height:1.65}.legend p{margin:8px 0 0}.example{margin:10px 0;padding:10px;background:#f8f9fa;border:1px solid rgb(0 0 0 / 0.1);border-radius:10px;color:#3c4043;overflow:auto}.id-list{max-height:220px;overflow:auto;margin-top:10px;display:flex;flex-direction:column;gap:6px}.id-row{display:grid;grid-template-columns:112px 1fr;gap:8px;align-items:center;color:#5f6368;font-size:12px}@media (max-width:1100px){.layout{grid-template-columns:1fr}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:#f0f2f5;color:#2c2c2c;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
||||
button,input{font:inherit}
|
||||
code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
|
||||
.app{min-height:100vh;padding:24px}
|
||||
.layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:20px;max-width:1400px;margin:0 auto}
|
||||
.header{margin-bottom:16px}
|
||||
.header h1{margin:0 0 6px;font-size:24px;letter-spacing:-0.02em;color:#1a1a1a;font-weight:700}
|
||||
.header p{margin:0;color:#5a5a5a;line-height:1.6;font-size:14px}
|
||||
.canvas-shell,.card{background:#ffffff;border:1px solid rgba(0,0,0,0.08);border-radius:12px;box-shadow:0 4px 12px rgba(0,0,0,0.04)}
|
||||
.canvas-shell{padding:12px;overflow:auto;position:relative}
|
||||
.map-canvas{display:block;border-radius:8px;background:#f8f9fa}
|
||||
.sidebar{display:flex;flex-direction:column;gap:16px}
|
||||
.card{padding:18px}
|
||||
.label,.card-title{display:block;margin-bottom:12px;color:#1a1a1a;font-size:14px;font-weight:600}
|
||||
.input{width:100%;border:1px solid rgba(0,0,0,0.15);background:#fff;color:#2c2c2c;border-radius:8px;padding:10px 12px;outline:none;transition:border-color 0.2s}
|
||||
.input:focus{border-color:#1a73e8;box-shadow:0 0 0 3px rgba(26,115,232,0.15)}
|
||||
.primary-button,.mode-button{border:0;border-radius:8px;padding:10px 12px;cursor:pointer;font-weight:600;transition:background 0.2s}
|
||||
.primary-button{margin-top:12px;width:100%;background:#1a73e8;color:#fff}
|
||||
.primary-button:hover{background:#1557b0}
|
||||
.mode-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
||||
.mode-button{background:#f1f3f4;color:#3c4043;border:1px solid transparent}
|
||||
.mode-button:hover{background:#e8eaed}
|
||||
.mode-button.active{background:#e8f0fe;color:#1a73e8;border:1px solid #1a73e8}
|
||||
.checkbox-row{display:flex;gap:8px;align-items:center;margin-top:12px;color:#3c4043;font-size:14px;cursor:pointer}
|
||||
.stats{display:flex;flex-direction:column;gap:8px}
|
||||
.stat-row{display:flex;justify-content:space-between;gap:12px;color:#5f6368;font-size:13px;align-items:baseline}
|
||||
.stat-row strong{color:#202124;font-family:ui-monospace,monospace}
|
||||
.legend{color:#5f6368;font-size:13px;line-height:1.6}
|
||||
.legend p{margin:8px 0 0}
|
||||
.example{margin:10px 0;padding:12px;background:#f8f9fa;border:1px solid rgba(0,0,0,0.08);border-radius:8px;color:#3c4043;overflow:auto;font-size:12px}
|
||||
.id-list{max-height:220px;overflow:auto;margin-top:12px;display:flex;flex-direction:column;gap:6px}
|
||||
.id-row{display:grid;grid-template-columns:112px 1fr;gap:8px;align-items:center;color:#5f6368;font-size:12px}
|
||||
|
||||
.legend-grid{display:flex;flex-direction:column;gap:7px;margin-top:8px}.legend-row{display:grid;grid-template-columns:30px 1fr;gap:8px;align-items:center;min-height:20px}.legend-line{display:inline-block;width:28px;height:0;border-top:3px solid #777;border-radius:999px}.legend-swatch{display:inline-block;width:26px;height:14px;border-radius:5px;background:#f5f5f5}.border-swatch{border:2px solid rgba(80,80,80,.8);box-shadow:inset 0 0 0 1px rgba(255,255,255,.9)}.river-major{border-top:4px solid rgba(100,170,210,.95);box-shadow:0 3px 0 rgba(120,180,215,.55)}.rail-line{border-top:3px solid rgba(70,70,70,.95)}.road-line{border-top:3px solid rgba(252,210,90,.95)}.express-line{border-top:5px solid rgba(245,140,60,.95)}.old-road-line{border-top:2px dashed rgba(150,120,90,.75)}.legend-icon{display:inline-block;width:15px;height:15px;justify-self:center;border:2px solid #fff;box-shadow:0 0 0 1px rgb(0 0 0 / .28)}.city-icon{border-radius:50%;background:rgba(230,100,100,.95);width:17px;height:17px}.port-icon{width:0;height:0;border-left:8px solid transparent;border-right:8px solid transparent;border-bottom:15px solid rgba(70,130,200,.95);border-top:0;box-shadow:none;background:transparent}.castle-icon{background:rgba(180,70,70,.96);border-radius:2px}.station-icon{background:#fff;border-color:rgba(60,60,60,.9);border-radius:2px}.industry-icon{background:rgba(110,170,140,.96);border-radius:2px}.newtown-icon{width:0;height:0;border-left:8px solid transparent;border-right:8px solid transparent;border-bottom:15px solid rgba(180,210,240,.95);border-top:0;box-shadow:none;background:transparent}
|
||||
@media (max-width:1100px){.layout{grid-template-columns:1fr}}
|
||||
|
||||
.legend-grid{display:flex;flex-direction:column;gap:8px;margin-top:12px}
|
||||
.legend-row{display:grid;grid-template-columns:32px 1fr;gap:8px;align-items:center;min-height:22px}
|
||||
|
||||
/* Layered GIS style CSS equivalents */
|
||||
.legend-line{display:inline-block;width:28px;height:4px;border-radius:2px;}
|
||||
.express-line{background:#6eb982; border:1px solid #508c64;}
|
||||
.road-line{background:#f5e182; border:1px solid #beaf8c;}
|
||||
|
||||
.legend-swatch{width:18px;height:14px;border-radius:4px;border:1px solid rgb(0 0 0 / 0.18);display:inline-block}.cbd-swatch{background:rgb(224 202 190)}.satellite-icon{background:rgba(215,95,145,0.95);border-radius:999px;border:2px solid #fff}
|
||||
/* Fishbone Railway Style */
|
||||
.rail-line{background:#6e6e6e; height:1.5px; position:relative; border:none; margin-top:2px; border-radius:0}
|
||||
.rail-line::after{content:"";position:absolute;top:-2.5px;left:0;right:0;height:7px;background:repeating-linear-gradient(90deg, transparent, transparent 5px, #6e6e6e 5px, #6e6e6e 6px);}
|
||||
|
||||
.legend-line.harbor-line::before{background:#6b86a0;height:3px;top:7px}
|
||||
.river-major{background:#a0cdf0; border:none; height:3px;}
|
||||
.old-road-line{background:#fff; border:1px solid #dcdcdc; height:3px; border-top:none;}
|
||||
|
||||
.map-tooltip{position:absolute;z-index:20;pointer-events:none;min-width:190px;max-width:270px;background:rgba(255,255,255,.96);border:1px solid rgb(0 0 0 / .16);border-radius:10px;box-shadow:0 8px 24px rgb(0 0 0 / .16);padding:8px 10px;color:#2c2c2c;font-size:12px;line-height:1.5;opacity:0;transform:translateY(2px);transition:opacity .08s ease,transform .08s ease}.map-tooltip.visible{opacity:1;transform:translateY(0)}
|
||||
.legend-swatch{display:inline-block;width:24px;height:14px;border-radius:4px;background:#f5f5f5}
|
||||
.border-swatch{border:2px dashed rgba(110,90,110,1);box-shadow:inset 0 0 0 1px rgba(255,255,255,1), 0 0 0 1px rgba(255,255,255,1)}
|
||||
.cbd-swatch{background:#f0dccd; border:1px solid rgba(0,0,0,0.1)}
|
||||
|
||||
.legend-icon{display:inline-block;width:14px;height:14px;justify-self:center;border:2px solid #fff;border-radius:50%;box-shadow:0 0 0 1px rgba(0,0,0,0.15)}
|
||||
.city-icon{background:#f06e6e;}
|
||||
.satellite-icon{background:#d75f91;}
|
||||
.station-icon{background:#fff;border-color:#444;}
|
||||
.industry-icon{background:#8caaa0; border-radius:3px;}
|
||||
.castle-icon{background:#b44646; border-radius:3px;}
|
||||
|
||||
.port-icon{width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #4682c8;border-top:0;box-shadow:none;background:transparent;border-radius:0}
|
||||
.newtown-icon{width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #b4d2f0;border-top:0;box-shadow:none;background:transparent;border-radius:0}
|
||||
.legend-line.harbor-line{background:transparent; border-top:2px solid #5f7896; height:0}
|
||||
|
||||
.map-tooltip{position:absolute;z-index:20;pointer-events:none;min-width:200px;max-width:280px;background:rgba(255,255,255,0.98);border:1px solid rgba(0,0,0,0.1);border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,0.1);padding:10px 12px;color:#2c2c2c;font-size:12px;line-height:1.5;opacity:0;transform:translateY(4px);transition:opacity 0.15s ease, transform 0.15s ease;font-weight:500}
|
||||
.map-tooltip.visible{opacity:1;transform:translateY(0)}
|
||||
525
test.js
525
test.js
|
|
@ -7,17 +7,19 @@ import {
|
|||
NAME_PROBABILITIES,
|
||||
NAME_TEMPLATES,
|
||||
NAME_TEMPLATE_WEIGHTS,
|
||||
generateEntityName,
|
||||
generateTemplateName,
|
||||
} from "./names.js";
|
||||
import { adminBoundaryMetrics, majorCityCoreIntegrity } from "./qualityMetrics.js";
|
||||
|
||||
const result = document.getElementById("result");
|
||||
const logLines = [];
|
||||
let failed = 0;
|
||||
|
||||
const [namesSource, mapGeneratorSource, testSource] = await Promise.all([
|
||||
const [namesSource, mapGeneratorSource, mapOutputSource, rendererSource, testSource] = await Promise.all([
|
||||
fetch("./names.js").then((response) => response.text()),
|
||||
fetch("./mapGenerator.js").then((response) => response.text()),
|
||||
fetch("./mapOutput.js").then((response) => response.text()),
|
||||
fetch("./renderer.js").then((response) => response.text()),
|
||||
fetch("./test.js").then((response) => response.text()),
|
||||
]);
|
||||
|
||||
|
|
@ -29,6 +31,352 @@ function assert(condition, message) {
|
|||
}
|
||||
}
|
||||
|
||||
function terrainBoundaryTargetForMetrics(map, i) {
|
||||
const lu = map.landuse[i];
|
||||
const urbanPenalty = Math.min(1, (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) + map.populationDensity[i] * 1.35);
|
||||
const majorRiver = Math.min(1, Math.max(map.river[i] - 0.32, 0) * 1.9 + Math.max(map.flowAccum[i] - 0.38, 0) * 0.75);
|
||||
const minorStream = Math.min(1, map.river[i] * 0.34 + map.flowAccum[i] * 0.18);
|
||||
const ridgeDivide = Math.min(1, map.ridgeField[i] * 1.55 + Math.max(0, map.elevation[i] - 0.54) * map.ridgeField[i] * 0.95);
|
||||
const slopeBreak = Math.min(1, map.slope[i] * 0.58 + Math.max(0, map.slope[i] - 0.32) * 0.68);
|
||||
const highGround = Math.max(0, map.elevation[i] - 0.56) * 0.22;
|
||||
const valleyFloorPenalty = map.valleyField[i] * (majorRiver > 0.34 ? -0.10 : -0.62);
|
||||
return Math.max(0, Math.min(1, ridgeDivide + majorRiver * 0.88 + minorStream * 0.22 + slopeBreak + highGround + valleyFloorPenalty - urbanPenalty * 0.72));
|
||||
}
|
||||
|
||||
function adminBoundaryMetrics(map) {
|
||||
let borderEdges = 0;
|
||||
let targetSum = 0;
|
||||
let denseUrbanEdges = 0;
|
||||
let rightAngleRuns = 0;
|
||||
let voronoiLikeEdges = 0;
|
||||
let lowScoreFlatEdges = 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 (!map.prefectureMask[i] || map.sea[i] || map.adminId[i] < 0) continue;
|
||||
for (const [dx, dy] of [[1, 0], [0, 1]]) {
|
||||
const ni = indexOf(x + dx, y + dy);
|
||||
if (!map.prefectureMask[ni] || map.sea[ni] || map.adminId[ni] < 0 || map.adminId[ni] === map.adminId[i]) continue;
|
||||
borderEdges++;
|
||||
const edgeTarget = (terrainBoundaryTargetForMetrics(map, i) + terrainBoundaryTargetForMetrics(map, ni)) * 0.5;
|
||||
targetSum += edgeTarget;
|
||||
const urban = Math.max(map.populationDensity[i], map.populationDensity[ni]) > 0.58 || [2, 3, 4, 7, 8].includes(map.landuse[i]) || [2, 3, 4, 7, 8].includes(map.landuse[ni]);
|
||||
if (urban) denseUrbanEdges++;
|
||||
const ca = map.adminCenters[map.adminId[i]];
|
||||
const cb = map.adminCenters[map.adminId[ni]];
|
||||
if (ca && cb) {
|
||||
const mx = x + dx * 0.5;
|
||||
const my = y + dy * 0.5;
|
||||
const dA = Math.hypot(mx - ca.x, my - ca.y);
|
||||
const dB = Math.hypot(mx - cb.x, my - cb.y);
|
||||
if (Math.abs(dA - dB) < 4.2 && edgeTarget < 0.40) voronoiLikeEdges++;
|
||||
}
|
||||
if (edgeTarget < 0.16 && Math.max(map.slope[i], map.slope[ni]) < 0.24 && Math.max(map.ridgeField[i], map.ridgeField[ni]) < 0.28 && Math.max(map.river[i], map.river[ni]) < 0.26) {
|
||||
lowScoreFlatEdges++;
|
||||
}
|
||||
const sideA = indexOf(x + (dy ? 1 : 0), y + (dx ? 1 : 0));
|
||||
const sideB = indexOf(x - (dy ? 1 : 0), y - (dx ? 1 : 0));
|
||||
if (map.prefectureMask[sideA] && map.prefectureMask[sideB] && !map.sea[sideA] && !map.sea[sideB]) {
|
||||
const turnA = map.adminId[sideA] !== map.adminId[i] && map.adminId[sideA] !== map.adminId[ni];
|
||||
const turnB = map.adminId[sideB] !== map.adminId[i] && map.adminId[sideB] !== map.adminId[ni];
|
||||
if ((turnA || turnB) && terrainBoundaryTargetForMetrics(map, i) < 0.46) rightAngleRuns++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ids = new Set([...map.adminId].filter((id, i) => id >= 0 && map.prefectureMask[i] && !map.sea[i]));
|
||||
const areaById = new Map();
|
||||
for (let i = 0; i < map.adminId.length; i++) {
|
||||
if (map.prefectureMask[i] && !map.sea[i] && map.adminId[i] >= 0) areaById.set(map.adminId[i], (areaById.get(map.adminId[i]) || 0) + 1);
|
||||
}
|
||||
const areas = [...areaById.values()].sort((a, b) => a - b);
|
||||
const medianArea = areas.length ? areas[Math.floor(areas.length / 2)] : 1;
|
||||
const maxArea = areas.length ? areas[areas.length - 1] : 1;
|
||||
let disconnectedMunicipalities = 0;
|
||||
let maxComponents = 0;
|
||||
const seen = new Uint8Array(MAP_W * MAP_H);
|
||||
for (const id of ids) {
|
||||
let comps = 0;
|
||||
seen.fill(0);
|
||||
for (let i = 0; i < map.adminId.length; i++) {
|
||||
if (seen[i] || map.adminId[i] !== id || !map.prefectureMask[i] || map.sea[i]) continue;
|
||||
comps++;
|
||||
const queue = [i];
|
||||
seen[i] = 1;
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
const x = cur % MAP_W;
|
||||
const y = Math.floor(cur / MAP_W);
|
||||
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (seen[ni] || map.adminId[ni] !== id || !map.prefectureMask[ni] || map.sea[ni]) continue;
|
||||
seen[ni] = 1;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (comps > 1) disconnectedMunicipalities++;
|
||||
maxComponents = Math.max(maxComponents, comps);
|
||||
}
|
||||
|
||||
const centerValidCount = map.adminCenters.filter((center) => {
|
||||
const i = indexOf(center.x, center.y);
|
||||
return map.prefectureMask[i] && !map.sea[i] && map.adminId[i] >= 0;
|
||||
}).length;
|
||||
|
||||
return {
|
||||
borderEdges,
|
||||
avgTarget: borderEdges ? targetSum / borderEdges : 0,
|
||||
denseUrbanRate: borderEdges ? denseUrbanEdges / borderEdges : 0,
|
||||
rightAngleRate: borderEdges ? rightAngleRuns / borderEdges : 0,
|
||||
voronoiLikeRate: borderEdges ? voronoiLikeEdges / borderEdges : 0,
|
||||
lowScoreFlatRate: borderEdges ? lowScoreFlatEdges / borderEdges : 0,
|
||||
areaDiversity: maxArea / Math.max(1, medianArea),
|
||||
municipalityCount: ids.size,
|
||||
disconnectedMunicipalities,
|
||||
maxComponents,
|
||||
centerValidRatio: map.adminCenters.length ? centerValidCount / map.adminCenters.length : 1,
|
||||
};
|
||||
}
|
||||
|
||||
function majorCityCoreIntegrity(map) {
|
||||
const majorCities = map.modernCities.filter((city) => (city.population || 0) >= 180000);
|
||||
if (majorCities.length === 0) return 1;
|
||||
let sum = 0;
|
||||
let checked = 0;
|
||||
for (const city of majorCities) {
|
||||
const counts = new Map();
|
||||
const r = Math.ceil(Math.max(3, city.coreRadius || 4));
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const x = city.x + dx;
|
||||
const y = city.y + dy;
|
||||
if (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H || Math.hypot(dx, dy) > r) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (!map.prefectureMask[i] || map.sea[i]) continue;
|
||||
if (map.landuse[i] !== 3 && map.populationDensity[i] < 0.38) continue;
|
||||
const id = map.adminId[i];
|
||||
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
|
||||
}
|
||||
}
|
||||
const total = [...counts.values()].reduce((a, b) => a + b, 0);
|
||||
if (total === 0) continue;
|
||||
sum += Math.max(...counts.values()) / total;
|
||||
checked++;
|
||||
}
|
||||
return checked ? sum / checked : 1;
|
||||
}
|
||||
|
||||
function satelliteMunicipalityMetrics(map) {
|
||||
const areaById = new Map();
|
||||
for (let i = 0; i < map.adminId.length; i++) {
|
||||
if (map.prefectureMask[i] && !map.sea[i] && map.adminId[i] >= 0) areaById.set(map.adminId[i], (areaById.get(map.adminId[i]) || 0) + 1);
|
||||
}
|
||||
const rows = (map.satelliteCities || [])
|
||||
.filter((sat) => map.prefectureMask[indexOf(sat.x, sat.y)] && !map.sea[indexOf(sat.x, sat.y)])
|
||||
.map((sat) => {
|
||||
const admin = map.adminId[indexOf(sat.x, sat.y)];
|
||||
return { sat, admin, area: areaById.get(admin) || 0 };
|
||||
});
|
||||
const independent = rows.filter((row) => row.sat.municipalityClass === "independentSatelliteMunicipality");
|
||||
const small = independent.filter((row) => row.area < 80);
|
||||
const largeTooSmall = rows.filter((row) => (row.sat.population || 0) >= 60000 && row.sat.municipalityClass === "independentSatelliteMunicipality" && row.area < 120);
|
||||
const average = independent.length ? independent.reduce((sum, row) => sum + row.area, 0) / independent.length : 0;
|
||||
return { rows, independent, small, largeTooSmall, average };
|
||||
}
|
||||
|
||||
function regionalComponentMetrics(map) {
|
||||
const ids = new Set([...map.prefectureRegionId].filter((id, i) => id >= 0 && !map.sea[i]));
|
||||
const seen = new Uint8Array(MAP_W * MAP_H);
|
||||
let maxComponents = 0;
|
||||
for (const id of ids) {
|
||||
seen.fill(0);
|
||||
let comps = 0;
|
||||
for (let i = 0; i < map.prefectureRegionId.length; i++) {
|
||||
if (seen[i] || map.sea[i] || map.prefectureRegionId[i] !== id) continue;
|
||||
comps++;
|
||||
const queue = [i];
|
||||
seen[i] = 1;
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
const x = cur % MAP_W;
|
||||
const y = Math.floor(cur / MAP_W);
|
||||
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (seen[ni] || map.sea[ni] || map.prefectureRegionId[ni] !== id) continue;
|
||||
seen[ni] = 1;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
}
|
||||
maxComponents = Math.max(maxComponents, comps);
|
||||
}
|
||||
return { regionCount: ids.size, maxComponents };
|
||||
}
|
||||
|
||||
function meanField(map, fieldName, predicate) {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
const field = map[fieldName];
|
||||
for (let i = 0; i < field.length; i++) {
|
||||
if (!predicate(i)) continue;
|
||||
sum += field[i];
|
||||
count++;
|
||||
}
|
||||
return count ? sum / count : 0;
|
||||
}
|
||||
|
||||
function ridgeSinuosityMetric(map) {
|
||||
const centers = [];
|
||||
for (let y = 1; y < MAP_H - 1; y++) {
|
||||
let sum = 0;
|
||||
let weight = 0;
|
||||
for (let x = 1; x < MAP_W - 1; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (map.sea[i]) continue;
|
||||
const r = Math.max(0, map.ridgeField[i] - 0.36);
|
||||
sum += x * r;
|
||||
weight += r;
|
||||
}
|
||||
if (weight > 1.2) centers.push(sum / weight);
|
||||
}
|
||||
if (centers.length < 8) return 0;
|
||||
let turn = 0;
|
||||
let total = 0;
|
||||
for (let i = 2; i < centers.length; i++) {
|
||||
const a = centers[i - 1] - centers[i - 2];
|
||||
const b = centers[i] - centers[i - 1];
|
||||
turn += Math.abs(b - a);
|
||||
total += Math.abs(b) + Math.abs(a) + 0.01;
|
||||
}
|
||||
return turn / total;
|
||||
}
|
||||
|
||||
function terrainCoreMetrics(map) {
|
||||
const land = [...map.elevation].map((_, i) => i).filter((i) => !map.sea[i]);
|
||||
const mountainCells = land.filter((i) => map.elevation[i] > 0.58 || map.ridgeField[i] > 0.42).length;
|
||||
const lowlandCells = land.filter((i) => map.plain[i] > 0.38 || map.depositionalLowland?.[i] > 0.24).length;
|
||||
const ridgeValues = land.map((i) => map.ridgeField[i]);
|
||||
const ridgeMean = ridgeValues.reduce((sum, value) => sum + value, 0) / Math.max(1, ridgeValues.length);
|
||||
const ridgeVariance = ridgeValues.reduce((sum, value) => sum + (value - ridgeMean) ** 2, 0) / Math.max(1, ridgeValues.length);
|
||||
const depositionTargetMean = meanField(map, "depositionField", (i) => !map.sea[i] && (map.coastalLowland[i] > 0.18 || map.basinField[i] > 0.22 || map.river[i] > 0.18 || map.flowAccum[i] > 0.24));
|
||||
const depositionOtherMean = meanField(map, "depositionField", (i) => !map.sea[i] && map.coastalLowland[i] < 0.08 && map.basinField[i] < 0.12 && map.river[i] < 0.06 && map.flowAccum[i] < 0.12 && map.ridgeField[i] < 0.28);
|
||||
const riverValleyMean = meanField(map, "valleyField", (i) => !map.sea[i] && map.river[i] > 0.20);
|
||||
const nonRiverValleyMean = meanField(map, "valleyField", (i) => !map.sea[i] && map.river[i] <= 0.02);
|
||||
return {
|
||||
landCount: land.length,
|
||||
mountainRatio: mountainCells / Math.max(1, land.length),
|
||||
lowlandRatio: lowlandCells / Math.max(1, land.length),
|
||||
ridgeVariance,
|
||||
ridgeSinuosity: ridgeSinuosityMetric(map),
|
||||
depositionTargetMean,
|
||||
depositionOtherMean,
|
||||
riverValleyMean,
|
||||
nonRiverValleyMean,
|
||||
depositionSum: [...map.depositionField].reduce((sum, value) => sum + value, 0),
|
||||
alluvialMax: Math.max(...(map.alluvialFanField || [0])),
|
||||
deltaMax: Math.max(...(map.deltaField || [0])),
|
||||
};
|
||||
}
|
||||
|
||||
function requiredTransportNodes(map) {
|
||||
const nodes = [];
|
||||
const seen = new Set();
|
||||
function add(p, reason) {
|
||||
if (!p) return;
|
||||
const key = `${p.x},${p.y}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
nodes.push({ ...p, requiredTransportReason: reason });
|
||||
}
|
||||
add(map.prefecturalCapital, "capital");
|
||||
for (const gate of map.externalGateways || []) add(gate, "externalGateway");
|
||||
for (const city of map.modernCities || []) if ((city.population || 0) >= 120000 || city.isPrefecturalCapital) add(city, "majorCity");
|
||||
for (const port of map.ports || []) if (port.portClass === "major") add(port, "majorPort");
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function transportConnectivityMetrics(map) {
|
||||
const paths = [
|
||||
...map.railways,
|
||||
...map.branchRailways,
|
||||
...map.externalRailways,
|
||||
...map.nationalRoads,
|
||||
...(map.ringRoads || []),
|
||||
...map.expressways,
|
||||
...map.externalRoads,
|
||||
...map.externalExpressways,
|
||||
];
|
||||
const pathCells = new Set();
|
||||
for (const path of paths) for (const [x, y] of path) pathCells.add(`${x},${y}`);
|
||||
const nodes = requiredTransportNodes(map);
|
||||
function nearestCell(node) {
|
||||
let best = null;
|
||||
let bestD = Infinity;
|
||||
for (const key of pathCells) {
|
||||
const [x, y] = key.split(",").map(Number);
|
||||
const d = Math.hypot(node.x - x, node.y - y);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = key;
|
||||
}
|
||||
}
|
||||
return { key: best, distance: bestD };
|
||||
}
|
||||
const seen = new Set();
|
||||
const components = [];
|
||||
for (const key of pathCells) {
|
||||
if (seen.has(key)) continue;
|
||||
const queue = [key];
|
||||
const component = new Set([key]);
|
||||
seen.add(key);
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const [x, y] = queue[q].split(",").map(Number);
|
||||
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) {
|
||||
const nk = `${x + dx},${y + dy}`;
|
||||
if (!pathCells.has(nk) || seen.has(nk)) continue;
|
||||
seen.add(nk);
|
||||
component.add(nk);
|
||||
queue.push(nk);
|
||||
}
|
||||
}
|
||||
components.push(component);
|
||||
}
|
||||
const mapped = nodes.map((node) => ({
|
||||
node,
|
||||
nearest: nearestCell(node),
|
||||
components: components
|
||||
.map((component, componentIndex) => ({
|
||||
componentIndex,
|
||||
near: [...component].some((key) => {
|
||||
const [x, y] = key.split(",").map(Number);
|
||||
return Math.hypot(node.x - x, node.y - y) <= 7;
|
||||
}),
|
||||
}))
|
||||
.filter((item) => item.near)
|
||||
.map((item) => item.componentIndex),
|
||||
}));
|
||||
const reachable = mapped.filter((item) => item.components.length > 0);
|
||||
let largestRequiredComponent = 0;
|
||||
for (let componentIndex = 0; componentIndex < components.length; componentIndex++) {
|
||||
largestRequiredComponent = Math.max(largestRequiredComponent, reachable.filter((item) => item.components.includes(componentIndex)).length);
|
||||
}
|
||||
return {
|
||||
requiredCount: nodes.length,
|
||||
reachableCount: reachable.length,
|
||||
largestRequiredComponent,
|
||||
isolatedExternalGateways: mapped.filter((item) => item.node.requiredTransportReason === "externalGateway" && item.components.length === 0).length,
|
||||
isolatedMajorCities: mapped.filter((item) => item.node.requiredTransportReason === "majorCity" && item.components.length === 0).length,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const map = generateMap(12345);
|
||||
const other = generateMap(54321);
|
||||
|
|
@ -143,6 +491,10 @@ try {
|
|||
: 1;
|
||||
const adminMetrics = adminBoundaryMetrics(map);
|
||||
const cityCoreIntegrity = majorCityCoreIntegrity(map);
|
||||
const satelliteMetrics = satelliteMunicipalityMetrics(map);
|
||||
const regionalMetrics = regionalComponentMetrics(map);
|
||||
const terrainMetrics = terrainCoreMetrics(map);
|
||||
const transportMetrics = transportConnectivityMetrics(map);
|
||||
|
||||
assert(NAME_KANJI_POOLS && Array.isArray(NAME_KANJI_POOLS.modifiers), "NAME_KANJI_POOLS exists");
|
||||
assert(NAME_TEMPLATES && NAME_TEMPLATES.modifierTerrain?.slots?.length === 2, "NAME_TEMPLATES exists");
|
||||
|
|
@ -151,7 +503,7 @@ try {
|
|||
const removedContextModule = "placeName" + "Context.js";
|
||||
assert(!namesSource.includes(removedContextModule) && !mapGeneratorSource.includes(removedContextModule) && !testSource.includes(removedContextModule), "removed name-context import is absent");
|
||||
assert(Object.keys(NAME_KANJI_POOLS).every((key) => Array.isArray(NAME_KANJI_POOLS[key])), "name category pools are centralized arrays");
|
||||
assert(Object.values(NAME_KANJI_POOLS).every((pool) => pool.length === 0), "default name category pools are empty");
|
||||
assert(Object.values(NAME_KANJI_POOLS).every((pool) => pool.every((part) => typeof part === "string" && !part.includes("\uFFFD"))), "configured name category pools contain valid strings");
|
||||
assert(Object.keys(NAME_PARTS).length === 0, "legacy NAME_PARTS has no hidden candidates");
|
||||
const removedContextSuffixConst = "CONTEXT" + "_SUFFIXES";
|
||||
const removedContextSuffixKey = "context" + "Suffixes";
|
||||
|
|
@ -160,6 +512,7 @@ try {
|
|||
|
||||
assert(map.elevation.length === size, "elevation length matches map size");
|
||||
assert(map.sea.length === size, "sea length matches map size");
|
||||
assert(map.ocean.length === size && map.lake.length === size, "ocean and lake masks match map size");
|
||||
assert(map.river.length === size, "river length matches map size");
|
||||
assert(map.landuse.length === size, "land-use length matches map size");
|
||||
assert(map.adminId.length === size, "municipal id length matches map size");
|
||||
|
|
@ -167,9 +520,27 @@ try {
|
|||
assert(map.populationDensity.length === size, "population density length matches map size");
|
||||
assert(map.ridgeField.length === size && map.valleyField.length === size && map.flowAccum.length === size, "causal terrain fields match map size");
|
||||
assert(map.erosionField.length === size && map.depositionField.length === size, "erosion and deposition fields match map size");
|
||||
assert(map.arcSpineField.length === size && map.branchRidgeField.length === size, "spine and branch ridge fields match map size");
|
||||
assert(map.depositionalLowland.length === size && map.alluvialFanField.length === size && map.deltaField.length === size, "depositional debug fields match map size");
|
||||
assert(map.naturalBarrierScore.length === size, "natural barrier score field matches map size");
|
||||
assert(map.terrainTemplate && Number.isFinite(map.terrainTemplate.deposition) && Number.isFinite(map.terrainTemplate.erosion), "terrain template parameters are exposed");
|
||||
assert(map.terrainDebug && Number.isFinite(map.terrainDebug.primarySpineStrength), "terrain debug metrics exist");
|
||||
assert(map.terrainDebug.primarySpineStrength > 0.08, "primary mountain spine has visible strength");
|
||||
assert(map.terrainDebug.largeInlandLakeCount <= 1, "large inland lakes are rare");
|
||||
assert(map.terrainDebug.smallIslandCount <= 24, "small island/coast speckles stay limited");
|
||||
assert(map.terrainDebug.depositionLowlandArea > 0, "depositional lowland area is tracked");
|
||||
assert(["east-west", "north-south", "diagonal"].includes(map.terrainTemplate.coastAxis) && map.terrainTemplate.coastSides?.length === 2, "paired coast template parameters are exposed");
|
||||
assert(map.settlementCluster.length === size, "settlement cluster field matches map size");
|
||||
assert(Array.isArray(map.bridges) && Array.isArray(map.tunnels) && Array.isArray(map.harborWorks), "legacy bridge/tunnel arrays and harbor arrays exist");
|
||||
assert(map.prefectureRegionId.length === size && Array.isArray(map.regionalPrefectureBorders), "neighbor prefecture regions exist");
|
||||
assert(map.regionalDebug && Number.isFinite(map.regionalDebug.regionalChangedAfterNaturalPartition), "regional changed-cell debug exists");
|
||||
assert(map.regionalDebug.regionalChangedAfterNaturalPartition > 0, "regional natural partition changes region cells");
|
||||
assert(map.regionalDebug.regionalBorderCountBefore > 0 && map.regionalDebug.regionalBorderCountAfter > 0, "regional border counts are tracked");
|
||||
assert(map.regionalDebug.regionalNaturalBarrierAverageAfter >= map.regionalDebug.regionalNaturalBarrierAverageBefore - 0.08, "regional border natural-barrier affinity does not degrade meaningfully");
|
||||
assert(map.regionalDebug.regionalVoronoiLikeRateAfter <= map.regionalDebug.regionalVoronoiLikeRateBefore + 0.22, "regional weak Voronoi-like border rate stays bounded");
|
||||
assert(map.regionalDebug.compartmentCount > 0 && map.regionalDebug.changedAfterCompartmentAssignment > 0, "regional compartment assignment debug is available");
|
||||
assert(Number.isFinite(map.regionalDebug.borderNaturalBarrierAverage) && Number.isFinite(map.regionalDebug.voronoiLikeRate), "regional natural-border aliases are exposed");
|
||||
assert(regionalMetrics.regionCount >= 4 && regionalMetrics.maxComponents <= 5, "regional prefecture regions remain connected enough for display");
|
||||
assert(Array.isArray(map.tributaryRivers) && Array.isArray(map.smallStreams), "river hierarchy arrays exist");
|
||||
assert(Array.isArray(map.icAccessRoads), "IC access road array exists");
|
||||
assert(Array.isArray(map.satelliteCities), "satelliteCities is an array");
|
||||
|
|
@ -206,21 +577,44 @@ try {
|
|||
}
|
||||
assert(prefectureComponents === 1, "prefecture area is a single connected component");
|
||||
assert(map.prefectureBorder.length > 0, "prefecture border exists");
|
||||
assert([...map.ocean].some((value) => value === 1), "edge-connected ocean mask exists");
|
||||
assert([...map.lake].every((value, i) => !value || (map.sea[i] && !map.ocean[i])), "lake mask only marks isolated non-ocean water");
|
||||
assert([...map.sea].every((value, i) => !value || map.ocean[i] || map.lake[i]), "water cells are classified as ocean or lake");
|
||||
assert(map.adminBorders.length > 0, "municipal borders exist");
|
||||
assert(map.mainRivers.length > 0, "at least one major river exists");
|
||||
assert(map.tributaryRivers.length > 0, "tributary river network exists");
|
||||
assert(map.smallStreams.length > 0, "small stream network exists");
|
||||
assert(terrainMetrics.mountainRatio > 0.10 && terrainMetrics.mountainRatio < 0.72, "mountain and ridge area is meaningful but not total");
|
||||
assert(terrainMetrics.lowlandRatio > 0.08 && terrainMetrics.lowlandRatio < 0.72, "lowlands exist without dominating every map");
|
||||
assert(terrainMetrics.ridgeVariance > 0.004, "ridge field has nontrivial spatial variance");
|
||||
assert(terrainMetrics.ridgeSinuosity > 0.015, "ridge centerlines are not perfectly straight bands");
|
||||
assert(terrainMetrics.depositionSum > 0.2, "deposition field has nonzero values");
|
||||
assert(terrainMetrics.depositionTargetMean >= terrainMetrics.depositionOtherMean * 0.85, "deposition favors rivers, basins, and coastal lowlands");
|
||||
assert(terrainMetrics.riverValleyMean > terrainMetrics.nonRiverValleyMean * 1.08, "river cells overlap valley fields more than random non-river cells");
|
||||
assert(terrainMetrics.alluvialMax > 0 || terrainMetrics.deltaMax > 0, "alluvial fan or delta fields are active");
|
||||
assert(map.harborWorks.length <= map.ports.length, "harbor works are attached to ports");
|
||||
assert(map.ports.some((p) => p.portClass === "major"), "at least one major port is classified");
|
||||
assert(map.ports.every((p) => ["major", "regional", "fishing", "lake"].includes(p.portClass)), "ports have explicit classes");
|
||||
assert(map.externalGateways.length > 0, "external gateways exist");
|
||||
assert(map.minorRoads.length > 0, "minor roads exist");
|
||||
assert(map.adminCenters.length >= 12, "municipality count is sufficiently large");
|
||||
assert(map.adminCenters.length >= 18, "municipality center count is sufficiently large");
|
||||
assert(adminMetrics.municipalityCount >= 10, "terrain snapping preserves a reasonable municipality count");
|
||||
assert(adminMetrics.centerValidRatio >= 0.95, "municipality centers remain on valid assigned land cells");
|
||||
assert(adminMetrics.maxComponents <= 4, "municipal topology repair prevents excessive disconnected fragments");
|
||||
assert(adminMetrics.disconnectedMunicipalities <= Math.max(2, Math.ceil(adminMetrics.municipalityCount * 0.20)), "most municipalities remain connected after terrain snapping");
|
||||
assert(adminMetrics.avgTarget > 0.18, "admin borders align with terrain target features often enough");
|
||||
assert(map.adminDebug && map.adminDebug.compartmentCount > 0, "natural compartment debug is available");
|
||||
assert(map.adminDebug.averageCompartmentArea > 0, "natural compartments have positive average area");
|
||||
assert(Number.isFinite(map.adminDebug.changedAfterLandscapePartition) && Number.isFinite(map.adminDebug.changedAfterSnap), "municipal changed-cell diagnostics exist");
|
||||
assert(map.adminDebug.changedAfterCompartmentAssignment > 0 || map.adminDebug.changedAfterLandscapePartition > 0 || map.adminDebug.changedAfterSnap > 0, "municipal compartment or terrain passes change admin cells");
|
||||
assert(map.adminDebug.changedAfterFinalExclaveRemoval + map.adminDebug.changedAfterFinalMerge < Math.max(2800, (map.adminDebug.changedAfterLandscapePartition + map.adminDebug.changedAfterSnap + map.adminDebug.changedAfterUrbanLock) * 1.35), "final municipal repair does not erase most terrain and urban changes");
|
||||
assert(map.adminDebug.finalBorderNaturalBarrierAverage >= 0, "natural barrier score is tracked along final borders");
|
||||
assert(map.adminDebug.voronoiLikeRateAfter <= Math.max(0.72, map.adminDebug.voronoiLikeRateBefore + 0.20), "natural compartment pass does not increase weak bisectors excessively");
|
||||
assert(Number.isFinite(map.adminDebug.satelliteMunicipalitiesCreated) && Number.isFinite(map.adminDebug.averageSatelliteMunicipalityArea), "satellite municipality debug is available");
|
||||
assert(satelliteMetrics.independent.length < 3 || satelliteMetrics.small.length / satelliteMetrics.independent.length <= 0.35, "tiny independent satellite municipalities are not the dominant pattern");
|
||||
assert(satelliteMetrics.largeTooSmall.length === 0, "large independent satellites have meaningful municipal area");
|
||||
assert(satelliteMetrics.independent.length < 3 || satelliteMetrics.average >= 140, "average independent satellite municipality area is meaningful");
|
||||
assert(satelliteMetrics.rows.every((row) => row.area >= 80 || row.sat.municipalityClass === "smallTownAttachedToRuralMunicipality" || row.sat.municipalityClass === "suburbanDistrictMergedWithParent" || row.sat.municipalityClass === "newTownDistrict"), "tiny satellite areas are merged or explicitly classified as attached districts");
|
||||
assert(adminMetrics.denseUrbanRate < 0.42, "admin borders avoid excessive dense urban crossings");
|
||||
assert(adminMetrics.rightAngleRate < 0.46, "admin borders avoid excessive unsupported stair-step artifacts");
|
||||
assert(adminMetrics.voronoiLikeRate < 0.58, "admin borders are not dominated by weak-terrain center bisectors");
|
||||
|
|
@ -243,21 +637,39 @@ try {
|
|||
assert(capitalInside, "prefectural capital is inside the prefecture");
|
||||
assert(maxModernEndpointDegree <= 10, "modern transport endpoints are not over-centralized");
|
||||
assert(map.entitiesForNames.every((item) => item.id && item.name), "nameable entities have ids and names");
|
||||
assert(map.adminCenters.every((item) => item.id && item.name), "municipal centers have ids and names");
|
||||
assert(map.entitiesForNames.some((item) => item.kind === "Municipal Center"), "municipal centers are included in label/name candidates");
|
||||
assert(map.adminCenters.every((item) => item.name && Array.from(String(item.name)).length >= 2), "municipal center names are valid labels");
|
||||
assert(map.adminCenters.some((item) => item.representativeFeatureName), "municipal centers keep representative feature metadata when available");
|
||||
assert(map.adminCenters.every((item) => Array.from(String(item.name)).length >= 2), "municipal center names are not one-character labels");
|
||||
assert(map.adminCenters.every((item) => !(item.representativeFeatureName && String(item.name).startsWith(item.representativeFeatureName) && Array.from(String(item.name)).length === Array.from(String(item.representativeFeatureName)).length + 1)), "municipal names avoid dangling one-kanji suffix fallback");
|
||||
const adminNameDuplicateRatio = map.adminCenters.length ? 1 - new Set(map.adminCenters.map((item) => item.name)).size / map.adminCenters.length : 0;
|
||||
assert(adminNameDuplicateRatio < 0.22, "duplicate municipal names stay low");
|
||||
assert(map.adminDebug.naturalCompartmentCount > adminMetrics.municipalityCount, "natural compartments are finer than municipalities");
|
||||
assert(map.adminDebug.targetMunicipalityCount >= 20 && map.adminDebug.targetMunicipalityCount <= 50 && map.adminDebug.actualMunicipalityCount >= 18, "municipality target and actual counts are dense enough");
|
||||
assert(map.adminDebug.changedAfterCompartmentAssignment > 0, "municipal compartment assignment is active");
|
||||
assert(map.adminDebug.seedCellRevivalCount === 0, "seed cell revival is disabled");
|
||||
assert(map.adminDebug.candidateSeedCount >= map.adminDebug.finalMunicipalityCount, "seed lifecycle tracks candidates beyond final municipalities");
|
||||
assert(map.adminDebug.absorbedSeedCount >= 0 && map.adminDebug.pendingSeedCount === 0, "unresolved pending seeds are absorbed");
|
||||
assert(map.adminDebug.finalTinyMunicipalityCount <= Math.max(3, Math.ceil(map.adminDebug.finalMunicipalityCount * 0.16)), "tiny municipalities remain a small fraction");
|
||||
assert(Array.isArray(map.adminDebug.compartmentBorders) && map.adminDebug.compartmentBorders.length > map.adminBorders.length, "natural compartment borders are available for debug rendering");
|
||||
assert(map.entitiesForNames.every((item) => typeof item.name === "string"), "nameable entity names are strings");
|
||||
assert(map.entitiesForNames.every((item) => !String(item.name).includes("\uFFFD")), "generated names contain no replacement characters");
|
||||
assert(map.entitiesForNames.every((item) => Array.from(String(item.name)).length >= 2), "one-character generated names are prevented");
|
||||
assert(map.entitiesForNames.every((item) => String(item.name).length > 0), "empty generated names are prevented");
|
||||
assert(duplicateNameRatio < 0.18, "generated place-name duplicates stay low");
|
||||
assert(map.nameDebug && Array.isArray(map.nameDebug.emptyPools), "nameDebug reports empty pools");
|
||||
assert(map.nameDebug.emptyPools.length === Object.keys(NAME_KANJI_POOLS).length, "empty default pools are visible in nameDebug");
|
||||
assert(map.nameDebug.oneKanjiAppendFallbackUsed === 0, "one-kanji append fallback is never used");
|
||||
assert((map.nameDebug.derivedNameCount || 0) === 0, "derived municipality suffix names are not generated");
|
||||
assert((map.nameDebug.maxDerivedPerBase || 0) <= 2, "derived names per base stay small");
|
||||
assert(map.nameDebug.emptyPools.length === Object.values(NAME_KANJI_POOLS).filter((pool) => pool.length === 0).length, "nameDebug empty pools match configured pools");
|
||||
assert(map.nameDebug.selectedTemplateCounts && typeof map.nameDebug.selectedTemplateCounts === "object", "nameDebug selectedTemplateCounts exists");
|
||||
assert(map.nameDebug.selectedContextCounts && typeof map.nameDebug.selectedContextCounts === "object", "nameDebug selectedContextCounts exists");
|
||||
assert(
|
||||
map.nameDebug.generatedNamesUsed + map.nameDebug.customNamesUsed + map.nameDebug.forcedNamesUsed + map.nameDebug.fallbackAttempts === namedEntityCount,
|
||||
"nameDebug accounting covers named entities"
|
||||
);
|
||||
assert(generateTemplateName(777, "probe-0", { x: 10, y: 10, kind: "Probe" }, {}, 0, new Set()) === null, "empty pools do not use hidden fallback candidates");
|
||||
assert(activePoolChars.size === 0, "no active pool characters exist until configured");
|
||||
assert(activePoolChars.size > 0 || generateTemplateName(777, "probe-0", { x: 10, y: 10, kind: "Probe" }, {}, 0, new Set()) === null, "template generation depends on active pools");
|
||||
assert(villageClusterMean > 0.16, "villages prefer clustered valley, basin, coastal, and agricultural cells");
|
||||
assert(saneEndpointRatio >= 0.76, "transport endpoints stay near meaningful generated nodes");
|
||||
assert(Object.keys(CUSTOM_NAMES).length === 0 || NAME_PROBABILITIES.customName < 1, "CUSTOM_NAMES are probabilistic by default");
|
||||
|
|
@ -275,6 +687,72 @@ try {
|
|||
assert(JSON.stringify(againA.entitiesForNames.map((item) => [item.id, item.name])) === JSON.stringify(againB.entitiesForNames.map((item) => [item.id, item.name])), "generated names are deterministic for the same seed");
|
||||
assert(JSON.stringify(againA.adminCenters.map((item) => [item.id, item.x, item.y, item.name])) === JSON.stringify(againB.adminCenters.map((item) => [item.id, item.x, item.y, item.name])), "municipal centers are deterministic for the same seed");
|
||||
assert(JSON.stringify([...againA.adminId]) === JSON.stringify([...againB.adminId]), "municipal adminId snapping is deterministic for the same seed");
|
||||
assert(JSON.stringify([...againA.prefectureRegionId]) === JSON.stringify([...againB.prefectureRegionId]), "regional prefecture ids are deterministic for the same seed");
|
||||
assert(JSON.stringify(againA.adminDebug) === JSON.stringify(againB.adminDebug), "admin debug metrics are deterministic for the same seed");
|
||||
assert(JSON.stringify(againA.regionalDebug) === JSON.stringify(againB.regionalDebug), "regional debug metrics are deterministic for the same seed");
|
||||
assert(JSON.stringify(againA.transportDebug) === JSON.stringify(againB.transportDebug), "transport debug metrics are deterministic for the same seed");
|
||||
assert(JSON.stringify(transportConnectivityMetrics(againA)) === JSON.stringify(transportConnectivityMetrics(againB)), "transport connectivity metrics are deterministic for the same seed");
|
||||
assert(JSON.stringify([...againA.elevation]) === JSON.stringify([...againB.elevation]), "elevation is deterministic for the same seed");
|
||||
assert(JSON.stringify([...againA.ridgeField]) === JSON.stringify([...againB.ridgeField]), "ridge field is deterministic for the same seed");
|
||||
assert(JSON.stringify([...againA.river]) === JSON.stringify([...againB.river]), "river field is deterministic for the same seed");
|
||||
assert(JSON.stringify(terrainCoreMetrics(againA)) === JSON.stringify(terrainCoreMetrics(againB)), "terrain debug metrics are deterministic for the same seed");
|
||||
|
||||
const blockedCapitalName = "\u52A0\u8302";
|
||||
const capitalNameMaps = [114514, 12345, 54321, 777, 999].map((seedValue) => generateMap(seedValue));
|
||||
const capitalNames = capitalNameMaps.map((seeded) => seeded.prefecturalCapital?.name).filter(Boolean);
|
||||
assert(new Set(capitalNames).size > 1, "prefectural capital names vary across seeds");
|
||||
assert(capitalNames.some((name) => name !== blockedCapitalName), "prefectural capital is not always the repeated custom name");
|
||||
for (const [n, seeded] of capitalNameMaps.entries()) {
|
||||
const seedValue = [114514, 12345, 54321, 777, 999][n];
|
||||
const metrics = terrainCoreMetrics(seeded);
|
||||
assert(seeded.mainRivers.length > 0 && seeded.tributaryRivers.length > 0 && seeded.smallStreams.length > 0, `seed ${seedValue}: river hierarchy exists`);
|
||||
assert(metrics.mountainRatio > 0.08 && metrics.lowlandRatio > 0.06, `seed ${seedValue}: mountain and lowland terrain both exist`);
|
||||
assert(metrics.ridgeVariance > 0.003 && metrics.ridgeSinuosity > 0.010, `seed ${seedValue}: ridges have varied jagged structure`);
|
||||
assert(metrics.depositionSum > 0.1 && metrics.depositionTargetMean >= metrics.depositionOtherMean * 0.75, `seed ${seedValue}: deposition is active in plausible lowlands`);
|
||||
assert(metrics.riverValleyMean > metrics.nonRiverValleyMean, `seed ${seedValue}: rivers follow valley fields`);
|
||||
assert(seeded.terrainDebug?.primarySpineStrength > 0.08, `seed ${seedValue}: primary spine is strong enough`);
|
||||
assert((seeded.terrainDebug?.largeInlandLakeCount || 0) <= 1, `seed ${seedValue}: large inland lakes are rare`);
|
||||
assert((seeded.terrainDebug?.smallIslandCount || 0) <= 24, `seed ${seedValue}: small island speckles are limited`);
|
||||
assert(seeded.villages.length > 0 && seeded.markets.length > 0 && seeded.modernCities.length > 0, `seed ${seedValue}: settlements are generated`);
|
||||
assert(seeded.premodernRoads.length > 0 && seeded.railways.length > 0, `seed ${seedValue}: roads and railways are generated`);
|
||||
const seededTransport = transportConnectivityMetrics(seeded);
|
||||
assert(seeded.adminId.length === size && seeded.adminBorders.length > 0 && seeded.regionalPrefectureBorders.length > 0, `seed ${seedValue}: admin and regional borders exist`);
|
||||
assert(seeded.adminCenters.every((item) => item.name && Array.from(String(item.name)).length >= 2), `seed ${seedValue}: every admin center has a valid name`);
|
||||
assert(seeded.entitiesForNames.some((item) => item.kind === "Municipal Center"), `seed ${seedValue}: admin labels are included in label candidates`);
|
||||
assert(seeded.adminCenters.every((item) => !(item.representativeFeatureName && String(item.name).startsWith(item.representativeFeatureName) && Array.from(String(item.name)).length === Array.from(String(item.representativeFeatureName)).length + 1)), `seed ${seedValue}: no dangling one-kanji admin suffix fallback`);
|
||||
assert(seeded.nameDebug?.oneKanjiAppendFallbackUsed === 0, `seed ${seedValue}: one-kanji append fallback stays unused`);
|
||||
assert((seeded.nameDebug?.derivedNameCount || 0) === 0, `seed ${seedValue}: derived suffix names stay disabled`);
|
||||
const seededAdminMetrics = adminBoundaryMetrics(seeded);
|
||||
const debug = seeded.adminDebug || {};
|
||||
assert(seededAdminMetrics.municipalityCount >= 18 && seededAdminMetrics.municipalityCount <= 50, `seed ${seedValue}: municipality count stays in target range`);
|
||||
assert(debug.naturalCompartmentCount >= debug.actualMunicipalityCount * 2.5, `seed ${seedValue}: natural compartments are substantially finer than municipalities`);
|
||||
assert(debug.naturalCompartmentCount <= debug.actualMunicipalityCount * 10, `seed ${seedValue}: natural compartments do not become noisy cells`);
|
||||
assert(debug.averageCompartmentsPerMunicipality >= 2.5, `seed ${seedValue}: municipalities group multiple compartments on average`);
|
||||
assert(debug.singleCompartmentMunicipalityRatio < 0.35, `seed ${seedValue}: one-compartment municipalities are uncommon`);
|
||||
assert(debug.seedCellRevivalCount === 0, `seed ${seedValue}: seed cells are not revived after absorption`);
|
||||
assert(debug.finalMunicipalityCount >= 18 && debug.finalMunicipalityCount <= 50, `seed ${seedValue}: final municipality count remains bounded`);
|
||||
assert(debug.finalTinyMunicipalityCount <= Math.max(3, Math.ceil(debug.finalMunicipalityCount * 0.16)), `seed ${seedValue}: tiny municipalities stay uncommon`);
|
||||
assert(debug.pendingSeedsUsedForLowlandSplit > 0 || debug.absorbedSeedCount > 0 || debug.finalMunicipalityCount >= Math.min(20, debug.targetMunicipalityCount), `seed ${seedValue}: pending seeds are either used for lowland splits or absorbed`);
|
||||
assert((debug.seedLifecycle || []).every((seed) => seed.state !== "absorbed" || !seed.protected), `seed ${seedValue}: protected seeds are not absorbed`);
|
||||
const highMountainSeeds = (seeded.adminCenters || []).filter((p) => {
|
||||
const i = indexOf(p.x, p.y);
|
||||
return seeded.elevation[i] > 0.70 || seeded.slope[i] > 0.52 || seeded.ridgeField[i] > 0.62;
|
||||
}).length;
|
||||
assert(highMountainSeeds <= Math.max(2, Math.ceil((seeded.adminCenters || []).length * 0.12)), `seed ${seedValue}: high mountain admin seeds are rare`);
|
||||
assert(seededAdminMetrics.avgTarget > 0.13, `seed ${seedValue}: municipal borders beat a loose lowland-random barrier baseline`);
|
||||
assert(seededAdminMetrics.denseUrbanRate < 0.52, `seed ${seedValue}: dense urban boundary crossing rate remains low`);
|
||||
assert(seededAdminMetrics.voronoiLikeRate < 0.66, `seed ${seedValue}: Voronoi-like municipal borders do not dominate`);
|
||||
}
|
||||
const deterministicSeedMapsA = [114514, 12345, 54321, 777, 999].map((seedValue) => generateMap(seedValue));
|
||||
const deterministicSeedMapsB = [114514, 12345, 54321, 777, 999].map((seedValue) => generateMap(seedValue));
|
||||
for (let n = 0; n < deterministicSeedMapsA.length; n++) {
|
||||
assert(JSON.stringify([...deterministicSeedMapsA[n].adminId]) === JSON.stringify([...deterministicSeedMapsB[n].adminId]), `seed ${[114514, 12345, 54321, 777, 999][n]}: adminId is deterministic`);
|
||||
assert(JSON.stringify(deterministicSeedMapsA[n].adminDebug) === JSON.stringify(deterministicSeedMapsB[n].adminDebug), `seed ${[114514, 12345, 54321, 777, 999][n]}: admin debug metrics are deterministic`);
|
||||
}
|
||||
const byDeposition = capitalNameMaps
|
||||
.map((seeded) => ({ deposition: seeded.terrainTemplate.deposition, lowlandRatio: terrainCoreMetrics(seeded).lowlandRatio }))
|
||||
.sort((a, b) => a.deposition - b.deposition);
|
||||
assert(byDeposition[byDeposition.length - 1].lowlandRatio >= byDeposition[0].lowlandRatio * 0.72, "higher-deposition templates generally preserve or expand lowland area");
|
||||
|
||||
CUSTOM_NAMES["city-0"] = "C1";
|
||||
const customSameA = generateMap(321);
|
||||
|
|
@ -288,13 +766,44 @@ try {
|
|||
assert(!FORCED_NAMES["city-0"] && customTargets.length > 0 && customHits < customTargets.length, "CUSTOM_NAMES do not force every seed");
|
||||
delete CUSTOM_NAMES["city-0"];
|
||||
|
||||
CUSTOM_NAMES["custom-probe"] = "C1";
|
||||
const directCustomNames = Array.from({ length: 40 }, (_, n) => generateEntityName(9000 + n, "custom-probe", { x: 10, y: 10, kind: "Probe" }, {}, new Set()));
|
||||
const directCustomHits = directCustomNames.filter((name) => name === "C1").length;
|
||||
assert(NAME_PROBABILITIES.customName > 0 && NAME_PROBABILITIES.customName < 1 && directCustomHits > 0 && directCustomHits < directCustomNames.length, "CUSTOM_NAMES are probabilistic suggestions");
|
||||
delete CUSTOM_NAMES["custom-probe"];
|
||||
|
||||
FORCED_NAMES["forced-probe"] = "F1";
|
||||
assert(generateEntityName(123, "forced-probe", { x: 8, y: 8, kind: "Probe" }, {}, new Set(), map.nameDebug) === "F1", "FORCED_NAMES always apply");
|
||||
delete FORCED_NAMES["forced-probe"];
|
||||
|
||||
for (const seed of [101, 2026, 54321]) {
|
||||
const seeded = generateMap(seed);
|
||||
const metrics = adminBoundaryMetrics(seeded);
|
||||
const seededRegional = regionalComponentMetrics(seeded);
|
||||
const seededSatellites = satelliteMunicipalityMetrics(seeded);
|
||||
const invalidLandCells = [...seeded.adminId].filter((id, i) => seeded.prefectureMask[i] && !seeded.sea[i] && id < 0).length;
|
||||
const invalidRegionCells = [...seeded.prefectureRegionId].filter((id, i) => !seeded.sea[i] && id < 0).length;
|
||||
assert(invalidLandCells === 0, `seed ${seed}: every prefecture land cell has a valid adminId`);
|
||||
assert(invalidRegionCells === 0, `seed ${seed}: every regional land cell has a valid regionId`);
|
||||
assert(seeded.adminBorders.length > 0, `seed ${seed}: municipal borders exist`);
|
||||
assert(metrics.municipalityCount >= 8, `seed ${seed}: municipality count remains reasonable`);
|
||||
assert(seeded.regionalPrefectureBorders.length > 0, `seed ${seed}: regional prefecture borders exist`);
|
||||
assert(seeded.regionalDebug?.regionalChangedAfterNaturalPartition > 0, `seed ${seed}: regional natural partition changes cells`);
|
||||
assert(seeded.regionalDebug.regionalNaturalBarrierAverageAfter >= seeded.regionalDebug.regionalNaturalBarrierAverageBefore - 0.10, `seed ${seed}: regional border natural affinity is stable`);
|
||||
assert(seeded.regionalDebug.regionalVoronoiLikeRateAfter <= seeded.regionalDebug.regionalVoronoiLikeRateBefore + 0.25, `seed ${seed}: regional Voronoi-like rate is bounded`);
|
||||
assert(seededRegional.maxComponents <= 5, `seed ${seed}: regional regions remain connected enough`);
|
||||
assert(seeded.adminDebug && seeded.adminDebug.compartmentCount > 0, `seed ${seed}: natural compartments are built`);
|
||||
assert(seeded.adminDebug.naturalCompartmentCount > metrics.municipalityCount, `seed ${seed}: compartments are finer than municipalities`);
|
||||
assert(seeded.adminDebug.targetMunicipalityCount >= 20 && seeded.adminDebug.actualMunicipalityCount >= 18, `seed ${seed}: municipality count is dense enough`);
|
||||
assert(seeded.adminDebug.seedCellRevivalCount === 0, `seed ${seed}: seed cell revival stays disabled`);
|
||||
assert(seeded.adminDebug.finalTinyMunicipalityCount <= Math.max(3, Math.ceil(seeded.adminDebug.finalMunicipalityCount * 0.18)), `seed ${seed}: tiny final municipalities are limited`);
|
||||
assert(seeded.adminDebug.changedAfterCompartmentAssignment > 0, `seed ${seed}: compartment assignment is not a no-op`);
|
||||
assert(Array.isArray(seeded.adminDebug.compartmentBorders) && seeded.adminDebug.compartmentBorders.length > seeded.adminBorders.length, `seed ${seed}: compartment border debug exists`);
|
||||
assert(seeded.regionalDebug?.changedAfterCompartmentAssignment > 0, `seed ${seed}: regional compartment assignment changes cells`);
|
||||
assert(seeded.regionalDebug?.borderNaturalBarrierAverage > 0.12, `seed ${seed}: regional borders have natural barrier affinity`);
|
||||
assert(seeded.adminDebug.changedAfterCompartmentAssignment > 0 || seeded.adminDebug.changedAfterLandscapePartition > 0 || seeded.adminDebug.changedAfterSnap > 0, `seed ${seed}: municipal compartment or terrain passes change cells`);
|
||||
assert(seededSatellites.largeTooSmall.length === 0, `seed ${seed}: large satellites are not tiny independent municipalities`);
|
||||
assert(seededSatellites.independent.length < 3 || seededSatellites.small.length / seededSatellites.independent.length <= 0.35, `seed ${seed}: tiny satellite municipalities remain uncommon`);
|
||||
assert(metrics.municipalityCount >= 18, `seed ${seed}: municipality count remains reasonable`);
|
||||
assert(metrics.centerValidRatio >= 0.90, `seed ${seed}: municipality centers remain valid`);
|
||||
assert(metrics.maxComponents <= 5, `seed ${seed}: topology repair limits disconnected fragments`);
|
||||
assert(metrics.avgTarget > 0.14, `seed ${seed}: borders retain terrain-boundary affinity`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue