Compare commits
No commits in common. "3f2be9639579b3b2145a47a6648d5a9b46dca2c4" and "db8265384cbd8a09dd964178235bb40f44baf534" have entirely different histories.
3f2be96395
...
db8265384c
21 changed files with 3678 additions and 7120 deletions
744
adminRegions.js
744
adminRegions.js
|
|
@ -1,4 +1,6 @@
|
|||
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, indexOf, inside, weightedScore, xyOf } from "./mapUtils.js";
|
||||
import { MinHeap } from "./graph.js";
|
||||
import { INF, MAP_H, MAP_W, SIZE, clamp, indexOf, inside, xyOf } from "./grid.js";
|
||||
import { weightedScore } from "./scoring.js";
|
||||
|
||||
function neighbors8(x, y) {
|
||||
const out = [];
|
||||
|
|
@ -156,31 +158,12 @@ export function lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask,
|
|||
}
|
||||
}
|
||||
|
||||
export function mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities = [], minArea = 320, options = {}) {
|
||||
export function mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities = [], minArea = 320) {
|
||||
const area = new Map();
|
||||
const pop = new Map();
|
||||
const adjacency = new Map();
|
||||
const cityMunicipalities = new Set();
|
||||
for (const city of modernCities || []) {
|
||||
if (!inside(city.x, city.y)) continue;
|
||||
const id = adminId[indexOf(city.x, city.y)];
|
||||
if (id < 0) continue;
|
||||
if (options.protectAllModernCities !== false || city.isPrefecturalCapital || (city.population || 0) >= (options.majorCityPopulationThreshold || 120000)) cityMunicipalities.add(id);
|
||||
}
|
||||
for (const point of options.protectedPoints || []) {
|
||||
if (!point || !inside(point.x, point.y)) continue;
|
||||
const id = adminId[indexOf(point.x, point.y)];
|
||||
if (id >= 0) cityMunicipalities.add(id);
|
||||
}
|
||||
const satelliteByAdmin = new Map();
|
||||
for (const sat of options.satelliteCities || []) {
|
||||
if (!sat || !inside(sat.x, sat.y)) continue;
|
||||
const id = adminId[indexOf(sat.x, sat.y)];
|
||||
if (id < 0) continue;
|
||||
if (!satelliteByAdmin.has(id)) satelliteByAdmin.set(id, []);
|
||||
satelliteByAdmin.get(id).push(sat);
|
||||
}
|
||||
const satelliteStats = options.satelliteStats || null;
|
||||
for (const city of modernCities || []) if (inside(city.x, city.y)) cityMunicipalities.add(adminId[indexOf(city.x, city.y)]);
|
||||
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
|
|
@ -206,35 +189,16 @@ 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 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;
|
||||
const candidate = border * 3 + (area.get(other) || 0) * 0.012 + (pop.get(other) || 0) * 0.24;
|
||||
if (candidate > bestScore) { bestScore = candidate; bestNeighbor = other; }
|
||||
}
|
||||
if (bestNeighbor >= 0 && (area.get(bestNeighbor) || 0) >= score * 0.35) {
|
||||
mergeTarget.set(id, bestNeighbor);
|
||||
if (satelliteStats && satellites.length) {
|
||||
satelliteStats.satelliteMunicipalitiesMerged += satellites.length;
|
||||
for (const sat of satellites) sat.mergedMunicipalityTarget = bestNeighbor;
|
||||
}
|
||||
}
|
||||
if (bestNeighbor >= 0 && (area.get(bestNeighbor) || 0) >= score * 0.35) mergeTarget.set(id, bestNeighbor);
|
||||
}
|
||||
if (mergeTarget.size === 0) return;
|
||||
for (let i = 0; i < SIZE; i++) if (mergeTarget.has(adminId[i])) adminId[i] = mergeTarget.get(adminId[i]);
|
||||
|
|
@ -503,254 +467,6 @@ 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);
|
||||
|
|
@ -845,338 +561,52 @@ function mergeTinyLandscapeUnits(unitId, units, minArea = 10) {
|
|||
}
|
||||
}
|
||||
|
||||
function naturalOwnershipAffinity(unit, neighbor, edge) {
|
||||
function landscapeTransitionCost(a, b, edge) {
|
||||
const boundaryTarget = edge.target / Math.max(1, edge.count);
|
||||
const sameClass = unit.classId === neighbor.classId ? 1.0 : 0;
|
||||
const sameGroup = naturalGroupKey(unit) === naturalGroupKey(neighbor) ? 1.1 : 0;
|
||||
const bothUrban = unit.classId <= 3 && neighbor.classId <= 3;
|
||||
const bothCorridor = [5, 6, 7, 10].includes(unit.classId) && [5, 6, 7, 10].includes(neighbor.classId);
|
||||
const urbanContinuity = bothUrban ? 1.35 : (unit.urbanWeight + neighbor.urbanWeight) > 0.75 && Math.abs(unit.urbanWeight - neighbor.urbanWeight) < 0.35 ? 0.58 : 0;
|
||||
const strongDividerPenalty = boundaryTarget * (edge.count > 2 ? 2.8 : 1.8);
|
||||
return edge.count * 0.55 + sameClass + sameGroup + urbanContinuity + (bothCorridor ? 0.72 : 0) - strongDividerPenalty;
|
||||
}
|
||||
|
||||
function compartmentCrossingCost(unit, neighbor, edge) {
|
||||
const boundaryScore = edge.target / Math.max(1, edge.count);
|
||||
const sameClass = unit.classId === neighbor.classId ? 1 : 0;
|
||||
const sameGroup = naturalGroupKey(unit) === naturalGroupKey(neighbor) ? 1 : 0;
|
||||
const lowlandContinuity = Math.min(unit.lowlandFitness || 0, neighbor.lowlandFitness || 0);
|
||||
const urbanContinuity = Math.min(unit.urbanWeight || 0, neighbor.urbanWeight || 0);
|
||||
const mountainPenalty = Math.max(unit.mountainFitness || 0, neighbor.mountainFitness || 0);
|
||||
const ridgePenalty = Math.max(unit.ridgeExposure || 0, neighbor.ridgeExposure || 0);
|
||||
return Math.max(0.18,
|
||||
1.0 +
|
||||
boundaryScore * 5.2 +
|
||||
mountainPenalty * 1.8 +
|
||||
ridgePenalty * 0.9 -
|
||||
sameClass * 0.45 -
|
||||
sameGroup * 0.35 -
|
||||
lowlandContinuity * 1.15 -
|
||||
urbanContinuity * 0.70 -
|
||||
Math.min(1.0, edge.count / 12) * 0.25
|
||||
);
|
||||
}
|
||||
|
||||
function graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options = {}) {
|
||||
const owner = new Int16Array(compartments.length);
|
||||
const dist = new Float32Array(compartments.length);
|
||||
owner.fill(-1);
|
||||
dist.fill(INF);
|
||||
const heap = new MinHeap();
|
||||
for (let id = 0; id < adminCenters.length; id++) {
|
||||
const center = adminCenters[id];
|
||||
if (!center || !inside(center.x, center.y)) continue;
|
||||
const compIndex = compartmentId[indexOf(center.x, center.y)];
|
||||
const unit = compartments[compIndex];
|
||||
if (compIndex < 0 || !unit || unit.area === 0) continue;
|
||||
unit.centerIds.push(id);
|
||||
if (dist[compIndex] > 0) {
|
||||
dist[compIndex] = 0;
|
||||
owner[compIndex] = id;
|
||||
heap.push({ i: compIndex, f: 0, owner: id });
|
||||
}
|
||||
}
|
||||
while (heap.length > 0) {
|
||||
const cur = heap.pop();
|
||||
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
|
||||
const unit = compartments[cur.i];
|
||||
if (!unit || unit.area === 0) continue;
|
||||
const center = adminCenters[cur.owner];
|
||||
for (const [neighborId, edge] of unit.adjacent) {
|
||||
const neighbor = compartments[neighborId];
|
||||
if (!neighbor || neighbor.area === 0) continue;
|
||||
const crossing = compartmentCrossingCost(unit, neighbor, edge);
|
||||
const euclideanTie = center ? Math.hypot(neighbor.x - center.x, neighbor.y - center.y) * 0.006 : 0;
|
||||
const hinterlandDrag = (neighbor.mountainFitness || 0) > 0.64 && (neighbor.population || 0) < 6 ? 0.35 : 0;
|
||||
const next = cur.f + crossing + euclideanTie + hinterlandDrag;
|
||||
if (next + 1e-5 < dist[neighborId]) {
|
||||
dist[neighborId] = next;
|
||||
owner[neighborId] = cur.owner;
|
||||
heap.push({ i: neighborId, f: next, owner: cur.owner });
|
||||
} else if (Math.abs(next - dist[neighborId]) < 0.08 && owner[neighborId] >= 0) {
|
||||
const oldCenter = adminCenters[owner[neighborId]];
|
||||
const oldD = oldCenter ? Math.hypot(neighbor.x - oldCenter.x, neighbor.y - oldCenter.y) : INF;
|
||||
const newD = center ? Math.hypot(neighbor.x - center.x, neighbor.y - center.y) : INF;
|
||||
if (newD < oldD - 1.5 || (newD < oldD + 1.5 && cur.owner < owner[neighborId])) owner[neighborId] = cur.owner;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const unit of compartments) {
|
||||
if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue;
|
||||
let bestOwner = -1, bestScore = INF;
|
||||
for (const [neighborId, edge] of unit.adjacent) {
|
||||
if (owner[neighborId] < 0) continue;
|
||||
const neighbor = compartments[neighborId];
|
||||
const score = compartmentCrossingCost(unit, neighbor, edge) + (neighbor?.area || 0) * -0.001;
|
||||
if (score < bestScore) { bestScore = score; bestOwner = owner[neighborId]; }
|
||||
}
|
||||
owner[unit.id] = bestOwner >= 0 ? bestOwner : 0;
|
||||
}
|
||||
return owner;
|
||||
}
|
||||
|
||||
function compartmentMunicipalityMetrics(compartments, owner, targetMunicipalityCount = 0, targetCompartmentCount = 0) {
|
||||
const counts = new Map();
|
||||
let active = 0;
|
||||
for (const unit of compartments) {
|
||||
if (!unit || unit.area === 0) continue;
|
||||
active++;
|
||||
const id = owner[unit.id];
|
||||
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
|
||||
}
|
||||
const actual = counts.size;
|
||||
const singles = [...counts.values()].filter((value) => value === 1).length;
|
||||
return {
|
||||
targetMunicipalityCount,
|
||||
actualMunicipalityCount: actual,
|
||||
targetNaturalCompartmentCount: targetCompartmentCount,
|
||||
naturalCompartmentCount: active,
|
||||
compartmentCount: active,
|
||||
averageCompartmentsPerMunicipality: actual ? active / actual : 0,
|
||||
singleCompartmentMunicipalityRatio: actual ? singles / actual : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore) {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (let y = 1; y < MAP_H - 1; y++) {
|
||||
for (let x = 1; x < MAP_W - 1; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
|
||||
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (!prefectureMask[ni] || sea[ni] || adminId[ni] < 0 || adminId[ni] === adminId[i]) continue;
|
||||
sum += (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count ? sum / count : 0;
|
||||
}
|
||||
|
||||
function assignCompartmentsToAdminOwners(compartments, compartmentId, adminCenters, naturalBarrierScore, prefectureMask, sea) {
|
||||
const owner = new Int16Array(compartments.length);
|
||||
owner.fill(-1);
|
||||
for (let id = 0; id < adminCenters.length; id++) {
|
||||
const center = adminCenters[id];
|
||||
if (!center || !inside(center.x, center.y)) continue;
|
||||
const compIndex = compartmentId[indexOf(center.x, center.y)];
|
||||
if (compIndex >= 0 && compartments[compIndex]?.area > 0) {
|
||||
const unit = compartments[compIndex];
|
||||
unit.centerIds.push(id);
|
||||
owner[compIndex] = id;
|
||||
}
|
||||
}
|
||||
|
||||
for (let pass = 0; pass < compartments.length + 8; pass++) {
|
||||
let changed = 0;
|
||||
for (const unit of compartments) {
|
||||
if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue;
|
||||
let bestOwner = -1;
|
||||
let bestScore = -INF;
|
||||
for (const [neighborId, edge] of unit.adjacent) {
|
||||
const neighborOwner = owner[neighborId];
|
||||
if (neighborOwner < 0) continue;
|
||||
const neighbor = compartments[neighborId];
|
||||
if (!neighbor || neighbor.area === 0) continue;
|
||||
const center = adminCenters[neighborOwner];
|
||||
const d = center ? Math.hypot(unit.x - center.x, unit.y - center.y) : 0;
|
||||
const score = naturalOwnershipAffinity(unit, neighbor, edge) - d * 0.006 + Math.min(0.9, Math.sqrt(Math.max(1, neighbor.area)) * 0.020);
|
||||
if (score > bestScore) { bestScore = score; bestOwner = neighborOwner; }
|
||||
}
|
||||
const accept = unit.classId <= 3 ? bestScore > -0.35 : unit.classId === 8 || unit.classId === 9 ? bestScore > -1.05 : bestScore > -0.70;
|
||||
if (bestOwner >= 0 && accept) {
|
||||
owner[unit.id] = bestOwner;
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
if (changed === 0) break;
|
||||
}
|
||||
|
||||
for (const unit of compartments) {
|
||||
if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue;
|
||||
let bestId = -1;
|
||||
let bestScore = -INF;
|
||||
for (let id = 0; id < adminCenters.length; id++) {
|
||||
const center = adminCenters[id];
|
||||
if (!center || !inside(center.x, center.y)) continue;
|
||||
const centerComp = compartments[compartmentId[indexOf(center.x, center.y)]];
|
||||
const sameGroup = centerComp && naturalGroupKey(centerComp) === naturalGroupKey(unit) ? 2.3 : 0;
|
||||
const sameClass = centerComp && centerComp.classId === unit.classId ? 0.8 : 0;
|
||||
const urbanFit = unit.urbanWeight > 0.55 && centerComp?.urbanWeight > 0.55 ? 1.3 : 0;
|
||||
const d = Math.hypot(unit.x - center.x, unit.y - center.y);
|
||||
const score = sameGroup + sameClass + urbanFit - d * 0.020 - unit.ridgeExposure * 0.16;
|
||||
if (score > bestScore) { bestScore = score; bestId = id; }
|
||||
}
|
||||
owner[unit.id] = bestId >= 0 ? bestId : 0;
|
||||
}
|
||||
return owner;
|
||||
}
|
||||
|
||||
export function extractCompartmentBorders(compartmentId, prefectureMask, sea) {
|
||||
const segments = [];
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (!prefectureMask[i] || sea[i] || compartmentId[i] < 0) continue;
|
||||
const a = compartmentId[i];
|
||||
if (x + 1 < MAP_W) {
|
||||
const ni = indexOf(x + 1, y);
|
||||
if (prefectureMask[ni] && !sea[ni] && compartmentId[ni] >= 0 && compartmentId[ni] !== a) segments.push([[x + 1, y], [x + 1, y + 1]]);
|
||||
}
|
||||
if (y + 1 < MAP_H) {
|
||||
const ni = indexOf(x, y + 1);
|
||||
if (prefectureMask[ni] && !sea[ni] && compartmentId[ni] >= 0 && compartmentId[ni] !== a) segments.push([[x, y + 1], [x + 1, y + 1]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], options = {}) {
|
||||
const { 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;
|
||||
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));
|
||||
}
|
||||
|
||||
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;
|
||||
const { unitId, units, targetScore } = buildLandscapeUnits(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse);
|
||||
if (units.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)]];
|
||||
const unit = units[unitId[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) {
|
||||
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;
|
||||
owner[unit.id] = unit.centerIds[0];
|
||||
const id = unit.centerIds[0];
|
||||
owner[unit.id] = id; dist[unit.id] = 0; heap.push({ i: unit.id, f: 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; }
|
||||
while (heap.length) {
|
||||
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 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) {
|
||||
for (const unit of units) {
|
||||
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;
|
||||
|
|
@ -1188,18 +618,7 @@ export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, e
|
|||
if (prefectureMask[i] && !sea[i]) adminId[i] = id;
|
||||
}
|
||||
}
|
||||
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse);
|
||||
let changedCells = 0;
|
||||
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== adminId[i]) changedCells++;
|
||||
const activeCompartments = compartments.filter((unit) => unit.area > 0);
|
||||
applyLandscapeUnitAdminPartition.lastDebug = {
|
||||
compartmentCount: activeCompartments.length,
|
||||
averageCompartmentArea: activeCompartments.length ? activeCompartments.reduce((sum, unit) => sum + unit.area, 0) / activeCompartments.length : 0,
|
||||
changedAfterNaturalCompartmentPartition: changedCells,
|
||||
finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore),
|
||||
voronoiLikeRateBefore: beforeVoronoiLikeRate,
|
||||
voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore),
|
||||
};
|
||||
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, targetScore, populationDensity, landuse);
|
||||
}
|
||||
|
||||
export function snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse, adminCenters = [], protectedPoints = [], passes = 6) {
|
||||
|
|
@ -1242,94 +661,3 @@ 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,9 +11,6 @@ const modes = [
|
|||
["development", "Development"],
|
||||
["landuse", "Land Use"],
|
||||
["admin", "Municipal Borders"],
|
||||
["terrain-debug", "Terrain Debug"],
|
||||
["admin-debug", "Admin Debug"],
|
||||
["borders-debug", "Borders Debug"],
|
||||
];
|
||||
|
||||
const state = {
|
||||
|
|
@ -79,8 +76,6 @@ 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 ?? "-"],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
17
entitySelection.js
Normal file
17
entitySelection.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
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
Normal file
28
fields.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
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
Normal file
34
graph.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
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
Normal file
26
grid.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
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
878
mapAdminStage.js
|
|
@ -1,878 +0,0 @@
|
|||
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
1521
mapFeatures.js
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
|
|
@ -1,972 +0,0 @@
|
|||
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
292
mapOutput.js
|
|
@ -1,292 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
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
1329
mapTerrain.js
File diff suppressed because it is too large
Load diff
175
mapUtils.js
175
mapUtils.js
|
|
@ -1,175 +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;
|
||||
}
|
||||
|
||||
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,114 +1,16 @@
|
|||
import { MAP_H, MAP_W, hash2, indexOf, inside } from "./mapUtils.js";
|
||||
import { MAP_H, MAP_W, indexOf, inside } from "./grid.js";
|
||||
import { hash2 } from "./random.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 = {
|
||||
|
|
@ -407,13 +309,9 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -519,7 +417,6 @@ 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;
|
||||
}
|
||||
|
|
@ -552,10 +449,7 @@ export function generateEntityName(seed, id, entity, fields, usedNames = null, d
|
|||
continue;
|
||||
}
|
||||
if (result.invalidReason) {
|
||||
if (result.invalidReason === "oneCharacter") {
|
||||
debug.oneCharacterNamesPrevented++;
|
||||
debug.rejectedOneCharacterNames++;
|
||||
}
|
||||
if (result.invalidReason === "oneCharacter") debug.oneCharacterNamesPrevented++;
|
||||
else debug.invalidNamesRejected++;
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
141
qualityMetrics.js
Normal file
141
qualityMetrics.js
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
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
Normal file
51
random.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
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;
|
||||
}
|
||||
784
renderer.js
784
renderer.js
|
|
@ -1,258 +1,5 @@
|
|||
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)]);
|
||||
}
|
||||
|
||||
import { MAP_W, MAP_H, CELL_SIZE, indexOf } from "./mapGenerator.js";
|
||||
import { clamp } from "./grid.js";
|
||||
|
||||
function distToNearest(points, x, y, fallback = 999) {
|
||||
let best = fallback;
|
||||
|
|
@ -263,21 +10,19 @@ function distToNearest(points, x, y, fallback = 999) {
|
|||
function blendOutside(color, isInside) {
|
||||
if (isInside) return color;
|
||||
return [
|
||||
Math.round(color[0] * 0.8 + 50),
|
||||
Math.round(color[1] * 0.8 + 50),
|
||||
Math.round(color[2] * 0.8 + 50),
|
||||
Math.round(color[0] * 0.55 + 112),
|
||||
Math.round(color[1] * 0.55 + 112),
|
||||
Math.round(color[2] * 0.55 + 112),
|
||||
];
|
||||
}
|
||||
|
||||
function fieldSample(field, fx, 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 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 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 = sx - x0;
|
||||
const ty = sy - y0;
|
||||
const tx = fx - x0;
|
||||
const ty = fy - y0;
|
||||
|
||||
const a = field[indexOf(x0, y0)];
|
||||
const b = field[indexOf(x1, y0)];
|
||||
|
|
@ -288,42 +33,53 @@ function fieldSample(field, fx, fy) {
|
|||
}
|
||||
|
||||
function terrainColorContinuous(map, fx, fy, mode) {
|
||||
const i = sampleCellIndex(fx, fy);
|
||||
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 isInside = Boolean(map.prefectureMask[i]);
|
||||
|
||||
let color;
|
||||
|
||||
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)];
|
||||
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)];
|
||||
} 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(240 - p * 15 + f * 10),
|
||||
Math.round(242 + a * 10),
|
||||
Math.round(235 - a * 15 + p * 10),
|
||||
Math.round(230 - p * 25 + f * 15),
|
||||
Math.round(235 + a * 15),
|
||||
Math.round(210 - a * 25 + p * 20),
|
||||
];
|
||||
} 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 base = 235;
|
||||
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;
|
||||
color = [
|
||||
Math.round(base + density * 20),
|
||||
Math.round(base + density * 5),
|
||||
Math.round(230 + density * 10),
|
||||
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),
|
||||
];
|
||||
} else {
|
||||
// 地形色を少し濃く(暗く)調整
|
||||
// 起伏の大きさが読めるよう、標高段彩をやや強める
|
||||
const e = fieldSample(map.elevation, fx, fy);
|
||||
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];
|
||||
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];
|
||||
}
|
||||
|
||||
return blendOutside(color, isInside);
|
||||
|
|
@ -334,31 +90,39 @@ 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: [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],
|
||||
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
|
||||
};
|
||||
color = colors[map.landuse[i]] || colors[0];
|
||||
} else if (mode === "admin") {
|
||||
const palette = [
|
||||
[250, 245, 242], [245, 250, 245], [245, 245, 252],
|
||||
[252, 250, 242], [250, 245, 250], [242, 250, 250]
|
||||
[245, 235, 230],
|
||||
[235, 245, 235],
|
||||
[240, 240, 250],
|
||||
[250, 245, 230],
|
||||
[245, 240, 248],
|
||||
[230, 245, 245],
|
||||
[250, 240, 240],
|
||||
[240, 250, 235],
|
||||
];
|
||||
const a = map.adminId[i];
|
||||
color = a >= 0 ? palette[a % palette.length] : [240, 242, 240];
|
||||
color = a >= 0 ? palette[a % palette.length] : [220, 225, 220];
|
||||
} else {
|
||||
color = terrainColorContinuous(map, x, y, "terrain");
|
||||
}
|
||||
|
||||
return blendOutside(color, Boolean(map.prefectureMask[i]));
|
||||
}
|
||||
|
||||
|
|
@ -379,12 +143,17 @@ 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.95 + (eR - eL) * 0.6 + (eD - eU) * 0.4, 0.85, 1.08);
|
||||
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 ii = (py * width + px) * 4;
|
||||
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] = 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 + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
|
@ -404,77 +173,93 @@ function drawBase(ctx, map, mode, continuousTerrain) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(img, 0, 0);
|
||||
}
|
||||
|
||||
function drawPath(ctx, path, color, width, dashed = false) {
|
||||
const points = vectorPath(path);
|
||||
if (points.length < 2) return;
|
||||
if (!path || path.length < 2) return;
|
||||
ctx.save();
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = width;
|
||||
if (dashed) ctx.setLineDash([8, 6]);
|
||||
if (dashed) ctx.setLineDash([6, 5]);
|
||||
ctx.beginPath();
|
||||
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.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);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawSegments(ctx, segments, color, width, dashed = false) {
|
||||
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) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = width;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
if (dashed) ctx.setLineDash([6, 5]);
|
||||
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;
|
||||
}
|
||||
|
||||
for (const seg of segments) {
|
||||
ctx.beginPath();
|
||||
|
|
@ -489,14 +274,15 @@ function drawUrbanAreas(ctx, map, mode) {
|
|||
const visibleModes = ["all", "modern", "development", "landuse", "roads", "admin"];
|
||||
if (!visibleModes.includes(mode)) return;
|
||||
|
||||
// Google Map風の都市部の色
|
||||
const colors = {
|
||||
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)",
|
||||
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)", // 沿道 - クリーム
|
||||
};
|
||||
|
||||
ctx.save();
|
||||
|
|
@ -511,22 +297,38 @@ function drawUrbanAreas(ctx, map, mode) {
|
|||
const py = y * CELL_SIZE;
|
||||
ctx.fillStyle = colors[lu];
|
||||
ctx.fillRect(px, py, CELL_SIZE, CELL_SIZE);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
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);
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
|
|
@ -537,41 +339,91 @@ 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.2;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function boxesOverlap(a, b, pad = 3) {
|
||||
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) {
|
||||
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 = "600 11.5px ui-sans-serif, system-ui, -apple-system, sans-serif";
|
||||
ctx.font = "11px ui-sans-serif, system-ui, 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, -4], [7, 13], [-textW - 7, -4], [-textW - 7, 13],
|
||||
[-textW / 2, -12], [-textW / 2, 20], [12, 4], [-textW - 12, 4],
|
||||
[7, -5], [7, 12], [-textW - 7, -5], [-textW - 7, 12],
|
||||
[-textW / 2, -13], [-textW / 2, 20], [12, 2], [-textW - 12, 2],
|
||||
];
|
||||
|
||||
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 + 4 };
|
||||
const box = { x1: x - 2, y1: y - textH, x2: x + textW + 2, y2: y + 3 };
|
||||
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.lineJoin = "round";
|
||||
ctx.lineWidth = 3.5;
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.95)";
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.95)";
|
||||
ctx.fillStyle = "rgba(40,40,40,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();
|
||||
|
|
@ -585,7 +437,7 @@ function drawLabels(ctx, points, limit = Infinity) {
|
|||
const occupied = [];
|
||||
const prioritized = points
|
||||
.filter((p) => p?.name)
|
||||
.map((p) => ({ ...p, labelPriority: (p.labelPriorityBase || 0) + (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 900 + (p.portClass === "major" ? 170 : p.portClass === "regional" ? 95 : 0) }))
|
||||
.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) }))
|
||||
.sort((a, b) => b.labelPriority - a.labelPriority);
|
||||
for (const p of prioritized.slice(0, limit)) labelWithCollision(ctx, p, occupied);
|
||||
}
|
||||
|
|
@ -597,120 +449,102 @@ 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;
|
||||
|
||||
// 1. Base Terrain & Urban
|
||||
drawBase(ctx, map, mode, true);
|
||||
drawBase(ctx, map, mode, continuousTerrain);
|
||||
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 });
|
||||
|
||||
// 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);
|
||||
// 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);
|
||||
|
||||
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 (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);
|
||||
|
||||
if (!showFeatures) return;
|
||||
|
||||
// 4. Transport casings. Layer order: local roads, trunk roads, railways, expressways.
|
||||
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);
|
||||
|
||||
if (showHistory) {
|
||||
for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(210, 210, 210, 0.7)", 2.5);
|
||||
}
|
||||
if (showMinorRoads) {
|
||||
for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(205, 205, 205, 0.60)", 2.35);
|
||||
}
|
||||
if (showRoads) {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
// 5. Transport fills. Expressways remain above railways; railways sit above ordinary roads.
|
||||
if (showHistory) {
|
||||
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.stations) dot(ctx, p, 2.5, "#fff", "#444");
|
||||
// 鉄道 - 濃いグレー
|
||||
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 (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);
|
||||
}
|
||||
|
||||
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)");
|
||||
}
|
||||
|
||||
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.modernCities) {
|
||||
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)");
|
||||
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)");
|
||||
}
|
||||
}
|
||||
|
||||
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, 60);
|
||||
|
||||
drawLabels(ctx, important, mode === "all" ? 28 : Infinity);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
5
scoring.js
Normal file
5
scoring.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
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,68 +1,11 @@
|
|||
*{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}
|
||||
*{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}}
|
||||
|
||||
@media (max-width:1100px){.layout{grid-template-columns:1fr}}
|
||||
.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}
|
||||
|
||||
.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;}
|
||||
|
||||
/* 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-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}
|
||||
|
||||
.river-major{background:#a0cdf0; border:none; height:3px;}
|
||||
.old-road-line{background:#fff; border:1px solid #dcdcdc; height:3px; border-top:none;}
|
||||
.legend-line.harbor-line::before{background:#6b86a0;height:3px;top:7px}
|
||||
|
||||
.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)}
|
||||
.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)}
|
||||
|
|
|
|||
525
test.js
525
test.js
|
|
@ -7,19 +7,17 @@ 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, mapOutputSource, rendererSource, testSource] = await Promise.all([
|
||||
const [namesSource, mapGeneratorSource, 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()),
|
||||
]);
|
||||
|
||||
|
|
@ -31,352 +29,6 @@ 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);
|
||||
|
|
@ -491,10 +143,6 @@ 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");
|
||||
|
|
@ -503,7 +151,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.every((part) => typeof part === "string" && !part.includes("\uFFFD"))), "configured name category pools contain valid strings");
|
||||
assert(Object.values(NAME_KANJI_POOLS).every((pool) => pool.length === 0), "default name category pools are empty");
|
||||
assert(Object.keys(NAME_PARTS).length === 0, "legacy NAME_PARTS has no hidden candidates");
|
||||
const removedContextSuffixConst = "CONTEXT" + "_SUFFIXES";
|
||||
const removedContextSuffixKey = "context" + "Suffixes";
|
||||
|
|
@ -512,7 +160,6 @@ 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");
|
||||
|
|
@ -520,27 +167,9 @@ 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");
|
||||
|
|
@ -577,44 +206,21 @@ 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 >= 18, "municipality center count is sufficiently large");
|
||||
assert(map.adminCenters.length >= 12, "municipality 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");
|
||||
|
|
@ -637,39 +243,21 @@ 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.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.emptyPools.length === Object.keys(NAME_KANJI_POOLS).length, "empty default pools are visible in nameDebug");
|
||||
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(activePoolChars.size > 0 || generateTemplateName(777, "probe-0", { x: 10, y: 10, kind: "Probe" }, {}, 0, new Set()) === null, "template generation depends on active pools");
|
||||
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(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");
|
||||
|
|
@ -687,72 +275,6 @@ 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);
|
||||
|
|
@ -766,44 +288,13 @@ 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(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.municipalityCount >= 8, `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