Compare commits
6 commits
3f2be96395
...
15c1031d72
| Author | SHA1 | Date | |
|---|---|---|---|
| 15c1031d72 | |||
| f5e7a1df1d | |||
| 1ea8ba1701 | |||
| 84ad22f7af | |||
| 8e1ec7b9ac | |||
| 1be3d86da0 |
19 changed files with 9888 additions and 1672 deletions
548
adminRegions.js
548
adminRegions.js
|
|
@ -409,25 +409,31 @@ function localBoundaryEnergy(labels, i, candidateId, targetScore, centerDist, po
|
|||
if (same4 === 1) energy += 1.7;
|
||||
if (diff4 >= 3 && targetScore[i] < 0.42) energy += 1.25;
|
||||
if (diagDiff >= 3 && diff4 >= 2 && targetScore[i] < 0.50) energy += 0.42;
|
||||
if (candidateId !== oldId && centerDist[candidateId] && centerDist[oldId]) {
|
||||
const drift = centerDist[candidateId][i] - centerDist[oldId][i];
|
||||
if (drift > 0) energy += Math.min(0.9, drift * 0.012);
|
||||
if (candidateId !== oldId) {
|
||||
const candidateDistance = centerDistanceAt(centerDist, candidateId, i);
|
||||
const oldDistance = centerDistanceAt(centerDist, oldId, i);
|
||||
if (Number.isFinite(candidateDistance) && Number.isFinite(oldDistance)) {
|
||||
const drift = candidateDistance - oldDistance;
|
||||
if (drift > 0) energy += Math.min(0.9, drift * 0.012);
|
||||
}
|
||||
}
|
||||
return energy;
|
||||
}
|
||||
|
||||
function centerDistanceAt(centerDist, id, i) {
|
||||
const field = centerDist?.fields?.[id] || centerDist?.[id];
|
||||
if (field) return field[i];
|
||||
const center = centerDist?.centers?.[id];
|
||||
if (!center || !inside(center.x, center.y)) return 24;
|
||||
const [x, y] = xyOf(i);
|
||||
return Math.hypot(x - center.x, y - center.y);
|
||||
}
|
||||
|
||||
function buildCenterDistanceFields(adminIds, adminCenters, prefectureMask, sea) {
|
||||
const fields = [];
|
||||
for (const id of adminIds) {
|
||||
const center = adminCenters[id];
|
||||
const field = new Float32Array(SIZE);
|
||||
if (!center || !inside(center.x, center.y) || sea[indexOf(center.x, center.y)] || !prefectureMask[indexOf(center.x, center.y)]) field.fill(24);
|
||||
else {
|
||||
for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) field[indexOf(x, y)] = Math.hypot(x - center.x, y - center.y);
|
||||
}
|
||||
fields[id] = field;
|
||||
}
|
||||
return fields;
|
||||
// Older versions materialized one full SIZE Float32Array per municipality.
|
||||
// In multi-prefecture generation this can create heavy transient memory use.
|
||||
// Keep the same interface conceptually, but compute distances on demand.
|
||||
return { ids: adminIds, centers: adminCenters, prefectureMask, sea };
|
||||
}
|
||||
|
||||
function repairAdminTopology(adminId, prefectureMask, sea, adminCenters = [], targetScore = null, populationDensity = null, landuse = null) {
|
||||
|
|
@ -558,20 +564,22 @@ function canShareNaturalCompartment(a, b, classA, classB, barrier, river, flowAc
|
|||
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 majorRiverEdge = Math.max(river[a], river[b]) > 0.56 || Math.max(flowAccum[a], flowAccum[b]) > 0.68;
|
||||
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;
|
||||
const valleyContinuity = (valleyField[a] + valleyField[b]) * 0.5 > 0.48 && !majorRiverEdge;
|
||||
const threshold = urbanEdge ? 0.78 : valleyContinuity ? 0.62 : classA === 8 || classB === 8 ? 0.36 : 0.50;
|
||||
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;
|
||||
let minX = MAP_W, minY = MAP_H, maxX = 0, maxY = 0;
|
||||
for (const i of unit.cells) {
|
||||
const [x, y] = xyOf(i);
|
||||
sx += x; sy += y; pop += populationDensity[i];
|
||||
minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
|
||||
urbanWeight += urbanBoundaryPenalty(i, populationDensity, landuse);
|
||||
ridgeExposure += ridgeField[i];
|
||||
coastalExposure += coastalLowland[i];
|
||||
|
|
@ -584,6 +592,13 @@ function refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField
|
|||
unit.area = area;
|
||||
unit.x = sx / Math.max(1, area);
|
||||
unit.y = sy / Math.max(1, area);
|
||||
unit.minX = area ? minX : 0;
|
||||
unit.minY = area ? minY : 0;
|
||||
unit.maxX = area ? maxX : 0;
|
||||
unit.maxY = area ? maxY : 0;
|
||||
unit.width = area ? maxX - minX + 1 : 0;
|
||||
unit.height = area ? maxY - minY + 1 : 0;
|
||||
unit.elongation = Math.max(unit.width, unit.height) / Math.max(1, Math.min(unit.width, unit.height));
|
||||
unit.population = pop;
|
||||
unit.urbanWeight = urbanWeight / Math.max(1, area);
|
||||
unit.ridgeExposure = ridgeExposure / Math.max(1, area);
|
||||
|
|
@ -596,13 +611,25 @@ function refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField
|
|||
}
|
||||
|
||||
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;
|
||||
if (!unit || unit.area < 24) return null;
|
||||
const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum } = fields;
|
||||
const minPart = Math.max(8, Math.min(28, Math.floor(unit.area * 0.20)));
|
||||
|
||||
let first = -1;
|
||||
let second = -1;
|
||||
let bestA = -INF;
|
||||
let bestB = -INF;
|
||||
const width = unit.width || (unit.maxX - unit.minX + 1) || 1;
|
||||
const height = unit.height || (unit.maxY - unit.minY + 1) || 1;
|
||||
const horizontal = width >= height;
|
||||
const elongated = Math.max(width, height) / Math.max(1, Math.min(width, height)) > 1.65;
|
||||
|
||||
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;
|
||||
const settled = populationDensity[i] * 0.28 + ([2, 3, 4, 7, 8].includes(landuse[i]) ? 0.34 : 0);
|
||||
const axis = elongated ? (horizontal ? (unit.maxX - x) / Math.max(1, width) : (unit.maxY - y) / Math.max(1, height)) : 0.0;
|
||||
const score = axis * 1.7 + low * 0.42 + settled + hashSeededTie(x, y, seed) * 0.05 - ridgeField[i] * 0.10;
|
||||
if (score > bestA) { bestA = score; first = i; }
|
||||
}
|
||||
if (first < 0) return null;
|
||||
|
|
@ -610,36 +637,54 @@ function splitOneNaturalCompartment(unit, newId, compartmentId, fields, seed) {
|
|||
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 axis = elongated ? (horizontal ? (x - unit.minX) / Math.max(1, width) : (y - unit.minY) / Math.max(1, height)) : 0.0;
|
||||
const d = Math.hypot(x - fx, y - fy);
|
||||
const score = d * (0.55 + low * 0.45) + hashSeededTie(x, y, seed + 17) * 0.20;
|
||||
const score = axis * 1.9 + d * (0.18 + low * 0.22) + hashSeededTie(x, y, seed + 17) * 0.08 - ridgeField[i] * 0.08;
|
||||
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 owner = new Int8Array(SIZE);
|
||||
owner.fill(-1);
|
||||
const dist = new Float32Array(SIZE);
|
||||
dist.fill(INF);
|
||||
const heap = new MinHeap();
|
||||
for (const [source, sourceOwner] of [[first, 0], [second, 1]]) {
|
||||
owner[source] = sourceOwner;
|
||||
dist[source] = 0;
|
||||
heap.push({ i: source, f: 0, owner: sourceOwner });
|
||||
}
|
||||
|
||||
while (heap.length > 0) {
|
||||
const cur = heap.pop();
|
||||
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
|
||||
const [x, y] = xyOf(cur.i);
|
||||
for (const [nx, ny, step] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (!cellSet.has(ni) || localOwner.has(ni)) continue;
|
||||
localOwner.set(ni, owner);
|
||||
queue.push(ni);
|
||||
if (!cellSet.has(ni)) continue;
|
||||
const barrier = ((naturalBarrierScore?.[cur.i] || 0) + (naturalBarrierScore?.[ni] || 0)) * 0.5;
|
||||
const riverBarrier = Math.max(river?.[cur.i] || 0, river?.[ni] || 0) + Math.max(flowAccum?.[cur.i] || 0, flowAccum?.[ni] || 0) * 0.32;
|
||||
const ridgeStep = Math.max(ridgeField[cur.i], ridgeField[ni]) * 0.80 + Math.abs(elevation[cur.i] - elevation[ni]) * 1.25;
|
||||
const corridorBonus = Math.min(0.48, ((valleyField[cur.i] + valleyField[ni]) * 0.5 + (plain?.[ni] || 0) * 0.18 + (coastalLowland?.[ni] || 0) * 0.12));
|
||||
const stepCost = Math.max(0.18, 0.78 + barrier * 3.0 + riverBarrier * 1.10 + ridgeStep + slope[ni] * 0.38 - corridorBonus) * step;
|
||||
const nd = cur.f + stepCost;
|
||||
if (nd < dist[ni]) {
|
||||
dist[ni] = nd;
|
||||
owner[ni] = cur.owner;
|
||||
heap.push({ i: ni, f: nd, owner: cur.owner });
|
||||
}
|
||||
}
|
||||
}
|
||||
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 = [];
|
||||
const bCells = [];
|
||||
for (const ci of unit.cells) {
|
||||
if (owner[ci] === 1) bCells.push(ci);
|
||||
else aCells.push(ci);
|
||||
}
|
||||
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;
|
||||
if (aCells.length < minPart || bCells.length < minPart) return null;
|
||||
|
||||
unit.cells = aCells;
|
||||
const newUnit = { ...unit, id: newId, cells: bCells, centerIds: [], adjacent: new Map() };
|
||||
for (const ci of bCells) compartmentId[ci] = newId;
|
||||
|
|
@ -663,7 +708,374 @@ function naturalGroupKey(unit) {
|
|||
return `plain:${Math.round(unit.x / 14)}:${Math.round(unit.y / 14)}`;
|
||||
}
|
||||
|
||||
|
||||
function collectLandComponents(prefectureMask, sea) {
|
||||
const seen = new Uint8Array(SIZE);
|
||||
const components = [];
|
||||
const queue = [];
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (seen[i] || !prefectureMask[i] || sea[i]) continue;
|
||||
const cells = [];
|
||||
queue.length = 0;
|
||||
queue.push(i);
|
||||
seen[i] = 1;
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
cells.push(cur);
|
||||
const [x, y] = xyOf(cur);
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue;
|
||||
seen[ni] = 1;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
components.push(cells);
|
||||
}
|
||||
return components;
|
||||
}
|
||||
|
||||
function naturalSeedScore(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, seed) {
|
||||
const klassUrban = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8;
|
||||
const lowland = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
|
||||
const mountain = mountainCompartmentFitness(i, elevation, slope, ridgeField, populationDensity, landuse);
|
||||
const stableInterior = clamp(1 - (naturalBarrierScore[i] || 0));
|
||||
const settlement = clamp(populationDensity[i] * 0.65 + (klassUrban ? 0.24 : 0));
|
||||
const streamCorridor = clamp(valleyField[i] * 0.28 + river[i] * 0.08);
|
||||
const mountainInterior = clamp(mountain * 0.45 + stableInterior * 0.28 - ridgeField[i] * 0.22);
|
||||
return stableInterior * 0.56 + lowland * 0.42 + mountainInterior * 0.32 + settlement * 0.26 + streamCorridor + hashSeededTie(...xyOf(i), seed) * 0.13 - slope[i] * 0.10;
|
||||
}
|
||||
|
||||
function chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, seed) {
|
||||
const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore } = fields;
|
||||
const totalArea = landComponents.reduce((sum, cells) => sum + cells.length, 0);
|
||||
const seeds = [];
|
||||
const seedComponentId = [];
|
||||
const minCellsPerUnit = 9;
|
||||
let remainingTarget = Math.max(1, Math.min(targetCount || Math.round(totalArea / 42), Math.floor(totalArea / minCellsPerUnit)));
|
||||
|
||||
const sortedComponents = landComponents
|
||||
.map((cells, componentIndex) => ({ cells, componentIndex, area: cells.length }))
|
||||
.sort((a, b) => b.area - a.area);
|
||||
|
||||
for (let componentOrder = 0; componentOrder < sortedComponents.length; componentOrder++) {
|
||||
const { cells, componentIndex, area } = sortedComponents[componentOrder];
|
||||
if (area <= 0) continue;
|
||||
const proportional = Math.round((targetCount || Math.round(totalArea / 42)) * area / Math.max(1, totalArea));
|
||||
let localTarget = Math.max(1, proportional);
|
||||
localTarget = Math.min(localTarget, Math.max(1, Math.floor(area / minCellsPerUnit)));
|
||||
if (componentOrder === sortedComponents.length - 1) localTarget = Math.max(1, Math.min(localTarget, remainingTarget));
|
||||
remainingTarget -= localTarget;
|
||||
|
||||
const candidates = cells
|
||||
.map((i) => ({ i, score: naturalSeedScore(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, seed + componentIndex * 1009) }))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
const localSeeds = [];
|
||||
const idealSpacing = Math.sqrt(area / Math.max(1, localTarget));
|
||||
const spacingPasses = [0.95, 0.78, 0.62, 0.48, 0.34];
|
||||
for (const factor of spacingPasses) {
|
||||
const minDist = Math.max(2.2, idealSpacing * factor);
|
||||
for (const candidate of candidates) {
|
||||
if (localSeeds.length >= localTarget) break;
|
||||
const [x, y] = xyOf(candidate.i);
|
||||
let ok = true;
|
||||
for (const existing of localSeeds) {
|
||||
const [ex, ey] = xyOf(existing);
|
||||
if (Math.hypot(x - ex, y - ey) < minDist) { ok = false; break; }
|
||||
}
|
||||
if (ok) localSeeds.push(candidate.i);
|
||||
}
|
||||
if (localSeeds.length >= localTarget) break;
|
||||
}
|
||||
for (const i of localSeeds) {
|
||||
seeds.push(i);
|
||||
seedComponentId.push(componentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
if (seeds.length === 0 && landComponents[0]?.length) {
|
||||
seeds.push(landComponents[0][0]);
|
||||
seedComponentId.push(0);
|
||||
}
|
||||
return { seeds, seedComponentId };
|
||||
}
|
||||
|
||||
function naturalStepCost(a, b, cellClass, fields) {
|
||||
const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, flowAccum } = fields;
|
||||
const barrier = ((naturalBarrierScore?.[a] || 0) + (naturalBarrierScore?.[b] || 0)) * 0.5;
|
||||
const ridge = Math.max(ridgeField[a], ridgeField[b]);
|
||||
const riverEdge = Math.max(river[a], river[b]);
|
||||
const flowEdge = Math.max(flowAccum?.[a] || 0, flowAccum?.[b] || 0);
|
||||
const majorRiverCrossing = riverEdge > 0.44 || flowEdge > 0.55;
|
||||
const elevationBreak = Math.abs(elevation[a] - elevation[b]);
|
||||
const slopeBreak = Math.max(slope[a], slope[b]);
|
||||
const classBreak = cellClass[a] !== cellClass[b] ? 0.34 : -0.08;
|
||||
const bothUrban = ((landuse[a] >= 2 && landuse[a] <= 4) || landuse[a] === 7 || populationDensity[a] > 0.30) &&
|
||||
((landuse[b] >= 2 && landuse[b] <= 4) || landuse[b] === 7 || populationDensity[b] > 0.30);
|
||||
const lowlandContinuity = Math.min(
|
||||
(plain?.[a] || 0) + (agriculture?.[a] || 0) * 0.35 + basinField[a] * 0.25 + coastalLowland[a] * 0.20,
|
||||
(plain?.[b] || 0) + (agriculture?.[b] || 0) * 0.35 + basinField[b] * 0.25 + coastalLowland[b] * 0.20
|
||||
);
|
||||
const valleyContinuity = Math.min(valleyField[a], valleyField[b]) * (majorRiverCrossing ? 0.10 : 0.45);
|
||||
const corridorBonus = Math.min(0.42, lowlandContinuity * 0.22 + valleyContinuity + (bothUrban ? 0.18 : 0));
|
||||
const riverPenalty = majorRiverCrossing && !bothUrban ? 1.85 + flowEdge * 1.45 : riverEdge > 0.22 ? 0.38 : 0;
|
||||
return Math.max(0.16,
|
||||
0.72 +
|
||||
barrier * 5.1 +
|
||||
ridge * 0.82 +
|
||||
elevationBreak * 3.0 +
|
||||
slopeBreak * 0.56 +
|
||||
riverPenalty +
|
||||
classBreak -
|
||||
corridorBonus
|
||||
);
|
||||
}
|
||||
|
||||
function buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields) {
|
||||
const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse } = fields;
|
||||
let maxId = -1;
|
||||
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && compartmentId[i] > maxId) maxId = compartmentId[i];
|
||||
const units = Array.from({ length: maxId + 1 }, (_, id) => ({ id, cells: [], centerIds: [], adjacent: new Map(), area: 0 }));
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
const id = compartmentId[i];
|
||||
if (id >= 0 && units[id]) units[id].cells.push(i);
|
||||
}
|
||||
for (const unit of units) {
|
||||
if (!unit.cells.length) { unit.area = 0; continue; }
|
||||
const counts = new Map();
|
||||
for (const ci of unit.cells) counts.set(cellClass[ci], (counts.get(cellClass[ci]) || 0) + 1);
|
||||
let klass = -1, best = -1;
|
||||
for (const [k, count] of counts) if (count > best) { best = count; klass = k; }
|
||||
unit.classId = klass;
|
||||
unit.dominantLandscapeClass = klass;
|
||||
refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
|
||||
let riverExposure = 0;
|
||||
for (const ci of unit.cells) riverExposure += river[ci] + (fields.flowAccum?.[ci] || 0) * 0.45;
|
||||
unit.riverExposure = riverExposure / Math.max(1, unit.area);
|
||||
}
|
||||
return units;
|
||||
}
|
||||
|
||||
|
||||
function splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea) {
|
||||
const queue = [];
|
||||
for (const unit of [...compartments]) {
|
||||
if (!unit || unit.area === 0 || !unit.cells?.length) continue;
|
||||
const unitCellSet = new Set(unit.cells);
|
||||
const seen = new Set();
|
||||
const components = [];
|
||||
for (const start of unit.cells) {
|
||||
if (seen.has(start) || compartmentId[start] !== unit.id) continue;
|
||||
const cells = [];
|
||||
queue.length = 0;
|
||||
queue.push(start);
|
||||
seen.add(start);
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
cells.push(cur);
|
||||
const [x, y] = xyOf(cur);
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (!prefectureMask[ni] || sea[ni] || seen.has(ni) || compartmentId[ni] !== unit.id || !unitCellSet.has(ni)) continue;
|
||||
seen.add(ni);
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
components.push(cells);
|
||||
}
|
||||
if (components.length <= 1) continue;
|
||||
components.sort((a, b) => b.length - a.length);
|
||||
unit.cells = components[0];
|
||||
for (const extra of components.slice(1)) {
|
||||
const newId = compartments.length;
|
||||
for (const ci of extra) compartmentId[ci] = newId;
|
||||
compartments.push({ id: newId, cells: extra, centerIds: [], adjacent: new Map(), classId: unit.classId, dominantLandscapeClass: unit.dominantLandscapeClass });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renumberCompartments(compartmentId, compartments, prefectureMask, sea) {
|
||||
const active = compartments.filter((unit) => unit && unit.area > 0 && unit.cells?.length);
|
||||
const idMap = new Map();
|
||||
active.forEach((unit, newId) => idMap.set(unit.id, newId));
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
const id = compartmentId[i];
|
||||
if (!prefectureMask[i] || sea[i]) compartmentId[i] = -1;
|
||||
else if (idMap.has(id)) compartmentId[i] = idMap.get(id);
|
||||
}
|
||||
active.forEach((unit, newId) => { unit.id = newId; unit.centerIds = []; });
|
||||
return active;
|
||||
}
|
||||
|
||||
function refreshAllCompartmentStats(compartments, fields) {
|
||||
const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, river, flowAccum } = fields;
|
||||
for (const unit of compartments) {
|
||||
if (!unit || unit.area === 0 || !unit.cells?.length) continue;
|
||||
refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
|
||||
let riverExposure = 0;
|
||||
const counts = new Map();
|
||||
for (const ci of unit.cells) {
|
||||
riverExposure += river[ci] + (flowAccum?.[ci] || 0) * 0.45;
|
||||
if (unit._cellClass) counts.set(unit._cellClass[ci], (counts.get(unit._cellClass[ci]) || 0) + 1);
|
||||
}
|
||||
unit.riverExposure = riverExposure / Math.max(1, unit.area);
|
||||
}
|
||||
}
|
||||
|
||||
function splitNaturalCompartmentCompact(unit, newId, compartmentId, fields, seed) {
|
||||
if (!unit || unit.area < 20) return null;
|
||||
const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum, cellClass } = fields;
|
||||
const minPart = Math.max(7, Math.min(30, Math.floor(unit.area * 0.18)));
|
||||
let cx = unit.x || 0, cy = unit.y || 0;
|
||||
let first = -1, second = -1, bestA = -INF, bestB = -INF;
|
||||
const elongated = (unit.elongation || 1) > 2.3;
|
||||
const horizontal = (unit.width || 0) >= (unit.height || 0);
|
||||
for (const i of unit.cells) {
|
||||
const [x, y] = xyOf(i);
|
||||
const centerDist = Math.hypot(x - cx, y - cy);
|
||||
const axis = elongated ? Math.abs((horizontal ? x - cx : y - cy)) / Math.max(1, horizontal ? unit.width : unit.height) : 0;
|
||||
const interior = 1 - (naturalBarrierScore[i] || 0);
|
||||
const score = centerDist * 0.13 + axis * 1.1 + interior * 0.35 + hashSeededTie(x, y, seed) * 0.08 - ridgeField[i] * 0.10;
|
||||
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 d = Math.hypot(x - fx, y - fy);
|
||||
const interior = 1 - (naturalBarrierScore[i] || 0);
|
||||
const score = d * 0.20 + interior * 0.38 + hashSeededTie(x, y, seed + 31) * 0.08 - ridgeField[i] * 0.08;
|
||||
if (score > bestB) { bestB = score; second = i; }
|
||||
}
|
||||
if (second < 0 || second === first) return null;
|
||||
|
||||
const cellSet = new Set(unit.cells);
|
||||
const owner = new Int8Array(SIZE);
|
||||
owner.fill(-1);
|
||||
const dist = new Float32Array(SIZE);
|
||||
dist.fill(INF);
|
||||
const heap = new MinHeap();
|
||||
for (const [source, sourceOwner] of [[first, 0], [second, 1]]) {
|
||||
owner[source] = sourceOwner;
|
||||
dist[source] = 0;
|
||||
heap.push({ i: source, f: 0, owner: sourceOwner });
|
||||
}
|
||||
while (heap.length > 0) {
|
||||
const cur = heap.pop();
|
||||
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
|
||||
const [x, y] = xyOf(cur.i);
|
||||
for (const [nx, ny, step] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (!cellSet.has(ni)) continue;
|
||||
const nd = cur.f + naturalStepCost(cur.i, ni, cellClass, fields) * step;
|
||||
if (nd < dist[ni]) {
|
||||
dist[ni] = nd;
|
||||
owner[ni] = cur.owner;
|
||||
heap.push({ i: ni, f: nd, owner: cur.owner });
|
||||
}
|
||||
}
|
||||
}
|
||||
const aCells = [], bCells = [];
|
||||
for (const ci of unit.cells) (owner[ci] === 1 ? bCells : aCells).push(ci);
|
||||
if (aCells.length < minPart || bCells.length < minPart) return null;
|
||||
unit.cells = aCells;
|
||||
for (const ci of bCells) compartmentId[ci] = newId;
|
||||
const newUnit = { id: newId, cells: bCells, centerIds: [], adjacent: new Map(), classId: unit.classId, dominantLandscapeClass: unit.dominantLandscapeClass };
|
||||
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 buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) {
|
||||
const progress = typeof options.progress === "function" ? options.progress : null;
|
||||
const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse);
|
||||
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 fields = { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, naturalBarrierScore, cellClass };
|
||||
const landComponents = collectLandComponents(prefectureMask, sea);
|
||||
const landArea = landComponents.reduce((sum, cells) => sum + cells.length, 0);
|
||||
const requestedTarget = options.targetCompartmentCount || clamp(Math.round(landArea / 34), 40, 360);
|
||||
const targetCount = clamp(Math.round(requestedTarget), Math.min(1, landArea), Math.max(1, Math.floor(landArea / 8)));
|
||||
const { seeds } = chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, options.seed || 0);
|
||||
progress?.(`natural seeds chosen: ${seeds.length}/${targetCount}`);
|
||||
const compartmentId = new Int32Array(SIZE);
|
||||
compartmentId.fill(-1);
|
||||
const dist = new Float32Array(SIZE);
|
||||
dist.fill(INF);
|
||||
const heap = new MinHeap();
|
||||
seeds.forEach((i, id) => {
|
||||
compartmentId[i] = id;
|
||||
dist[i] = 0;
|
||||
heap.push({ i, f: 0, id });
|
||||
});
|
||||
while (heap.length > 0) {
|
||||
const cur = heap.pop();
|
||||
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
|
||||
const [x, y] = xyOf(cur.i);
|
||||
for (const [nx, ny, step] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (!prefectureMask[ni] || sea[ni]) continue;
|
||||
const nd = cur.f + naturalStepCost(cur.i, ni, cellClass, fields) * step;
|
||||
if (nd < dist[ni]) {
|
||||
dist[ni] = nd;
|
||||
compartmentId[ni] = cur.id;
|
||||
heap.push({ i: ni, f: nd, id: cur.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && compartmentId[i] < 0) compartmentId[i] = 0;
|
||||
progress?.("natural seeded growth complete");
|
||||
|
||||
let compartments = buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields);
|
||||
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||
mergeTinyLandscapeUnits(compartmentId, compartments, 9);
|
||||
progress?.(`natural post-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`);
|
||||
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
|
||||
compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea);
|
||||
refreshAllCompartmentStats(compartments, fields);
|
||||
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||
progress?.(`natural pre-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`);
|
||||
|
||||
const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(28, Math.round(landArea / Math.max(1, targetCount) * 1.55));
|
||||
let guard = Math.max(60, targetCount * 2);
|
||||
while (guard-- > 0) {
|
||||
let active = compartments.filter((unit) => unit && unit.area > 0);
|
||||
const needMore = active.length < targetCount;
|
||||
const worst = active
|
||||
.filter((unit) => unit.area >= 20 && (unit._splitRejected || 0) < 3 && (needMore || unit.area > maxNaturalCompartmentArea * 1.18 || (unit.elongation || 1) > 4.2))
|
||||
.sort((a, b) => {
|
||||
const sa = (a.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (a.elongation || 1) - 3.0) * 1.2;
|
||||
const sb = (b.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (b.elongation || 1) - 3.0) * 1.2;
|
||||
return sb - sa;
|
||||
})[0];
|
||||
if (!worst) break;
|
||||
const newUnit = splitNaturalCompartmentCompact(worst, compartments.length, compartmentId, fields, (options.seed || 0) + guard * 97);
|
||||
if (!newUnit) {
|
||||
worst._splitRejected = (worst._splitRejected || 0) + 1;
|
||||
if (worst._splitRejected > 2) worst.elongation = Math.min(worst.elongation || 1, 3.1);
|
||||
if (!needMore) break;
|
||||
continue;
|
||||
}
|
||||
compartments.push(newUnit);
|
||||
if (compartments.filter((unit) => unit && unit.area > 0).length >= targetCount && newUnit.area <= maxNaturalCompartmentArea) {
|
||||
const stillBad = compartments.some((unit) => unit && unit.area >= 20 && (
|
||||
unit.area > maxNaturalCompartmentArea * 1.35 ||
|
||||
((unit.elongation || 1) > 4.2 && unit.area > 28)
|
||||
));
|
||||
if (!stillBad) break;
|
||||
}
|
||||
}
|
||||
|
||||
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
|
||||
compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea);
|
||||
refreshAllCompartmentStats(compartments, fields);
|
||||
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||
return { compartmentId, compartments, naturalBarrierScore };
|
||||
}
|
||||
|
||||
export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) {
|
||||
return buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse, 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);
|
||||
|
|
@ -679,6 +1091,7 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope,
|
|||
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;
|
||||
let minX = MAP_W, minY = MAP_H, maxX = 0, maxY = 0;
|
||||
queue.length = 0;
|
||||
queue.push(i);
|
||||
compartmentId[i] = id;
|
||||
|
|
@ -687,6 +1100,7 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope,
|
|||
const [x, y] = xyOf(cur);
|
||||
cells.push(cur);
|
||||
sx += x; sy += y; pop += populationDensity[cur];
|
||||
minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
|
||||
urbanWeight += urbanBoundaryPenalty(cur, populationDensity, landuse);
|
||||
ridgeExposure += ridgeField[cur];
|
||||
riverExposure += river[cur] + flowAccum[cur] * 0.45;
|
||||
|
|
@ -711,6 +1125,13 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope,
|
|||
y: sy / area,
|
||||
classId: startClass,
|
||||
dominantLandscapeClass: startClass,
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
width: maxX - minX + 1,
|
||||
height: maxY - minY + 1,
|
||||
elongation: Math.max(maxX - minX + 1, maxY - minY + 1) / Math.max(1, Math.min(maxX - minX + 1, maxY - minY + 1)),
|
||||
population: pop,
|
||||
urbanWeight: urbanWeight / area,
|
||||
ridgeExposure: ridgeExposure / area,
|
||||
|
|
@ -730,22 +1151,47 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope,
|
|||
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;
|
||||
const fields = { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum };
|
||||
const landArea = compartments.reduce((sum, unit) => sum + (unit.area || 0), 0);
|
||||
const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(34, Math.round(landArea / Math.max(1, targetCount) * 1.65));
|
||||
const splitScore = (unit) => {
|
||||
const elongated = Math.max(0, (unit.elongation || 1) - 2.1);
|
||||
const areaPressure = unit.area / Math.max(1, maxNaturalCompartmentArea);
|
||||
const settled = (unit.lowlandFitness || 0) * 0.65 + (unit.urbanWeight || 0) * 0.35;
|
||||
return areaPressure * 2.2 + elongated * 1.4 + settled - (unit.mountainFitness || 0) * 0.20;
|
||||
};
|
||||
let guard = Math.max(targetCount * 4, 80);
|
||||
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));
|
||||
.filter((unit) => unit.area > 0 && unit.area >= 24 && ((unit.lowlandFitness || 0) > 0.18 || unit.area > maxNaturalCompartmentArea * 1.20 || (unit.elongation || 1) > 2.8))
|
||||
.sort((a, b) => splitScore(b) - splitScore(a));
|
||||
const target = candidates[0];
|
||||
if (!target) break;
|
||||
const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard);
|
||||
if (!newUnit) {
|
||||
target.lowlandFitness = 0;
|
||||
target._splitRejected = (target._splitRejected || 0) + 1;
|
||||
target.elongation = Math.max(1, (target.elongation || 1) * 0.72);
|
||||
if (target._splitRejected > 2) target.area = target.cells.length;
|
||||
continue;
|
||||
}
|
||||
compartments.push(newUnit);
|
||||
if (compartments.filter((unit) => unit.area > 0).length % 12 === 0) rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||
}
|
||||
|
||||
guard = Math.max(targetCount * 2, 60);
|
||||
while (guard-- > 0) {
|
||||
const target = compartments
|
||||
.filter((unit) => unit.area > 0 && unit.area >= 24 && (unit.area > maxNaturalCompartmentArea * 1.55 || ((unit.elongation || 1) > 3.2 && unit.area > maxNaturalCompartmentArea * 0.85)))
|
||||
.sort((a, b) => splitScore(b) - splitScore(a))[0];
|
||||
if (!target) break;
|
||||
const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard + 991);
|
||||
if (!newUnit) {
|
||||
target.elongation = Math.max(1, (target.elongation || 1) * 0.70);
|
||||
break;
|
||||
}
|
||||
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 };
|
||||
|
|
@ -1055,10 +1501,13 @@ export function extractCompartmentBorders(compartmentId, prefectureMask, sea) {
|
|||
}
|
||||
|
||||
export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], options = {}) {
|
||||
const progress = typeof options.progress === "function" ? options.progress : null;
|
||||
const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse, options);
|
||||
progress?.("natural compartments built");
|
||||
const adminId = new Int16Array(SIZE);
|
||||
adminId.fill(-1);
|
||||
const owner = graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options);
|
||||
progress?.("natural compartments assigned");
|
||||
for (const unit of compartments) {
|
||||
const assigned = owner[unit.id];
|
||||
if (assigned < 0) continue;
|
||||
|
|
@ -1070,6 +1519,7 @@ export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, e
|
|||
adminId[i] = comp && owner[comp.id] >= 0 ? owner[comp.id] : 0;
|
||||
}
|
||||
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse);
|
||||
progress?.("natural topology repaired");
|
||||
const activeCompartments = compartments.filter((unit) => unit.area > 0);
|
||||
const relationMetrics = compartmentMunicipalityMetrics(compartments, owner, options.targetMunicipalityCount || adminCenters.length, options.targetCompartmentCount || 0);
|
||||
return {
|
||||
|
|
@ -1081,6 +1531,12 @@ export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, e
|
|||
...relationMetrics,
|
||||
compartmentBorders: extractCompartmentBorders(compartmentId, prefectureMask, sea),
|
||||
averageCompartmentArea: activeCompartments.length ? activeCompartments.reduce((sum, unit) => sum + unit.area, 0) / activeCompartments.length : 0,
|
||||
maxCompartmentArea: activeCompartments.length ? Math.max(...activeCompartments.map((unit) => unit.area || 0)) : 0,
|
||||
maxCompartmentElongation: activeCompartments.length ? Math.max(...activeCompartments.map((unit) => unit.elongation || 1)) : 1,
|
||||
worstNaturalCompartments: activeCompartments
|
||||
.map((unit) => ({ id: unit.id, area: unit.area || 0, width: unit.width || 0, height: unit.height || 0, elongation: unit.elongation || 1, classId: unit.classId, x: Math.round(unit.x || 0), y: Math.round(unit.y || 0) }))
|
||||
.sort((a, b) => (b.elongation * Math.sqrt(Math.max(1, b.area))) - (a.elongation * Math.sqrt(Math.max(1, a.area))))
|
||||
.slice(0, 8),
|
||||
finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore),
|
||||
voronoiLikeRateBefore: 0,
|
||||
voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore),
|
||||
|
|
|
|||
1776
adminRegions.notrace.js
Normal file
1776
adminRegions.notrace.js
Normal file
File diff suppressed because it is too large
Load diff
91
app.js
91
app.js
|
|
@ -1,5 +1,6 @@
|
|||
import { generateMap } from "./mapGenerator.js";
|
||||
import { drawMap } from "./renderer.js";
|
||||
import { landuseLabel } from "./landuseCodes.js";
|
||||
|
||||
const modes = [
|
||||
["all", "All"],
|
||||
|
|
@ -7,11 +8,9 @@ const modes = [
|
|||
["suitability", "Suitability"],
|
||||
["history", "Premodern"],
|
||||
["modern", "Modern"],
|
||||
["roads", "Roads"],
|
||||
["development", "Development"],
|
||||
["landuse", "Land Use"],
|
||||
["admin", "Municipal Borders"],
|
||||
["terrain-debug", "Terrain Debug"],
|
||||
["admin-debug", "Admin Debug"],
|
||||
["borders-debug", "Borders Debug"],
|
||||
];
|
||||
|
|
@ -33,6 +32,9 @@ const modeGrid = document.getElementById("modeGrid");
|
|||
const statsEl = document.getElementById("stats");
|
||||
const idsEl = document.getElementById("nameIds");
|
||||
const tooltipEl = document.getElementById("mapTooltip");
|
||||
const progressEl = document.getElementById("generationProgress");
|
||||
const progressStageEl = document.getElementById("generationProgressStage");
|
||||
const progressTimingsEl = document.getElementById("generationProgressTimings");
|
||||
|
||||
function parseSeed(seedText) {
|
||||
const numeric = Number.parseInt(seedText, 10);
|
||||
|
|
@ -55,14 +57,63 @@ function countText(items) {
|
|||
return `${insideCount(items)} / outside ${outsideCount(items)}`;
|
||||
}
|
||||
|
||||
function formatMs(ms) {
|
||||
if (!Number.isFinite(ms)) return "-";
|
||||
return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${Math.round(ms)}ms`;
|
||||
}
|
||||
|
||||
function renderTimingRows(timings = []) {
|
||||
if (!progressTimingsEl) return;
|
||||
progressTimingsEl.innerHTML = "";
|
||||
for (const row of timings) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "progress-timing-row";
|
||||
const label = document.createElement("span");
|
||||
label.textContent = row.label;
|
||||
const value = document.createElement("strong");
|
||||
value.textContent = formatMs(row.ms);
|
||||
item.append(label, value);
|
||||
progressTimingsEl.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
function updateGenerationProgress(event) {
|
||||
if (!progressEl) return;
|
||||
progressEl.classList.remove("hidden");
|
||||
if (progressStageEl) {
|
||||
progressStageEl.textContent = event?.status === "done"
|
||||
? `Completed: ${event.label} / ${formatMs(event.ms)}`
|
||||
: `Running: ${event?.label || "Preparing"}`;
|
||||
}
|
||||
renderTimingRows(event?.timings || []);
|
||||
}
|
||||
|
||||
function setProgressVisible(visible, message = "Preparing") {
|
||||
if (!progressEl) return;
|
||||
progressEl.classList.toggle("hidden", !visible);
|
||||
if (progressStageEl) progressStageEl.textContent = message;
|
||||
if (visible) renderTimingRows([]);
|
||||
}
|
||||
|
||||
function nextFrame() {
|
||||
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
|
||||
function getStats(map) {
|
||||
return [
|
||||
["Map Type", map.terrainTemplate?.terrainTypeLabel || map.terrainDebug?.terrainTypeLabel || "-"],
|
||||
["Terrain ID", map.terrainTemplate?.terrainType || map.terrainDebug?.terrainType || "-"],
|
||||
["Generation Time", map.generationTotalMs ? `${formatMs(map.generationTotalMs)} / slowest ${(map.generationTimings || []).slice().sort((a, b) => b.ms - a.ms)[0]?.label || "-"}` : "-"],
|
||||
["Villages", countText(map.villages)],
|
||||
["Market Towns", countText(map.markets)],
|
||||
["Castles", countText(map.castles)],
|
||||
["Premodern Roads", map.premodernRoads.length],
|
||||
["Minor Roads", map.minorRoads.length],
|
||||
["Prefecture", map.prefectureName || "-"],
|
||||
["Neighbor Prefectures", (map.neighborPrefectures || []).map((p) => p.name).join(" / ") || "-"],
|
||||
["Neighbor Features", map.neighborPrefectureDetails ? `${map.neighborPrefectureDetails.cities?.length || 0} cities / ${map.neighborPrefectureDetails.adminCenters?.length || 0} municipalities / ${map.neighborPrefectureDetails.roads?.length || 0} roads` : "-"],
|
||||
["Prefectural Capital", map.prefecturalCapital?.name || "-"],
|
||||
["Regional Capitals", (map.modernCities || []).filter((p) => p.isRegionalCapital).length],
|
||||
["Modern Cities", countText(map.modernCities)],
|
||||
["Ports", `${map.ports.filter((p) => p.portClass === "major").length} major / ${map.ports.filter((p) => p.portClass === "regional").length} regional / ${map.ports.filter((p) => p.portClass === "fishing").length} fishing / ${map.ports.filter((p) => p.portClass === "lake").length} lake`],
|
||||
["Satellite Cities", countText(map.satelliteCities || [])],
|
||||
|
|
@ -72,7 +123,8 @@ function getStats(map) {
|
|||
["Harbor Works", (map.harborWorks || []).length],
|
||||
["Rail Lines", map.railways.length + map.branchRailways.length + (map.ringRailways || []).length + map.externalRailways.length],
|
||||
["Industrial Zones", countText(map.industrialZones)],
|
||||
["National Roads", map.nationalRoads.length + (map.ringRoads || []).length],
|
||||
["National Roads", `${map.nationalRoads.length} / pop cover ${Math.round((map.transportDebug?.nationalRoadPopulationCoverage || 0) * 100)}% / uncovered ${(map.transportDebug?.nationalRoadUncoveredPopulation || 0).toLocaleString()}`],
|
||||
["General Ring Roads", (map.ringRoads || []).length],
|
||||
["Expressways", map.expressways.length + map.externalExpressways.length],
|
||||
["External Gateways", map.externalGateways.length],
|
||||
["Interchanges", countText(map.interchanges)],
|
||||
|
|
@ -144,18 +196,7 @@ function nearestEntity(map, x, y, maxDistance = 5) {
|
|||
}
|
||||
|
||||
function landuseName(value) {
|
||||
return {
|
||||
0: "Agriculture",
|
||||
1: "Plain",
|
||||
2: "Old urban area",
|
||||
3: "CBD / DID core",
|
||||
4: "Suburban urban area",
|
||||
5: "Industrial zone",
|
||||
6: "Logistics area",
|
||||
7: "New town",
|
||||
8: "Roadside development",
|
||||
9: "Forest / rural land",
|
||||
}[value] || "Land";
|
||||
return landuseLabel(value);
|
||||
}
|
||||
|
||||
function adminName(map, adminId) {
|
||||
|
|
@ -205,12 +246,22 @@ function renderModeButtons() { modeGrid.innerHTML = "";
|
|||
}
|
||||
}
|
||||
|
||||
function regenerate() {
|
||||
async function regenerate() {
|
||||
state.seedText = seedInput.value;
|
||||
state.map = generateMap(parseSeed(state.seedText));
|
||||
renderStats(state.map);
|
||||
renderNameIds(state.map);
|
||||
redraw();
|
||||
setProgressVisible(true, "Preparing generation...");
|
||||
await nextFrame();
|
||||
try {
|
||||
state.map = generateMap(parseSeed(state.seedText), { onProgress: updateGenerationProgress });
|
||||
renderStats(state.map);
|
||||
renderNameIds(state.map);
|
||||
redraw();
|
||||
if (progressStageEl) progressStageEl.textContent = `Done in ${formatMs(state.map.generationTotalMs || 0)}`;
|
||||
renderTimingRows(state.map.generationTimings || []);
|
||||
window.setTimeout(() => setProgressVisible(false), 900);
|
||||
} catch (error) {
|
||||
if (progressStageEl) progressStageEl.textContent = `Generation failed: ${error?.message || error}`;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function redraw() {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@
|
|||
|
||||
<div class="canvas-shell">
|
||||
<canvas id="mapCanvas" class="map-canvas"></canvas>
|
||||
<div id="generationProgress" class="generation-progress hidden" role="status" aria-live="polite">
|
||||
<div class="progress-title">Generating map...</div>
|
||||
<div id="generationProgressStage" class="progress-stage">Preparing</div>
|
||||
<div id="generationProgressTimings" class="progress-timings"></div>
|
||||
</div>
|
||||
<div id="mapTooltip" class="map-tooltip" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
37
landuseCodes.js
Normal file
37
landuseCodes.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
export const LANDUSE = Object.freeze({
|
||||
RURAL: 0,
|
||||
FARMLAND: 1,
|
||||
OLD_URBAN: 2,
|
||||
CBD: 3,
|
||||
SUBURB: 4,
|
||||
INDUSTRIAL: 5,
|
||||
LOGISTICS: 6,
|
||||
NEW_TOWN: 7,
|
||||
ROADSIDE: 8,
|
||||
FOREST: 9,
|
||||
});
|
||||
|
||||
export const LANDUSE_LABELS = Object.freeze({
|
||||
[LANDUSE.RURAL]: "Rural / natural land",
|
||||
[LANDUSE.FARMLAND]: "Farmland",
|
||||
[LANDUSE.OLD_URBAN]: "Old urban area",
|
||||
[LANDUSE.CBD]: "CBD / DID core",
|
||||
[LANDUSE.SUBURB]: "Suburban urban area",
|
||||
[LANDUSE.INDUSTRIAL]: "Industrial zone",
|
||||
[LANDUSE.LOGISTICS]: "Logistics area",
|
||||
[LANDUSE.NEW_TOWN]: "New town",
|
||||
[LANDUSE.ROADSIDE]: "Roadside development",
|
||||
[LANDUSE.FOREST]: "Forest / mountain land",
|
||||
});
|
||||
|
||||
export function landuseLabel(value) {
|
||||
return LANDUSE_LABELS[value] || "Land";
|
||||
}
|
||||
|
||||
export function isBuiltLanduse(value) {
|
||||
return value >= LANDUSE.OLD_URBAN && value <= LANDUSE.ROADSIDE;
|
||||
}
|
||||
|
||||
export function isUrbanResidentialLanduse(value) {
|
||||
return value === LANDUSE.OLD_URBAN || value === LANDUSE.CBD || value === LANDUSE.SUBURB || value === LANDUSE.NEW_TOWN || value === LANDUSE.ROADSIDE;
|
||||
}
|
||||
358
mapAdminStage.js
358
mapAdminStage.js
|
|
@ -11,6 +11,15 @@ import {
|
|||
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, xyOf } from "./mapUtils.js";
|
||||
import { extractAdminBorderSegments } from "./mapGeneratorHelpers.js";
|
||||
|
||||
const OUTER_ANCHOR_REGION_ID = -2;
|
||||
|
||||
function adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId) {
|
||||
if (prefectureMask[i]) return 0;
|
||||
const regionalId = prefectureRegionId?.[i] ?? -1;
|
||||
if (regionalId === 0) return OUTER_ANCHOR_REGION_ID;
|
||||
return regionalId;
|
||||
}
|
||||
|
||||
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++;
|
||||
|
|
@ -277,7 +286,22 @@ function restoreSurvivedSeedsByCompartment(adminId, prefectureMask, sea, compart
|
|||
return { changedCells, restoredSeeds };
|
||||
}
|
||||
|
||||
function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }) {
|
||||
function municipalityCountBoundsForRegion(landCells, meta = {}) {
|
||||
const focused = meta.isFocusedRegion !== false;
|
||||
if (focused) return { min: 20, max: 50 };
|
||||
// Neighbor prefectures are often visible only as clipped map-edge slivers.
|
||||
// Avoid giving every tiny visible fragment the full 20-municipality floor.
|
||||
let min = 1;
|
||||
if (landCells >= 500) min = 2;
|
||||
if (landCells >= 950) min = 3;
|
||||
if (landCells >= 1700) min = 5;
|
||||
if (landCells >= 2800) min = 7;
|
||||
if (landCells >= 4300) min = 10;
|
||||
const max = clamp(Math.round(landCells / 260 + 2), Math.max(min, 2), 34);
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages, adminRegionMeta = {} }) {
|
||||
let landCells = 0;
|
||||
let habitableCells = 0;
|
||||
let lowlandCells = 0;
|
||||
|
|
@ -305,8 +329,9 @@ function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope,
|
|||
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);
|
||||
const rawTarget = Math.round(habitableCells / 230 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.55 + lowlandBonus - mountainRatio * 2.2);
|
||||
const { min, max } = municipalityCountBoundsForRegion(landCells, adminRegionMeta);
|
||||
return clamp(rawTarget, min, max);
|
||||
}
|
||||
|
||||
function lowlandAdminSeedScore(i, { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse }) {
|
||||
|
|
@ -580,7 +605,7 @@ function expandSatelliteMunicipalityCatchment(adminId, satellite, targetAdmin, c
|
|||
return changed;
|
||||
}
|
||||
|
||||
export function generateAdminLayout({
|
||||
function generateAdminLayoutForMask({
|
||||
seed,
|
||||
prefectureMask,
|
||||
sea,
|
||||
|
|
@ -611,14 +636,24 @@ export function generateAdminLayout({
|
|||
stations,
|
||||
industrialZones,
|
||||
logisticsParks,
|
||||
adminRegionMeta = {},
|
||||
adminProgress = null,
|
||||
}) {
|
||||
const boundaryRidgeField = naturalBarrierScore
|
||||
? Float32Array.from(ridgeField, (value, i) => clamp(value * 0.72 + naturalBarrierScore[i] * 0.46))
|
||||
: ridgeField;
|
||||
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "classify satellites" });
|
||||
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);
|
||||
const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages, adminRegionMeta });
|
||||
const compartmentMultiplier = clamp(4.6 + rand(seed, 1320) * 2.4, 4.6, 7.0);
|
||||
const regionLandArea = adminRegionMeta.landArea || maskLandArea(prefectureMask, sea);
|
||||
const minCompartmentTarget = adminRegionMeta.isFocusedRegion === false
|
||||
? clamp(Math.round(Math.max(targetMunicipalityCount * 3.2, regionLandArea / 75)), 18, 90)
|
||||
: 120;
|
||||
const maxCompartmentTarget = adminRegionMeta.isFocusedRegion === false
|
||||
? clamp(Math.round(Math.max(targetMunicipalityCount * 5.8, regionLandArea / 38)), minCompartmentTarget, 220)
|
||||
: 360;
|
||||
let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), minCompartmentTarget, maxCompartmentTarget);
|
||||
let adminCentersRaw = buildLowlandAdminSeeds({
|
||||
seed,
|
||||
targetMunicipalityCount,
|
||||
|
|
@ -643,11 +678,14 @@ export function generateAdminLayout({
|
|||
newTowns,
|
||||
stations,
|
||||
});
|
||||
if (adminCentersRaw.length < 20) targetCompartmentCount = Math.max(targetCompartmentCount, 120);
|
||||
if (adminCentersRaw.length < 20) targetCompartmentCount = Math.max(targetCompartmentCount, minCompartmentTarget);
|
||||
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "natural compartments", targetMunicipalityCount, targetCompartmentCount, seedCount: adminCentersRaw.length });
|
||||
const compartmentAssignment = assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, {
|
||||
seed,
|
||||
targetMunicipalityCount,
|
||||
targetCompartmentCount,
|
||||
maxNaturalCompartmentArea: Math.max(32, Math.round(maskLandArea(prefectureMask, sea) / Math.max(1, targetCompartmentCount) * 1.65)),
|
||||
progress: (step) => adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step }),
|
||||
});
|
||||
const adminId = compartmentAssignment.adminId;
|
||||
let previousSnapshot = new Int16Array(adminId);
|
||||
|
|
@ -700,6 +738,7 @@ export function generateAdminLayout({
|
|||
satelliteMunicipalityStats: satelliteClassificationDebug,
|
||||
...compartmentAssignment.debug,
|
||||
};
|
||||
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "seed lifecycle" });
|
||||
const seedLifecycle = buildSeedLifecycle(adminCentersRaw, adminId, prefectureMask, sea, 35);
|
||||
const pendingSplitDebug = splitOversizedLowlandsWithPendingSeeds(adminId, prefectureMask, sea, {
|
||||
plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation,
|
||||
|
|
@ -725,6 +764,7 @@ export function generateAdminLayout({
|
|||
adminDebug[field] = changedCellsSince(previousSnapshot, adminId, prefectureMask, sea);
|
||||
previousSnapshot = new Int16Array(adminId);
|
||||
}
|
||||
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "smooth boundaries" });
|
||||
smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, populationDensity, landuse, 2);
|
||||
markChanged("changedAfterSmooth");
|
||||
|
||||
|
|
@ -787,19 +827,34 @@ export function generateAdminLayout({
|
|||
const activeAdminCenters = () => adminCentersRaw.filter((_, id) => activeAdminIds.has(id));
|
||||
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 120, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 100, protectedPoints: activeAdminCenters() });
|
||||
markChanged("changedAfterInitialMerge");
|
||||
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "topology cleanup" });
|
||||
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]);
|
||||
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "split oversized municipalities" });
|
||||
// The older oversized-lowland pass rebuilds natural compartments a second time.
|
||||
// The current pipeline already performs pending-seed lowland splitting on the active
|
||||
// compartment graph above, so keep the full admin layout while avoiding the duplicate
|
||||
// high-cost recomputation.
|
||||
const oversizedSplitDebug = {
|
||||
changedCells: 0,
|
||||
splitMunicipalities: 0,
|
||||
rejectedMunicipalities: 0,
|
||||
skippedDuplicateCompartmentRebuild: true,
|
||||
skippedForVisibleFragment: adminRegionMeta.isFocusedRegion === false && regionLandArea < 6500,
|
||||
};
|
||||
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;
|
||||
adminDebug.oversizedSplitSkippedForVisibleFragment = Boolean(oversizedSplitDebug.skippedForVisibleFragment);
|
||||
adminDebug.oversizedSplitSkippedDuplicateCompartmentRebuild = Boolean(oversizedSplitDebug.skippedDuplicateCompartmentRebuild);
|
||||
previousSnapshot = new Int16Array(adminId);
|
||||
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "terrain snap" });
|
||||
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);
|
||||
|
|
@ -871,8 +926,293 @@ export function generateAdminLayout({
|
|||
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;
|
||||
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "extract borders" });
|
||||
const adminBorders = extractAdminBorderSegments(adminId, prefectureMask);
|
||||
|
||||
|
||||
return { adminCentersRaw, adminId, adminBorders, adminDebug };
|
||||
}
|
||||
|
||||
|
||||
function filterPointsForMask(points = [], mask, sea) {
|
||||
return (points || [])
|
||||
.filter((p) => p && inside(p.x, p.y) && mask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)])
|
||||
.map((p) => ({ ...p }));
|
||||
}
|
||||
|
||||
function buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId) {
|
||||
const mask = new Uint8Array(SIZE);
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (sea[i]) continue;
|
||||
mask[i] = adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId) === regionId ? 1 : 0;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
function maskLandArea(mask, sea) {
|
||||
let area = 0;
|
||||
for (let i = 0; i < SIZE; i++) if (mask[i] && !sea[i]) area++;
|
||||
return area;
|
||||
}
|
||||
|
||||
|
||||
function compactAdminIdsAndCenters(adminId, humanMask, sea, centers = [], fields = {}) {
|
||||
const activeIds = [...new Set([...adminId].filter((id, i) => id >= 0 && humanMask[i] && !sea[i]))].sort((a, b) => a - b);
|
||||
const idMap = new Map(activeIds.map((oldId, newId) => [oldId, newId]));
|
||||
const newAdminId = new Int16Array(SIZE);
|
||||
newAdminId.fill(-1);
|
||||
const cellsByNewId = Array.from({ length: activeIds.length }, () => []);
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (!humanMask[i] || sea[i]) continue;
|
||||
const newId = idMap.get(adminId[i]);
|
||||
if (newId === undefined) continue;
|
||||
newAdminId[i] = newId;
|
||||
cellsByNewId[newId].push(i);
|
||||
}
|
||||
|
||||
const chooseOffice = (newId, oldId) => {
|
||||
const cells = cellsByNewId[newId] || [];
|
||||
const current = centers[oldId];
|
||||
if (current && inside(current.x, current.y)) {
|
||||
const ci = indexOf(current.x, current.y);
|
||||
if (newAdminId[ci] === newId && humanMask[ci] && !sea[ci]) {
|
||||
return { ...current, localAdminId: newId, oldAdminId: oldId, municipalityOffice: true };
|
||||
}
|
||||
}
|
||||
let sx = 0, sy = 0;
|
||||
for (const i of cells) {
|
||||
const [x, y] = xyOf(i);
|
||||
sx += x;
|
||||
sy += y;
|
||||
}
|
||||
const cx = cells.length ? sx / cells.length : current?.x || 0;
|
||||
const cy = cells.length ? sy / cells.length : current?.y || 0;
|
||||
let bestI = cells[0] ?? -1;
|
||||
let bestScore = -INF;
|
||||
for (const i of cells) {
|
||||
const [x, y] = xyOf(i);
|
||||
const land = fields.landuse?.[i] ?? 0;
|
||||
const urbanBonus = land === 3 ? 1.2 : land === 2 ? 1.0 : land === 4 || land === 7 || land === 8 ? 0.55 : land === 1 ? 0.24 : 0;
|
||||
const density = fields.populationDensity?.[i] || 0;
|
||||
const settlement = fields.settlementScore?.[i] || 0;
|
||||
const score =
|
||||
density * 2.25 +
|
||||
settlement * 0.75 +
|
||||
urbanBonus +
|
||||
(fields.plain?.[i] || 0) * 0.32 +
|
||||
(fields.basinField?.[i] || 0) * 0.24 +
|
||||
(fields.coastalLowland?.[i] || 0) * 0.18 +
|
||||
(fields.roadInfluence?.[i] || 0) * 0.34 +
|
||||
(fields.stationInfluence?.[i] || 0) * 0.45 -
|
||||
(fields.slope?.[i] || 0) * 0.52 -
|
||||
Math.hypot(x - cx, y - cy) * 0.018 +
|
||||
hash2(x, y, 91337 + newId) * 0.012;
|
||||
if (score > bestScore) { bestScore = score; bestI = i; }
|
||||
}
|
||||
const [bx, by] = bestI >= 0 ? xyOf(bestI) : [Math.round(cx), Math.round(cy)];
|
||||
return {
|
||||
...(current || {}),
|
||||
x: bx,
|
||||
y: by,
|
||||
score: bestScore > -INF ? bestScore : 0,
|
||||
seedKind: current?.seedKind || "generatedMunicipalOffice",
|
||||
invisibleLowlandAdminSeed: current?.invisibleLowlandAdminSeed ?? true,
|
||||
localAdminId: newId,
|
||||
oldAdminId: oldId,
|
||||
municipalityOffice: true,
|
||||
generatedOfficePoint: !current || !inside(current.x, current.y) || newAdminId[indexOf(current.x, current.y)] !== newId,
|
||||
};
|
||||
};
|
||||
|
||||
const adminCenters = activeIds.map((oldId, newId) => chooseOffice(newId, oldId));
|
||||
return {
|
||||
adminId: newAdminId,
|
||||
adminCenters,
|
||||
activeMunicipalityCount: activeIds.length,
|
||||
removedUnusedAdminCenterCount: Math.max(0, centers.length - activeIds.length),
|
||||
generatedOfficePointCount: adminCenters.filter((p) => p.generatedOfficePoint).length,
|
||||
};
|
||||
}
|
||||
|
||||
function discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea) {
|
||||
const ids = new Set();
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (sea[i]) continue;
|
||||
const id = adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId);
|
||||
if (id >= 0 || id === OUTER_ANCHOR_REGION_ID) ids.add(id);
|
||||
}
|
||||
return [...ids].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function generateAdminLayout(context) {
|
||||
const { prefectureMask, prefectureRegionId, sea, populationDensity, plain, slope, adminProgress } = context;
|
||||
const minFullAdminRegionArea = 1500;
|
||||
const regionIds = discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea)
|
||||
.filter((regionId) => regionId === 0 || (regionId !== OUTER_ANCHOR_REGION_ID && maskLandArea(buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId), sea) >= minFullAdminRegionArea));
|
||||
|
||||
if (!prefectureRegionId || regionIds.length <= 1) return generateAdminLayoutForMask(context);
|
||||
|
||||
let combinedAdminId = new Int16Array(SIZE);
|
||||
combinedAdminId.fill(-1);
|
||||
const combinedHumanMask = new Uint8Array(SIZE);
|
||||
let combinedCenters = [];
|
||||
const combinedCompartmentBorders = [];
|
||||
const perRegion = [];
|
||||
let idOffset = 0;
|
||||
|
||||
for (const regionId of regionIds) {
|
||||
const regionMask = buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId);
|
||||
const regionArea = maskLandArea(regionMask, sea);
|
||||
if (regionId !== 0 && regionArea < minFullAdminRegionArea) continue;
|
||||
|
||||
const localContext = {
|
||||
...context,
|
||||
seed: (context.seed + regionId * 10007) >>> 0,
|
||||
prefectureMask: regionMask,
|
||||
modernCities: filterPointsForMask(context.modernCities, regionMask, sea),
|
||||
satelliteCities: filterPointsForMask(context.satelliteCities, regionMask, sea),
|
||||
newTowns: filterPointsForMask(context.newTowns, regionMask, sea),
|
||||
markets: filterPointsForMask(context.markets, regionMask, sea),
|
||||
villages: filterPointsForMask(context.villages, regionMask, sea),
|
||||
ports: filterPointsForMask(context.ports, regionMask, sea),
|
||||
stations: filterPointsForMask(context.stations, regionMask, sea),
|
||||
industrialZones: filterPointsForMask(context.industrialZones, regionMask, sea),
|
||||
logisticsParks: filterPointsForMask(context.logisticsParks, regionMask, sea),
|
||||
adminRegionMeta: {
|
||||
regionId,
|
||||
landArea: regionArea,
|
||||
isFocusedRegion: regionId === 0,
|
||||
isOuterAnchorRegion: regionId === OUTER_ANCHOR_REGION_ID,
|
||||
},
|
||||
adminProgress,
|
||||
};
|
||||
|
||||
adminProgress?.({ status: "region-start", regionId, area: regionArea });
|
||||
const local = generateAdminLayoutForMask(localContext);
|
||||
adminProgress?.({ status: "region-done", regionId, area: regionArea, municipalities: local.adminDebug?.finalMunicipalityCount || local.adminDebug?.actualMunicipalityCount || 0 });
|
||||
if (local.adminDebug?.compartmentBorders?.length) combinedCompartmentBorders.push(...local.adminDebug.compartmentBorders);
|
||||
let localMaxAdminId = -1;
|
||||
for (let i = 0; i < SIZE; i++) if (regionMask[i] && !sea[i] && (local.adminId?.[i] ?? -1) > localMaxAdminId) localMaxAdminId = local.adminId[i];
|
||||
const localSlotCount = Math.max(local.adminCentersRaw?.length || 0, localMaxAdminId + 1);
|
||||
const localCenters = [];
|
||||
for (let localAdminId = 0; localAdminId < localSlotCount; localAdminId++) {
|
||||
let center = local.adminCentersRaw?.[localAdminId];
|
||||
if (!center) {
|
||||
let sx = 0, sy = 0, count = 0, bestI = -1, bestScore = -INF;
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (!regionMask[i] || sea[i] || local.adminId?.[i] !== localAdminId) continue;
|
||||
const [x, y] = xyOf(i);
|
||||
sx += x;
|
||||
sy += y;
|
||||
count++;
|
||||
const score = (populationDensity?.[i] || 0) + (plain?.[i] || 0) * 0.2 - (slope?.[i] || 0) * 0.2;
|
||||
if (score > bestScore) { bestScore = score; bestI = i; }
|
||||
}
|
||||
if (count && bestI >= 0) {
|
||||
const [bx, by] = xyOf(bestI);
|
||||
center = { x: bx, y: by, score: bestScore, seedKind: "generatedAdminSlot", invisibleLowlandAdminSeed: true };
|
||||
}
|
||||
}
|
||||
if (!center) center = { x: 0, y: 0, score: 0, seedKind: "emptyAdminSlot", invisibleLowlandAdminSeed: true };
|
||||
localCenters.push({
|
||||
...center,
|
||||
regionId,
|
||||
localAdminId,
|
||||
adminIdOffset: idOffset,
|
||||
});
|
||||
}
|
||||
combinedCenters.push(...localCenters);
|
||||
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (!regionMask[i] || sea[i]) continue;
|
||||
combinedHumanMask[i] = 1;
|
||||
const localId = local.adminId?.[i] ?? -1;
|
||||
if (localId >= 0) combinedAdminId[i] = localId + idOffset;
|
||||
}
|
||||
|
||||
perRegion.push({
|
||||
regionId,
|
||||
area: regionArea,
|
||||
centerCount: localCenters.length,
|
||||
municipalityCount: local.adminDebug?.finalMunicipalityCount || local.adminDebug?.actualMunicipalityCount || new Set([...local.adminId].filter((id, i) => id >= 0 && regionMask[i] && !sea[i])).size,
|
||||
naturalCompartmentCount: local.adminDebug?.naturalCompartmentCount || 0,
|
||||
targetNaturalCompartmentCount: local.adminDebug?.targetNaturalCompartmentCount || 0,
|
||||
averageCompartmentArea: local.adminDebug?.averageCompartmentArea || 0,
|
||||
maxCompartmentArea: local.adminDebug?.maxCompartmentArea || 0,
|
||||
maxCompartmentElongation: local.adminDebug?.maxCompartmentElongation || 1,
|
||||
worstNaturalCompartments: local.adminDebug?.worstNaturalCompartments || [],
|
||||
singleCompartmentMunicipalityRatio: local.adminDebug?.singleCompartmentMunicipalityRatio || 0,
|
||||
});
|
||||
idOffset += localSlotCount;
|
||||
}
|
||||
|
||||
const leftoverByRegion = new Map();
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (sea[i]) continue;
|
||||
const regionId = adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId);
|
||||
if ((regionId < 0 && regionId !== OUTER_ANCHOR_REGION_ID) || combinedAdminId[i] >= 0) continue;
|
||||
if (!leftoverByRegion.has(regionId)) leftoverByRegion.set(regionId, []);
|
||||
leftoverByRegion.get(regionId).push(i);
|
||||
}
|
||||
for (const [regionId, cells] of leftoverByRegion) {
|
||||
let sx = 0, sy = 0, bestI = cells[0], bestScore = -INF;
|
||||
for (const i of cells) {
|
||||
const [x, y] = xyOf(i);
|
||||
sx += x;
|
||||
sy += y;
|
||||
const score = (populationDensity?.[i] || 0) + (plain?.[i] || 0) * 0.2 - (slope?.[i] || 0) * 0.2;
|
||||
if (score > bestScore) { bestScore = score; bestI = i; }
|
||||
}
|
||||
const [cx, cy] = xyOf(bestI);
|
||||
const id = combinedCenters.length;
|
||||
combinedCenters.push({ x: cx, y: cy, score: bestScore, regionId, localAdminId: 0, seedKind: "tinyRegionAdminSeed", invisibleLowlandAdminSeed: true });
|
||||
for (const i of cells) {
|
||||
combinedHumanMask[i] = 1;
|
||||
combinedAdminId[i] = id;
|
||||
}
|
||||
perRegion.push({ regionId, area: cells.length, centerCount: 1, municipalityCount: 1, naturalCompartmentCount: 1, targetNaturalCompartmentCount: 1, averageCompartmentArea: cells.length, maxCompartmentArea: cells.length, maxCompartmentElongation: 1, singleCompartmentMunicipalityRatio: 1, tinyRegionFallback: true });
|
||||
}
|
||||
|
||||
const compactedAdmin = compactAdminIdsAndCenters(combinedAdminId, combinedHumanMask, sea, combinedCenters, {
|
||||
populationDensity,
|
||||
plain,
|
||||
slope,
|
||||
settlementScore: context.settlementScore,
|
||||
landuse: context.landuse,
|
||||
basinField: context.basinField,
|
||||
coastalLowland: context.coastalLowland,
|
||||
roadInfluence: context.roadInfluence,
|
||||
stationInfluence: context.stationInfluence,
|
||||
});
|
||||
combinedAdminId = compactedAdmin.adminId;
|
||||
combinedCenters = compactedAdmin.adminCenters;
|
||||
const adminBorders = extractAdminBorderSegments(combinedAdminId, combinedHumanMask);
|
||||
const totalMunicipalityCount = compactedAdmin.activeMunicipalityCount;
|
||||
const totalNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.naturalCompartmentCount || 0), 0);
|
||||
const totalTargetNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.targetNaturalCompartmentCount || 0), 0);
|
||||
const weightedCompartmentArea = perRegion.reduce((sum, row) => sum + (row.averageCompartmentArea || 0) * (row.naturalCompartmentCount || 0), 0);
|
||||
const weightedSingleRatio = perRegion.reduce((sum, row) => sum + (row.singleCompartmentMunicipalityRatio || 0) * (row.municipalityCount || 0), 0);
|
||||
const adminDebug = {
|
||||
multiRegionAdmin: true,
|
||||
adminRegionCount: perRegion.length,
|
||||
minFullAdminRegionArea,
|
||||
perRegion,
|
||||
finalMunicipalityCount: totalMunicipalityCount,
|
||||
actualMunicipalityCount: totalMunicipalityCount,
|
||||
candidateSeedCount: combinedCenters.length,
|
||||
municipalOfficePointCount: combinedCenters.length,
|
||||
generatedOfficePointCount: compactedAdmin.generatedOfficePointCount,
|
||||
removedUnusedAdminCenterCount: compactedAdmin.removedUnusedAdminCenterCount,
|
||||
naturalCompartmentCount: totalNaturalCompartmentCount,
|
||||
compartmentCount: totalNaturalCompartmentCount,
|
||||
targetNaturalCompartmentCount: totalTargetNaturalCompartmentCount,
|
||||
averageCompartmentArea: totalNaturalCompartmentCount ? weightedCompartmentArea / totalNaturalCompartmentCount : 0,
|
||||
maxCompartmentArea: Math.max(0, ...perRegion.map((row) => row.maxCompartmentArea || 0)),
|
||||
maxCompartmentElongation: Math.max(1, ...perRegion.map((row) => row.maxCompartmentElongation || 1)),
|
||||
averageCompartmentsPerMunicipality: totalMunicipalityCount ? totalNaturalCompartmentCount / totalMunicipalityCount : 0,
|
||||
singleCompartmentMunicipalityRatio: totalMunicipalityCount ? weightedSingleRatio / totalMunicipalityCount : 0,
|
||||
compartmentBorders: combinedCompartmentBorders,
|
||||
};
|
||||
|
||||
return { adminCentersRaw: combinedCenters, adminId: combinedAdminId, adminBorders, adminDebug };
|
||||
}
|
||||
|
|
|
|||
1064
mapAdminStage.nolog.js
Normal file
1064
mapAdminStage.nolog.js
Normal file
File diff suppressed because it is too large
Load diff
2168
mapFeatures.js
2168
mapFeatures.js
File diff suppressed because it is too large
Load diff
955
mapFeaturesV2.js
Normal file
955
mapFeaturesV2.js
Normal file
|
|
@ -0,0 +1,955 @@
|
|||
import { INF, MAP_H, MAP_W, SIZE, clamp, fbm, hash2, indexOf, inside, pickEntities, rand, valueNoise } from "./mapUtils.js";
|
||||
import { distanceToNearest, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js";
|
||||
import { LANDUSE } from "./landuseCodes.js";
|
||||
|
||||
// Lightweight Human Geography V2
|
||||
// --------------------------------
|
||||
// This replaces the heavy iterative human stage with a sparse skeleton + raster
|
||||
// synthesis model:
|
||||
// 1. build terrain-derived human context once
|
||||
// 2. place villages/towns/cities by region quotas
|
||||
// 3. make sparse approximate transport paths without full-resolution A*
|
||||
// 4. synthesize population and land-use fields in one raster pass
|
||||
|
||||
export function generateMapFeatures(seed, terrain) {
|
||||
const {
|
||||
elevation,
|
||||
moisture,
|
||||
slope,
|
||||
sea,
|
||||
river,
|
||||
floodplain,
|
||||
plain,
|
||||
agriculture,
|
||||
ridgeField,
|
||||
valleyField,
|
||||
basinField,
|
||||
coastalLowland,
|
||||
flowAccum,
|
||||
arcSpineField,
|
||||
branchRidgeField,
|
||||
depositionalLowland,
|
||||
alluvialFanField,
|
||||
deltaField,
|
||||
portSuitability,
|
||||
crossingSuitability,
|
||||
passSuitability,
|
||||
prefectureMask,
|
||||
prefectureRegionId,
|
||||
naturalBarrierScore,
|
||||
} = terrain;
|
||||
|
||||
function regionIdAt(x, y) {
|
||||
if (!inside(x, y)) return -1;
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) return -1;
|
||||
if (prefectureMask?.[i]) return 0;
|
||||
const id = prefectureRegionId?.[i];
|
||||
return id !== undefined && id >= 0 ? id : -1;
|
||||
}
|
||||
|
||||
function inFocusedPrefecture(p) {
|
||||
return Boolean(p && inside(p.x, p.y) && prefectureMask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]);
|
||||
}
|
||||
|
||||
function localConfluenceScore(x, y) {
|
||||
let arms = 0;
|
||||
let strong = 0;
|
||||
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const rv = river[indexOf(nx, ny)];
|
||||
if (rv > 0.18) arms++;
|
||||
if (rv > 0.34) strong++;
|
||||
}
|
||||
return clamp((arms >= 3 ? 0.22 : arms === 2 ? 0.09 : 0) + strong * 0.04);
|
||||
}
|
||||
|
||||
// --- 1. Human context: one full raster pass -----------------------------
|
||||
const developable = new Float32Array(SIZE);
|
||||
const ruralSuitability = new Float32Array(SIZE);
|
||||
const townSuitability = new Float32Array(SIZE);
|
||||
const valleySettlement = new Float32Array(SIZE);
|
||||
const coastalSettlement = new Float32Array(SIZE);
|
||||
const confluenceField = new Float32Array(SIZE);
|
||||
const barrierCost = new Float32Array(SIZE);
|
||||
const corridorCost = new Float32Array(SIZE);
|
||||
const settlementCluster = new Float32Array(SIZE);
|
||||
const settlementScore = 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]) {
|
||||
barrierCost[i] = INF;
|
||||
corridorCost[i] = INF;
|
||||
continue;
|
||||
}
|
||||
const depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.62 + (deltaField?.[i] || 0) * 0.90;
|
||||
const highPenalty = Math.max(0, elevation[i] - 0.56);
|
||||
const lowSlope = clamp(1 - slope[i] * 2.3);
|
||||
const confluence = x > 0 && y > 0 && x < MAP_W - 1 && y < MAP_H - 1 ? localConfluenceScore(x, y) : 0;
|
||||
const spine = (arcSpineField?.[i] || 0) * 0.58 + (branchRidgeField?.[i] || 0) * 0.38;
|
||||
confluenceField[i] = confluence;
|
||||
|
||||
developable[i] = clamp(
|
||||
plain[i] * 0.34 +
|
||||
agriculture[i] * 0.24 +
|
||||
basinField[i] * 0.24 +
|
||||
valleyField[i] * 0.24 +
|
||||
coastalLowland[i] * 0.18 +
|
||||
depositional * 0.22 +
|
||||
lowSlope * 0.10 -
|
||||
slope[i] * 0.82 -
|
||||
ridgeField[i] * 0.52 -
|
||||
spine * 0.24 -
|
||||
highPenalty * 1.14 -
|
||||
floodplain[i] * 0.03
|
||||
);
|
||||
valleySettlement[i] = clamp(
|
||||
valleyField[i] * 0.52 +
|
||||
river[i] * 0.08 +
|
||||
confluence * 0.38 +
|
||||
depositional * 0.20 +
|
||||
basinField[i] * 0.16 +
|
||||
plain[i] * 0.08 +
|
||||
lowSlope * 0.12 -
|
||||
slope[i] * 0.54 -
|
||||
ridgeField[i] * 0.30 -
|
||||
spine * 0.16 -
|
||||
highPenalty * 0.70 -
|
||||
floodplain[i] * 0.10
|
||||
);
|
||||
coastalSettlement[i] = clamp(
|
||||
coastalLowland[i] * 0.50 +
|
||||
(portSuitability?.[i] || 0) * 0.30 +
|
||||
(deltaField?.[i] || 0) * 0.20 +
|
||||
plain[i] * 0.10 -
|
||||
slope[i] * 0.52 -
|
||||
ridgeField[i] * 0.24 -
|
||||
spine * 0.12
|
||||
);
|
||||
const clusterNoise = 0.72 + fbm(x * 0.34 + 13, y * 0.34 - 31, seed + 7001) * 0.46 + valueNoise(x, y, seed + 7002, 8) * 0.16;
|
||||
settlementCluster[i] = clamp((developable[i] * 0.52 + valleySettlement[i] * 0.28 + coastalSettlement[i] * 0.18 + agriculture[i] * 0.22) * clusterNoise);
|
||||
ruralSuitability[i] = clamp(
|
||||
agriculture[i] * 0.42 +
|
||||
developable[i] * 0.28 +
|
||||
valleySettlement[i] * 0.24 +
|
||||
coastalSettlement[i] * 0.15 +
|
||||
settlementCluster[i] * 0.24 -
|
||||
Math.max(0, elevation[i] - 0.64) * 0.56
|
||||
);
|
||||
townSuitability[i] = clamp(
|
||||
developable[i] * 0.40 +
|
||||
valleySettlement[i] * 0.26 +
|
||||
coastalSettlement[i] * 0.20 +
|
||||
confluence * 0.34 +
|
||||
basinField[i] * 0.16 +
|
||||
plain[i] * 0.12 +
|
||||
settlementCluster[i] * 0.16 -
|
||||
slope[i] * 0.34 -
|
||||
ridgeField[i] * 0.17 -
|
||||
spine * 0.10
|
||||
);
|
||||
settlementScore[i] = clamp(ruralSuitability[i] * 0.58 + townSuitability[i] * 0.34 + confluence * 0.10);
|
||||
const naturalBarrier = naturalBarrierScore?.[i] || 0;
|
||||
barrierCost[i] = 1 + slope[i] * 6.4 + ridgeField[i] * 3.2 + spine * 2.2 + highPenalty * 5.8 + river[i] * 0.25 + naturalBarrier * 1.2 - valleyField[i] * 0.55 - plain[i] * 0.30 - coastalLowland[i] * 0.14;
|
||||
corridorCost[i] = Math.max(0.25, barrierCost[i] - developable[i] * 0.42 - valleySettlement[i] * 0.36 - coastalSettlement[i] * 0.12 + hash2(x, y, seed + 7011) * 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
// --- region statistics ---------------------------------------------------
|
||||
const regionStats = new Map();
|
||||
function ensureRegion(regionId) {
|
||||
let st = regionStats.get(regionId);
|
||||
if (!st) {
|
||||
st = {
|
||||
id: regionId,
|
||||
area: 0,
|
||||
developableCells: 0,
|
||||
developableSum: 0,
|
||||
valleyCells: 0,
|
||||
coastCells: 0,
|
||||
townCells: 0,
|
||||
plainCells: 0,
|
||||
minX: MAP_W,
|
||||
minY: MAP_H,
|
||||
maxX: 0,
|
||||
maxY: 0,
|
||||
};
|
||||
regionStats.set(regionId, st);
|
||||
}
|
||||
return st;
|
||||
}
|
||||
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 regionId = regionIdAt(x, y);
|
||||
if (regionId < 0) continue;
|
||||
const st = ensureRegion(regionId);
|
||||
st.area++;
|
||||
st.developableSum += developable[i];
|
||||
if (developable[i] > 0.16) st.developableCells++;
|
||||
if (valleySettlement[i] > 0.24) st.valleyCells++;
|
||||
if (coastalSettlement[i] > 0.25) st.coastCells++;
|
||||
if (townSuitability[i] > 0.28) st.townCells++;
|
||||
if (plain[i] > 0.24) st.plainCells++;
|
||||
st.minX = Math.min(st.minX, x);
|
||||
st.minY = Math.min(st.minY, y);
|
||||
st.maxX = Math.max(st.maxX, x);
|
||||
st.maxY = Math.max(st.maxY, y);
|
||||
}
|
||||
}
|
||||
|
||||
function visibilityFactor(regionId, st) {
|
||||
if (regionId === 0) return 1.15;
|
||||
if (!st || st.area <= 0) return 0;
|
||||
// Small map-edge slivers should not get the same municipal/human density
|
||||
// as full neighboring prefectures. This keeps external regions legible.
|
||||
return clamp(Math.sqrt(st.area / 1700), 0.28, 0.92);
|
||||
}
|
||||
|
||||
function pickRegionalPoints(scoreArray, {
|
||||
stride = 1,
|
||||
threshold = 0.25,
|
||||
minDistance = 6,
|
||||
totalMax = 100,
|
||||
seedOffset = 0,
|
||||
quotaForRegion,
|
||||
predicate = () => true,
|
||||
kind = "Point",
|
||||
extraScore = () => 0,
|
||||
}) {
|
||||
const byRegion = new Map();
|
||||
for (let y = 2; y < MAP_H - 2; y += stride) {
|
||||
for (let x = 2; x < MAP_W - 2; x += stride) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i] || !predicate(x, y, i)) continue;
|
||||
const regionId = regionIdAt(x, y);
|
||||
if (regionId < 0) continue;
|
||||
const score = scoreArray[i] + extraScore(x, y, i) + hash2(x, y, seed + seedOffset) * 0.055;
|
||||
if (score < threshold) continue;
|
||||
if (!byRegion.has(regionId)) byRegion.set(regionId, []);
|
||||
byRegion.get(regionId).push({ x, y, score, kind, regionId });
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
for (const [regionId, candidates] of [...byRegion.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
const st = regionStats.get(regionId);
|
||||
const quota = quotaForRegion ? quotaForRegion(regionId, st) : 0;
|
||||
if (quota <= 0) continue;
|
||||
out.push(...pickEntities(candidates, {
|
||||
max: quota,
|
||||
minDistance,
|
||||
threshold,
|
||||
seed: seed + seedOffset + regionId * 1009,
|
||||
jitter: 0.04,
|
||||
}));
|
||||
}
|
||||
return out.sort((a, b) => b.score - a.score).slice(0, totalMax);
|
||||
}
|
||||
|
||||
function pickGlobalPoints(scoreArray, { threshold, max, minDistance, seedOffset = 0, predicate = () => true, stride = 1 }) {
|
||||
const candidates = [];
|
||||
for (let y = 2; y < MAP_H - 2; y += stride) {
|
||||
for (let x = 2; x < MAP_W - 2; x += stride) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i] || !predicate(x, y, i)) continue;
|
||||
const score = scoreArray[i] + hash2(x, y, seed + seedOffset) * 0.07;
|
||||
if (score >= threshold) candidates.push({ x, y, score, regionId: regionIdAt(x, y) });
|
||||
}
|
||||
}
|
||||
return pickEntities(candidates, { max, minDistance, threshold, seed: seed + seedOffset });
|
||||
}
|
||||
|
||||
// --- 2. Sparse points ----------------------------------------------------
|
||||
let ports = pickGlobalPoints(portSuitability || coastalSettlement, {
|
||||
threshold: 0.30 + rand(seed, 1001) * 0.08,
|
||||
max: 10,
|
||||
minDistance: 13,
|
||||
seedOffset: 1000,
|
||||
predicate: (x, y, i) => coastalSettlement[i] > 0.14 || (portSuitability?.[i] || 0) > 0.25,
|
||||
}).map((p, n) => {
|
||||
const i = indexOf(p.x, p.y);
|
||||
const harborPotential = (portSuitability?.[i] || 0) + coastalLowland[i] * 0.22 + (deltaField?.[i] || 0) * 0.08 - slope[i] * 0.18;
|
||||
const portClass = n === 0 ? "major" : n < 3 && harborPotential > 0.34 ? "regional" : harborPotential > 0.24 ? "fishing" : "lake";
|
||||
const kind = portClass === "major" ? "Major Port" : portClass === "regional" ? "Regional Port" : portClass === "lake" ? "Lake Port" : "Fishing Port";
|
||||
return { ...p, harborPotential, portClass, kind, score: harborPotential };
|
||||
}).sort((a, b) => b.harborPotential - a.harborPotential);
|
||||
if (ports.length && !ports.some((p) => p.portClass === "major")) {
|
||||
ports[0].portClass = "major";
|
||||
ports[0].kind = "Major Port";
|
||||
}
|
||||
const commercialPorts = ports.filter((p) => p.portClass === "major" || p.portClass === "regional");
|
||||
|
||||
const crossings = pickGlobalPoints(crossingSuitability || confluenceField, {
|
||||
threshold: 0.30 + rand(seed, 1011) * 0.06,
|
||||
max: 18,
|
||||
minDistance: 9,
|
||||
seedOffset: 1010,
|
||||
predicate: (x, y, i) => river[i] > 0.12 || confluenceField[i] > 0.09,
|
||||
}).map((p) => ({ ...p, kind: "River Crossing" }));
|
||||
|
||||
const passes = pickGlobalPoints(passSuitability || valleySettlement, {
|
||||
threshold: 0.18 + rand(seed, 1021) * 0.06,
|
||||
max: 12,
|
||||
minDistance: 11,
|
||||
seedOffset: 1020,
|
||||
predicate: (x, y, i) => elevation[i] > 0.42 && !sea[i],
|
||||
}).map((p) => ({ ...p, kind: "Pass" }));
|
||||
|
||||
const villageScore = new Float32Array(SIZE);
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (sea[i]) continue;
|
||||
villageScore[i] = clamp(ruralSuitability[i] * 0.68 + valleySettlement[i] * 0.26 + coastalSettlement[i] * 0.18 + settlementCluster[i] * 0.08);
|
||||
}
|
||||
const villages = pickRegionalPoints(villageScore, {
|
||||
stride: 2,
|
||||
threshold: 0.25 + rand(seed, 1031) * 0.04,
|
||||
totalMax: 140,
|
||||
minDistance: 5,
|
||||
seedOffset: 1030,
|
||||
kind: "Village",
|
||||
quotaForRegion: (regionId, st) => {
|
||||
if (!st || st.developableCells < 10) return 0;
|
||||
const vf = visibilityFactor(regionId, st);
|
||||
const raw = (st.developableCells / 65 + st.valleyCells / 44 + st.coastCells / 55 + 1.2) * vf;
|
||||
const min = regionId === 0 ? 10 : st.area > 1100 ? 3 : st.area > 280 ? 1 : 0;
|
||||
const max = regionId === 0 ? 30 : st.area > 1800 ? 13 : st.area > 600 ? 7 : 3;
|
||||
return Math.round(clamp(raw + rand(seed, 1033 + regionId * 19) * 1.5, min, max));
|
||||
},
|
||||
}).map((p, n) => {
|
||||
const i = indexOf(p.x, p.y);
|
||||
const kind = valleySettlement[i] > 0.42 ? "Valley Village" : coastalSettlement[i] > 0.43 ? "Coastal Village" : "Village";
|
||||
const population = Math.round((300 + Math.pow(rand(seed, 18000 + n * 17 + p.x * 3 + p.y), 1.85) * 4700 + ruralSuitability[i] * 2600) / 100) * 100;
|
||||
return { ...p, kind, population };
|
||||
});
|
||||
|
||||
const villageInfluence = influenceFromPoints(villages, 6, (v) => clamp((v.population || 1800) / 4200, 0.35, 1.2));
|
||||
|
||||
const marketScore = new Float32Array(SIZE);
|
||||
for (let y = 2; y < MAP_H - 2; y++) {
|
||||
for (let x = 2; x < MAP_W - 2; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const featurePull = Math.max(
|
||||
distanceToNearest(ports, x, y) < 8 ? 0.10 : 0,
|
||||
distanceToNearest(crossings, x, y) < 6 ? 0.06 : 0,
|
||||
confluenceField[i] * 0.16
|
||||
);
|
||||
const valleyMouth = valleyField[i] > 0.22 && (plain[i] > 0.22 || basinField[i] > 0.18 || coastalLowland[i] > 0.18) ? 0.14 : 0;
|
||||
marketScore[i] = clamp(
|
||||
townSuitability[i] * 0.62 +
|
||||
villageInfluence[i] * 0.38 +
|
||||
featurePull +
|
||||
valleyMouth +
|
||||
basinField[i] * 0.12 +
|
||||
plain[i] * 0.14 +
|
||||
coastalLowland[i] * 0.08 -
|
||||
slope[i] * 0.18 -
|
||||
ridgeField[i] * 0.08
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const markets = pickRegionalPoints(marketScore, {
|
||||
stride: 2,
|
||||
threshold: 0.31 + rand(seed, 1041) * 0.045,
|
||||
totalMax: 52,
|
||||
minDistance: 9,
|
||||
seedOffset: 1040,
|
||||
kind: "Market Town",
|
||||
quotaForRegion: (regionId, st) => {
|
||||
if (!st || st.townCells < 8) return 0;
|
||||
const vf = visibilityFactor(regionId, st);
|
||||
const raw = (st.developableCells / 260 + st.valleyCells / 150 + st.coastCells / 160 + 0.8) * vf;
|
||||
const min = regionId === 0 ? 4 : st.area > 1300 ? 1 : 0;
|
||||
const max = regionId === 0 ? 11 : st.area > 1800 ? 5 : st.area > 650 ? 3 : 1;
|
||||
return Math.round(clamp(raw + rand(seed, 1043 + regionId * 23) * 0.8, min, max));
|
||||
},
|
||||
extraScore: (x, y, i) => (distanceToNearest(commercialPorts, x, y) < 8 ? 0.07 : 0) + confluenceField[i] * 0.08,
|
||||
}).map((p, n) => {
|
||||
const i = indexOf(p.x, p.y);
|
||||
const kind = coastalSettlement[i] > 0.45 && distanceToNearest(ports, p.x, p.y) < 9 ? "Port Town" : valleySettlement[i] > 0.42 ? "Valley Market Town" : "Market Town";
|
||||
const population = Math.round((4000 + Math.pow(rand(seed, 18100 + n * 19 + p.x * 5 + p.y), 1.50) * 24000 + marketScore[i] * 13000) / 1000) * 1000;
|
||||
return { ...p, kind, population };
|
||||
});
|
||||
|
||||
const defenseScore = new Float32Array(SIZE);
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (sea[i]) continue;
|
||||
defenseScore[i] = clamp(
|
||||
confluenceField[i] * 0.38 +
|
||||
townSuitability[i] * 0.16 +
|
||||
ridgeField[i] * clamp(1 - Math.abs(elevation[i] - 0.52) / 0.25) * 0.42 +
|
||||
plain[i] * 0.08 -
|
||||
floodplain[i] * 0.36 -
|
||||
coastalLowland[i] * 0.08
|
||||
);
|
||||
}
|
||||
const castles = pickGlobalPoints(defenseScore, {
|
||||
threshold: 0.34 + rand(seed, 1051) * 0.06,
|
||||
max: 5,
|
||||
minDistance: 16,
|
||||
seedOffset: 1050,
|
||||
}).map((p) => ({
|
||||
...p,
|
||||
kind: elevation[indexOf(p.x, p.y)] > 0.55 ? "Mountain Castle" : elevation[indexOf(p.x, p.y)] > 0.38 ? "Hilltop Castle" : "Flatland Castle",
|
||||
}));
|
||||
|
||||
const castleTowns = castles.map((c, n) => {
|
||||
const near = markets.slice().sort((a, b) => Math.hypot(a.x - c.x, a.y - c.y) - Math.hypot(b.x - c.x, b.y - c.y))[0];
|
||||
const x = near && Math.hypot(near.x - c.x, near.y - c.y) < 10 ? near.x : c.x;
|
||||
const y = near && Math.hypot(near.x - c.x, near.y - c.y) < 10 ? near.y : c.y;
|
||||
return { x, y, score: c.score, kind: "Castle Town", population: 12000 + Math.round(rand(seed, 1060 + n) * 22000 / 1000) * 1000, regionId: regionIdAt(x, y) };
|
||||
});
|
||||
|
||||
// --- 3. Cities by region, without detailed urban flood-fill --------------
|
||||
function estimateUrbanCapacity(p, radius = 22, densityBias = 1.0) {
|
||||
if (!p || !inside(p.x, p.y)) return 0;
|
||||
const centerRegion = regionIdAt(p.x, p.y);
|
||||
let capacity = 0;
|
||||
const r = Math.ceil(radius);
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const x = p.x + dx;
|
||||
const y = p.y + dy;
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
if (centerRegion >= 0 && regionIdAt(x, y) !== centerRegion) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > radius) continue;
|
||||
const dev = developable[i];
|
||||
if (dev < 0.04) continue;
|
||||
const radial = clamp(1 - d / Math.max(1, radius));
|
||||
const terrainMultiplier = clamp(0.60 + plain[i] * 0.28 + basinField[i] * 0.18 + coastalLowland[i] * 0.16 + valleyField[i] * 0.13 - slope[i] * 0.42 - ridgeField[i] * 0.18, 0.26, 1.24);
|
||||
capacity += dev * (900 + 6200 * Math.pow(radial, 1.25)) * terrainMultiplier * densityBias;
|
||||
}
|
||||
}
|
||||
return Math.max(26000, Math.round(capacity / 1000) * 1000);
|
||||
}
|
||||
|
||||
const urbanCandidates = [
|
||||
...markets.map((p) => ({ ...p, candidateKind: "town" })),
|
||||
...castleTowns.map((p) => ({ ...p, candidateKind: "castleTown" })),
|
||||
...commercialPorts.map((p) => ({ ...p, candidateKind: "port" })),
|
||||
...crossings.filter((p) => confluenceField[indexOf(p.x, p.y)] > 0.12).map((p) => ({ ...p, candidateKind: "crossing" })),
|
||||
];
|
||||
|
||||
const cityCandidateByRegion = new Map();
|
||||
for (const p of urbanCandidates) {
|
||||
const i = indexOf(p.x, p.y);
|
||||
const regionId = regionIdAt(p.x, p.y);
|
||||
if (regionId < 0) continue;
|
||||
const capacity = estimateUrbanCapacity(p, regionId === 0 ? 30 : 24, regionId === 0 ? 1.12 : 1.0);
|
||||
const score =
|
||||
Math.log10(capacity + 1) * 0.72 +
|
||||
townSuitability[i] * 1.40 +
|
||||
developable[i] * 1.05 +
|
||||
confluenceField[i] * 0.22 +
|
||||
(p.candidateKind === "port" ? 0.48 : 0) +
|
||||
(p.candidateKind === "castleTown" ? 0.22 : 0) +
|
||||
hash2(p.x, p.y, seed + 12000) * 0.16;
|
||||
if (!cityCandidateByRegion.has(regionId)) cityCandidateByRegion.set(regionId, []);
|
||||
cityCandidateByRegion.get(regionId).push({ ...p, score, capacity, regionId });
|
||||
}
|
||||
|
||||
const modernCities = [];
|
||||
const usedCitySites = [];
|
||||
for (const [regionId, list] of [...cityCandidateByRegion.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
const st = regionStats.get(regionId);
|
||||
if (!st || st.developableCells < 30) continue;
|
||||
const vf = visibilityFactor(regionId, st);
|
||||
const maxCities = regionId === 0
|
||||
? clamp(Math.round(3 + st.developableCells / 520 + rand(seed, 12100) * 2), 5, 9)
|
||||
: clamp(Math.round((st.developableCells / 850 + 0.8) * vf), st.area > 1500 ? 1 : 0, st.area > 2600 ? 4 : st.area > 950 ? 2 : 1);
|
||||
const selected = pickEntities(list, {
|
||||
max: maxCities,
|
||||
minDistance: regionId === 0 ? 16 : 18,
|
||||
threshold: 0,
|
||||
seed: seed + 12110 + regionId * 313,
|
||||
jitter: 0.02,
|
||||
});
|
||||
for (const p of selected) {
|
||||
if (usedCitySites.some((q) => Math.hypot(q.x - p.x, q.y - p.y) < 12)) continue;
|
||||
usedCitySites.push(p);
|
||||
modernCities.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
if (!modernCities.some((p) => inFocusedPrefecture(p))) {
|
||||
const focusCandidates = [...markets, ...commercialPorts, ...villages].filter((p) => inFocusedPrefecture(p));
|
||||
let fallback = focusCandidates.sort((a, b) => {
|
||||
const ai = indexOf(a.x, a.y);
|
||||
const bi = indexOf(b.x, b.y);
|
||||
return (townSuitability[bi] + developable[bi]) - (townSuitability[ai] + developable[ai]);
|
||||
})[0];
|
||||
if (!fallback) {
|
||||
let best = null;
|
||||
let bestScore = -INF;
|
||||
for (let y = 2; y < MAP_H - 2; y += 2) {
|
||||
for (let x = 2; x < MAP_W - 2; x += 2) {
|
||||
const i = indexOf(x, y);
|
||||
if (!prefectureMask[i] || sea[i]) continue;
|
||||
const score = townSuitability[i] + developable[i] + hash2(x, y, seed + 12199) * 0.04;
|
||||
if (score > bestScore) { bestScore = score; best = { x, y, score, kind: "Local City", regionId: 0 }; }
|
||||
}
|
||||
}
|
||||
fallback = best;
|
||||
}
|
||||
if (fallback) modernCities.push({
|
||||
...fallback,
|
||||
candidateKind: fallback.candidateKind || "fallback",
|
||||
score: fallback.score || 0.5,
|
||||
capacity: estimateUrbanCapacity(fallback, 30, 1.15),
|
||||
regionId: 0,
|
||||
});
|
||||
}
|
||||
|
||||
modernCities.sort((a, b) => b.capacity - a.capacity || b.score - a.score);
|
||||
for (const [rank, city] of modernCities.entries()) {
|
||||
const isFocused = inFocusedPrefecture(city);
|
||||
const isPrefecturalCapital = isFocused && !modernCities.slice(0, rank).some((c) => c.isPrefecturalCapital);
|
||||
const isRegionalCapital = !isFocused && !modernCities.slice(0, rank).some((c) => c.regionId === city.regionId && c.isRegionalCapital);
|
||||
const rawPop = isPrefecturalCapital
|
||||
? 450000 + rand(seed, 12200) * 1150000
|
||||
: isRegionalCapital
|
||||
? 160000 + rand(seed, 12201 + city.regionId * 17) * 460000
|
||||
: 32000 + Math.pow(rand(seed, 12202 + rank * 19 + city.x), 0.7) * 260000;
|
||||
const capMultiplier = isPrefecturalCapital ? 1.22 : isRegionalCapital ? 1.08 : 1.0;
|
||||
const population = Math.round(Math.min(rawPop, city.capacity * capMultiplier) / 1000) * 1000;
|
||||
city.population = Math.max(isPrefecturalCapital ? 260000 : isRegionalCapital ? 90000 : 24000, population);
|
||||
city.isPrefecturalCapital = isPrefecturalCapital;
|
||||
city.isRegionalCapital = isRegionalCapital;
|
||||
city.rank = isPrefecturalCapital ? "Prefectural Capital" : isRegionalCapital ? "Regional Capital" : city.population >= 200000 ? "Regional City" : "Local City";
|
||||
city.kind = city.rank;
|
||||
city.urbanRadius = clamp(5.8 + Math.sqrt(city.population) / 72, 8, isPrefecturalCapital ? 40 : isRegionalCapital ? 32 : 24);
|
||||
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 380, 2.2, isPrefecturalCapital ? 8.5 : 6.5);
|
||||
city.sprawlRadius = clamp(city.urbanRadius * (isPrefecturalCapital ? 1.65 : isRegionalCapital ? 1.45 : city.population >= 120000 ? 1.28 : 1.15), city.urbanRadius + 2, isPrefecturalCapital ? 56 : isRegionalCapital ? 42 : 30);
|
||||
city.urbanWeight = clamp(0.95 + Math.log10(Math.max(10000, city.population)) * 0.29, 1.08, 2.5);
|
||||
}
|
||||
|
||||
function cityPopulationCap(city) {
|
||||
const radius = city?.isPrefecturalCapital ? 34 : city?.isRegionalCapital ? 29 : city?.population >= 350000 ? 26 : 18;
|
||||
const bias = city?.isPrefecturalCapital ? 1.25 : city?.isRegionalCapital ? 1.12 : 1.0;
|
||||
return estimateUrbanCapacity(city, radius, bias);
|
||||
}
|
||||
|
||||
// --- 4. Lightweight corridors -------------------------------------------
|
||||
function routeLight(a, b, snapRadius = 3) {
|
||||
if (!a || !b) return [];
|
||||
const steps = Math.max(2, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y) * 1.15));
|
||||
const out = [];
|
||||
let lastKey = "";
|
||||
for (let s = 0; s <= steps; s++) {
|
||||
const t = s / steps;
|
||||
const fx = a.x + (b.x - a.x) * t;
|
||||
const fy = a.y + (b.y - a.y) * t;
|
||||
let best = null;
|
||||
let bestCost = INF;
|
||||
const radius = snapRadius + (s > 0 && s < steps ? 1 : 0);
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
const x = Math.round(fx + dx);
|
||||
const y = Math.round(fy + dy);
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const lineDist = Math.hypot(x - fx, y - fy);
|
||||
const cost = lineDist * 0.72 + corridorCost[i] * 0.62 - valleySettlement[i] * 0.34 - developable[i] * 0.18 + hash2(x, y, seed + 13000 + s) * 0.05;
|
||||
if (cost < bestCost) {
|
||||
bestCost = cost;
|
||||
best = [x, y];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!best) best = [Math.round(fx), Math.round(fy)];
|
||||
const key = `${best[0]},${best[1]}`;
|
||||
if (key !== lastKey) {
|
||||
out.push(best);
|
||||
lastKey = key;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function importantNodesForRegion(regionId) {
|
||||
const inRegion = (p) => regionIdAt(p.x, p.y) === regionId;
|
||||
return [
|
||||
...modernCities.filter(inRegion).map((p) => ({ ...p, nodeWeight: 8 + (p.population || 0) / 120000 })),
|
||||
...markets.filter(inRegion).map((p) => ({ ...p, nodeWeight: 3.2 + (p.population || 0) / 25000 })),
|
||||
...commercialPorts.filter(inRegion).map((p) => ({ ...p, nodeWeight: p.portClass === "major" ? 6.5 : 4.6 })),
|
||||
...passes.filter(inRegion).map((p) => ({ ...p, nodeWeight: 2.2 })),
|
||||
].sort((a, b) => b.nodeWeight - a.nodeWeight).slice(0, regionId === 0 ? 18 : 10);
|
||||
}
|
||||
|
||||
const premodernRoads = [];
|
||||
const nationalRoads = [];
|
||||
const minorRoads = [];
|
||||
const railways = [];
|
||||
const branchRailways = [];
|
||||
const externalRoads = [];
|
||||
const externalRailways = [];
|
||||
const expressways = [];
|
||||
const ringRoads = [];
|
||||
const ringRailways = [];
|
||||
const ringExpressways = [];
|
||||
const externalExpressways = [];
|
||||
const icAccessRoads = [];
|
||||
const externalGateways = [];
|
||||
|
||||
// Premodern roads connect castles/markets/ports sparsely.
|
||||
for (const c of castles) {
|
||||
const near = [...markets, ...ports, ...crossings].sort((a, b) => Math.hypot(a.x - c.x, a.y - c.y) - Math.hypot(b.x - c.x, b.y - c.y)).slice(0, 2);
|
||||
for (const n of near) {
|
||||
const path = routeLight(c, n, 2);
|
||||
if (path.length > 2) premodernRoads.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) {
|
||||
const nodes = importantNodesForRegion(regionId);
|
||||
if (nodes.length < 2) continue;
|
||||
const connected = [nodes[0]];
|
||||
const remaining = nodes.slice(1);
|
||||
const maxEdges = regionId === 0 ? Math.min(14, nodes.length + 3) : Math.min(7, nodes.length + 1);
|
||||
while (remaining.length && nationalRoads.length < 48) {
|
||||
let best = null;
|
||||
let bestScore = INF;
|
||||
for (const a of connected) {
|
||||
for (const b of remaining) {
|
||||
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
||||
const score = d - (a.nodeWeight + b.nodeWeight) * 0.9;
|
||||
if (score < bestScore) { bestScore = score; best = { a, b }; }
|
||||
}
|
||||
}
|
||||
if (!best) break;
|
||||
const path = routeLight(best.a, best.b, 3);
|
||||
if (path.length > 2) nationalRoads.push(path);
|
||||
connected.push(best.b);
|
||||
remaining.splice(remaining.indexOf(best.b), 1);
|
||||
if (connected.length - 1 >= maxEdges) break;
|
||||
}
|
||||
|
||||
// A few k-nearest shortcuts for urbanized regions.
|
||||
const urbanNodes = nodes.filter((p) => p.population || p.portClass).slice(0, regionId === 0 ? 8 : 4);
|
||||
for (let i = 0; i < urbanNodes.length; i++) {
|
||||
const a = urbanNodes[i];
|
||||
const b = urbanNodes.slice(i + 1).sort((p, q) => Math.hypot(a.x - p.x, a.y - p.y) - Math.hypot(a.x - q.x, a.y - q.y))[0];
|
||||
if (!b || Math.hypot(a.x - b.x, a.y - b.y) > 48) continue;
|
||||
const path = routeLight(a, b, 3);
|
||||
if (path.length > 2) nationalRoads.push(path);
|
||||
}
|
||||
|
||||
// Railways: only high-order cities/ports, as a lightweight placeholder.
|
||||
const railNodes = nodes.filter((p) => (p.population || 0) > 80000 || p.portClass === "major" || p.portClass === "regional").slice(0, regionId === 0 ? 7 : 4);
|
||||
railNodes.sort((a, b) => a.x - b.x || a.y - b.y);
|
||||
for (let i = 1; i < railNodes.length; i++) {
|
||||
const path = routeLight(railNodes[i - 1], railNodes[i], 4);
|
||||
if (path.length > 4) railways.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
// External gateways at land edges; used by naming/UI and later transport work.
|
||||
for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) {
|
||||
const st = regionStats.get(regionId);
|
||||
if (!st || st.area < 140) continue;
|
||||
const edgeCandidates = [];
|
||||
for (let y = st.minY; y <= st.maxY; y += 3) {
|
||||
for (const x of [st.minX, st.maxX]) {
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (!sea[i] && regionIdAt(x, y) === regionId && (x < 5 || y < 5 || x > MAP_W - 6 || y > MAP_H - 6)) edgeCandidates.push({ x, y, score: developable[i] + valleySettlement[i] });
|
||||
}
|
||||
}
|
||||
for (let x = st.minX; x <= st.maxX; x += 3) {
|
||||
for (const y of [st.minY, st.maxY]) {
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (!sea[i] && regionIdAt(x, y) === regionId && (x < 5 || y < 5 || x > MAP_W - 6 || y > MAP_H - 6)) edgeCandidates.push({ x, y, score: developable[i] + valleySettlement[i] });
|
||||
}
|
||||
}
|
||||
const gateway = pickEntities(edgeCandidates, { max: regionId === 0 ? 2 : 1, minDistance: 16, seed: seed + 13200 + regionId * 11 })[0];
|
||||
if (gateway) {
|
||||
gateway.kind = "External Gateway";
|
||||
gateway.regionId = regionId;
|
||||
externalGateways.push(gateway);
|
||||
const target = importantNodesForRegion(regionId)[0];
|
||||
if (target) {
|
||||
const path = routeLight(gateway, target, 3);
|
||||
if (path.length > 2) externalRoads.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Approximate expressways as a very small subset of top inter-city links.
|
||||
const topCities = modernCities.slice().sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, 6);
|
||||
for (let i = 1; i < topCities.length && expressways.length < 4; i++) {
|
||||
const a = topCities[i - 1];
|
||||
const b = topCities[i];
|
||||
if (Math.hypot(a.x - b.x, a.y - b.y) < 85) {
|
||||
const path = routeLight(a, b, 5);
|
||||
if (path.length > 5) expressways.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
const roadInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads, ...expressways], 5);
|
||||
const railInfluence2 = influenceFromPaths([...railways, ...branchRailways, ...externalRailways], 4);
|
||||
|
||||
const stations = [];
|
||||
const usedStationKeys = new Set();
|
||||
function addStation(x, y, kind = "Station", score = 1) {
|
||||
x = Math.round(x); y = Math.round(y);
|
||||
if (!inside(x, y) || sea[indexOf(x, y)]) return;
|
||||
const key = `${x},${y}`;
|
||||
if (usedStationKeys.has(key)) return;
|
||||
usedStationKeys.add(key);
|
||||
stations.push({ x, y, kind, score, regionId: regionIdAt(x, y) });
|
||||
}
|
||||
for (const city of modernCities) addStation(city.x, city.y, city.isPrefecturalCapital || city.isRegionalCapital ? "Major Station" : "Station", 1.5);
|
||||
for (const path of railways) for (const p of samplePath(path, 14)) addStation(p.x, p.y, "Station", 0.8);
|
||||
const stationInfluence = influenceFromPoints(stations, 7, (s) => s.kind === "Major Station" ? 1.35 : 0.85);
|
||||
|
||||
// --- 5. Approximate city/town influence and land-use ---------------------
|
||||
const cityInfluence = new Float32Array(SIZE);
|
||||
const coreInfluence = new Float32Array(SIZE);
|
||||
const oldTownInfluence = influenceFromPoints([...markets, ...castleTowns, ...ports], 7, (p) => p.kind === "Major Port" ? 1.2 : 0.9);
|
||||
const populationDensity = new Float32Array(SIZE);
|
||||
|
||||
function addKernel(grid, p, radius, weight, exponent = 1.7, terrainWeighted = true, combine = "max") {
|
||||
const r = Math.ceil(radius);
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const x = p.x + dx;
|
||||
const y = p.y + dy;
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > radius) continue;
|
||||
const terrain = terrainWeighted ? clamp(0.24 + developable[i] * 1.00 + valleySettlement[i] * 0.16 + coastalSettlement[i] * 0.10 - slope[i] * 0.20 - ridgeField[i] * 0.12 + roadInfluence[i] * 0.08 + railInfluence2[i] * 0.06, 0, 1.34) : 1;
|
||||
const v = weight * Math.pow(1 - d / Math.max(1, radius), exponent) * terrain;
|
||||
if (combine === "add") grid[i] = Math.min(3.4, grid[i] + v);
|
||||
else if (v > grid[i]) grid[i] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const city of modernCities) {
|
||||
addKernel(cityInfluence, city, city.sprawlRadius || Math.round((city.urbanRadius || 10) * 1.4), (city.urbanWeight || 1.0) * (city.isPrefecturalCapital ? 0.46 : city.isRegionalCapital ? 0.38 : 0.30), 2.75, true, "add");
|
||||
addKernel(cityInfluence, city, city.urbanRadius || 10, city.urbanWeight || 1.0, 1.18, true, "add");
|
||||
addKernel(coreInfluence, city, city.coreRadius || 3, (city.urbanWeight || 1.0) * 1.10, 1.65, true, "max");
|
||||
}
|
||||
const townInfluence = influenceFromPoints(markets, 6, (m) => clamp((m.population || 10000) / 26000, 0.45, 1.25));
|
||||
|
||||
// Industrial/logistics/new town placeholders remain lightweight. They are
|
||||
// routed by land-use proximity rather than expensive search passes.
|
||||
const industrialZones = [];
|
||||
for (const p of [...commercialPorts, ...modernCities.slice(0, 5)]) {
|
||||
const candidates = [];
|
||||
for (let dy = -10; dy <= 10; dy++) {
|
||||
for (let dx = -10; dx <= 10; dx++) {
|
||||
const x = p.x + dx;
|
||||
const y = p.y + dy;
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d < 3 || d > 10) continue;
|
||||
const score = coastalLowland[i] * 0.22 + developable[i] * 0.22 + roadInfluence[i] * 0.20 + plain[i] * 0.12 - slope[i] * 0.25 + hash2(x, y, seed + 14000) * 0.06;
|
||||
if (score > 0.22) candidates.push({ x, y, score, kind: "Industrial Zone", regionId: regionIdAt(x, y) });
|
||||
}
|
||||
}
|
||||
const z = pickEntities(candidates, { max: 1, minDistance: 6, seed: seed + 14010 + p.x * 3 + p.y })[0];
|
||||
if (z && industrialZones.every((q) => Math.hypot(q.x - z.x, q.y - z.y) > 13)) industrialZones.push(z);
|
||||
if (industrialZones.length >= 8) break;
|
||||
}
|
||||
const industrialInfluence = influenceFromPoints(industrialZones, 5, () => 1.0);
|
||||
|
||||
const satelliteCities = [];
|
||||
const newTowns = [];
|
||||
const logisticsParks = [];
|
||||
const interchanges = [];
|
||||
var landuse = new Uint8Array(SIZE);
|
||||
|
||||
// Re-run land-use classification after landuse allocation. The loop above is
|
||||
// intentionally inside a helper to keep all thresholds in one place.
|
||||
function classifyLanduse() {
|
||||
landuse.fill(LANDUSE.RURAL);
|
||||
let maxDensity = 0;
|
||||
const baseNoiseSeed = seed + 15000;
|
||||
const urbanCapacity = 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;
|
||||
const transport = Math.max(roadInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82);
|
||||
const urban = cityInfluence[i] * 0.76 + stationInfluence[i] * 0.26 + roadInfluence[i] * 0.12 + railInfluence2[i] * 0.10;
|
||||
const core = coreInfluence[i];
|
||||
const oldTown = oldTownInfluence[i] * 0.70 + townInfluence[i] * 0.38;
|
||||
const rural = villageInfluence[i] * 0.24 + ruralSuitability[i] * 0.30;
|
||||
const riverUrban = clamp(river[i] * 0.12 + valleyField[i] * 0.10 + plain[i] * 0.08 + basinField[i] * 0.08 - floodplain[i] * 0.10);
|
||||
urbanCapacity[i] = clamp(
|
||||
developable[i] * 0.66 +
|
||||
plain[i] * 0.16 +
|
||||
basinField[i] * 0.16 +
|
||||
valleyField[i] * 0.16 +
|
||||
coastalLowland[i] * 0.12 +
|
||||
transport * 0.18 +
|
||||
riverUrban * 0.14 -
|
||||
slope[i] * 0.18 -
|
||||
ridgeField[i] * 0.12 -
|
||||
floodplain[i] * 0.08
|
||||
);
|
||||
populationDensity[i] = clamp(urban * 0.66 + core * 0.46 + oldTown * 0.28 + townInfluence[i] * 0.16 + villageInfluence[i] * 0.14 + transport * 0.12);
|
||||
maxDensity = Math.max(maxDensity, populationDensity[i]);
|
||||
|
||||
if (elevation[i] > 0.67 || (slope[i] > 0.56 && ridgeField[i] > 0.30) || ridgeField[i] > 0.70) {
|
||||
landuse[i] = LANDUSE.FOREST;
|
||||
continue;
|
||||
}
|
||||
if (industrialInfluence[i] > 0.22 && urbanCapacity[i] > 0.10) {
|
||||
landuse[i] = LANDUSE.INDUSTRIAL;
|
||||
continue;
|
||||
}
|
||||
if (core > 0.38 && urbanCapacity[i] > 0.10) {
|
||||
landuse[i] = LANDUSE.CBD;
|
||||
continue;
|
||||
}
|
||||
if (oldTown > 0.18 && urbanCapacity[i] > 0.09) {
|
||||
landuse[i] = LANDUSE.OLD_URBAN;
|
||||
continue;
|
||||
}
|
||||
|
||||
const suburbanity = urban * 0.88 + transport * 0.23 + stationInfluence[i] * 0.12 + townInfluence[i] * 0.08 + riverUrban * 0.08;
|
||||
const edgeTaper = clamp(cityInfluence[i] * 0.52 + stationInfluence[i] * 0.16 + roadInfluence[i] * 0.10 + 0.28);
|
||||
const sprawlBias = clamp(0.58 + hash2(x, y, baseNoiseSeed) * 0.42);
|
||||
const sprawlScore = suburbanity * sprawlBias * edgeTaper - core * 0.12;
|
||||
if (sprawlScore > 0.24 && urbanCapacity[i] > 0.10) {
|
||||
landuse[i] = LANDUSE.SUBURB;
|
||||
} else if (transport > 0.18 && urbanCapacity[i] > 0.10 && (townInfluence[i] > 0.04 || cityInfluence[i] > 0.09)) {
|
||||
landuse[i] = transport > 0.28 && stationInfluence[i] > 0.10 ? LANDUSE.SUBURB : LANDUSE.ROADSIDE;
|
||||
} else if (agriculture[i] > 0.24 || rural > 0.22 || (developable[i] > 0.18 && plain[i] > 0.18)) {
|
||||
landuse[i] = LANDUSE.FARMLAND;
|
||||
} else {
|
||||
landuse[i] = elevation[i] > 0.52 || slope[i] > 0.34 ? LANDUSE.FOREST : LANDUSE.RURAL;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const baseLanduse = landuse.slice();
|
||||
const isBuilt = (lu) => lu >= LANDUSE.OLD_URBAN && lu <= LANDUSE.ROADSIDE;
|
||||
for (let y = 1; y < MAP_H - 1; y++) {
|
||||
for (let x = 1; x < MAP_W - 1; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i] || baseLanduse[i] === LANDUSE.FOREST) continue;
|
||||
const transport = Math.max(roadInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82);
|
||||
let urbanNeighbors = 0;
|
||||
let cbdNeighbors = 0;
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
if (!dx && !dy) continue;
|
||||
const lu = baseLanduse[indexOf(x + dx, y + dy)];
|
||||
if (isBuilt(lu)) urbanNeighbors++;
|
||||
if (lu === LANDUSE.CBD) cbdNeighbors++;
|
||||
}
|
||||
}
|
||||
if (baseLanduse[i] === LANDUSE.OLD_URBAN && coreInfluence[i] > 0.31 && cbdNeighbors >= 3) {
|
||||
landuse[i] = LANDUSE.CBD;
|
||||
continue;
|
||||
}
|
||||
if ((baseLanduse[i] === LANDUSE.FARMLAND || baseLanduse[i] === LANDUSE.RURAL) && urbanCapacity[i] > 0.10) {
|
||||
const fringeChance = urbanNeighbors * 0.055 + cityInfluence[i] * 0.13 + transport * 0.12 + stationInfluence[i] * 0.08;
|
||||
const noise = 0.23 + hash2(x, y, seed + 15050) * 0.24;
|
||||
if (fringeChance > 0.34 + noise) {
|
||||
landuse[i] = urbanNeighbors >= 4 || transport > 0.28 ? LANDUSE.SUBURB : LANDUSE.ROADSIDE;
|
||||
}
|
||||
}
|
||||
if ((river[i] > 0.10 || railInfluence2[i] > 0.16 || roadInfluence[i] > 0.22) && urbanNeighbors >= 3 && landuse[i] <= LANDUSE.FARMLAND && urbanCapacity[i] > 0.09) {
|
||||
landuse[i] = cbdNeighbors >= 1 || coreInfluence[i] > 0.15 ? LANDUSE.OLD_URBAN : LANDUSE.SUBURB;
|
||||
}
|
||||
if (landuse[i] === LANDUSE.SUBURB && urbanNeighbors <= 1) {
|
||||
const keep = clamp(cityInfluence[i] * 0.52 + transport * 0.32 + stationInfluence[i] * 0.18 + 0.08);
|
||||
if (hash2(x, y, seed + 15051) > keep) {
|
||||
landuse[i] = agriculture[i] > 0.22 ? LANDUSE.FARMLAND : LANDUSE.RURAL;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (maxDensity > 0) for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxDensity);
|
||||
}
|
||||
classifyLanduse();
|
||||
|
||||
for (const city of modernCities) {
|
||||
let urbanFootprintCells = 0;
|
||||
let coreFootprintCells = 0;
|
||||
const r = Math.ceil((city.urbanRadius || 8) * 1.3);
|
||||
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]) continue;
|
||||
if (Math.hypot(dx, dy) > r) continue;
|
||||
if (landuse[i] >= LANDUSE.OLD_URBAN && landuse[i] <= LANDUSE.ROADSIDE) urbanFootprintCells++;
|
||||
if (landuse[i] === LANDUSE.CBD) coreFootprintCells++;
|
||||
}
|
||||
}
|
||||
city.urbanFootprintCells = urbanFootprintCells;
|
||||
city.coreFootprintCells = coreFootprintCells;
|
||||
}
|
||||
|
||||
const transportDebug = {
|
||||
humanStageVersion: "v2-sparse-raster",
|
||||
aStarRoutes: 0,
|
||||
regionalNodeCount: [...regionStats.keys()].reduce((sum, regionId) => sum + importantNodesForRegion(regionId).length, 0),
|
||||
nationalRoadPopulationCoverage: 0,
|
||||
nationalRoadUncoveredPopulation: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
ports,
|
||||
crossings,
|
||||
passes,
|
||||
settlementCluster,
|
||||
settlementScore,
|
||||
villages,
|
||||
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,
|
||||
stationInfluence,
|
||||
roadInfluence,
|
||||
railInfluence2,
|
||||
villageInfluence,
|
||||
externalGateways,
|
||||
cityPopulationCap,
|
||||
transportDebug,
|
||||
};
|
||||
}
|
||||
|
|
@ -475,6 +475,9 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river,
|
|||
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);
|
||||
|
||||
const displayRegionId = new Int16Array(beforeRegionId);
|
||||
for (let pass = 0; pass < 3; pass++) repairRegionalTopology(displayRegionId, sea, seeded.centers, anchorMask, 200);
|
||||
|
||||
let changed = 0;
|
||||
for (let i = 0; i < SIZE; i++) if (!sea[i] && beforeRegionId[i] !== regionId[i]) changed++;
|
||||
const afterBorderCount = countRegionBorderEdges(regionId, sea);
|
||||
|
|
@ -484,6 +487,7 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river,
|
|||
|
||||
return {
|
||||
regionId,
|
||||
displayRegionId,
|
||||
centers: seeded.centers,
|
||||
naturalBarrierScore,
|
||||
debug: {
|
||||
|
|
@ -494,6 +498,8 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river,
|
|||
regionalVoronoiLikeRateAfter: afterVoronoiLikeRate,
|
||||
regionalNaturalBarrierAverageBefore: beforeNaturalAverage,
|
||||
regionalNaturalBarrierAverageAfter: afterNaturalAverage,
|
||||
regionalDisplayBorderCount: countRegionBorderEdges(displayRegionId, sea),
|
||||
regionalDisplayNaturalBarrierAverage: averageRegionBorderBarrier(displayRegionId, sea, naturalBarrierScore),
|
||||
regionalCompartmentCount: compartments.filter((unit) => unit.area > 0).length,
|
||||
compartmentCount: compartments.filter((unit) => unit.area > 0).length,
|
||||
changedAfterCompartmentAssignment: changed,
|
||||
|
|
@ -961,12 +967,17 @@ export function recalculatePopulationAfterLanduse(modernCities, satelliteCities,
|
|||
}
|
||||
}
|
||||
}
|
||||
const base = city.isPrefecturalCapital ? 90000 : city.kind === "Satellite City" ? 16000 : 32000;
|
||||
const urbanComponent = urbanCells * (city.isPrefecturalCapital ? 1500 : city.kind === "Satellite City" ? 900 : 1200);
|
||||
const capitalLike = city.isPrefecturalCapital || city.isRegionalCapital;
|
||||
const base = city.isPrefecturalCapital ? 90000 : city.isRegionalCapital ? 62000 : city.kind === "Satellite City" ? 16000 : 32000;
|
||||
const urbanComponent = urbanCells * (city.isPrefecturalCapital ? 1500 : city.isRegionalCapital ? 1350 : 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);
|
||||
const densityComponent = densitySum * 360;
|
||||
const computedPopulation = base + urbanComponent + coreComponent + densityComponent;
|
||||
const footprintCells = city.urbanFootprintCells || urbanCells;
|
||||
const footprintCoreCells = city.coreFootprintCells || coreCells;
|
||||
const footprintCap = base + footprintCells * (city.isPrefecturalCapital ? 8500 : city.isRegionalCapital ? 7000 : city.kind === "Satellite City" ? 4300 : 5200) + footprintCoreCells * (city.isPrefecturalCapital ? 10500 : 9000);
|
||||
city.population = Math.round(Math.max(base, Math.min(computedPopulation, footprintCap)) / 1000) * 1000;
|
||||
city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 95, city.kind === "Satellite City" ? 5 : 7, capitalLike ? 34 : 28);
|
||||
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 360, 2.2, capitalLike ? 9 : 8);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
255
mapOutput.js
255
mapOutput.js
|
|
@ -2,88 +2,146 @@ 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";
|
||||
|
||||
const MUNICIPAL_SUFFIX_RE = /[市町村区]$/u;
|
||||
|
||||
function municipalitySuffixForCenter(center, fields, seed, ordinal = 0) {
|
||||
const i = inside(center?.x || 0, center?.y || 0) ? indexOf(center.x, center.y) : 0;
|
||||
const density = fields.populationDensity?.[i] || 0;
|
||||
const land = fields.landuse?.[i] ?? 0;
|
||||
const urban = density > 0.36 || [2, 3, 4, 7, 8].includes(land) || center?.protectedSatellite;
|
||||
const rural = (fields.elevation?.[i] || 0) > 0.58 || (fields.slope?.[i] || 0) > 0.40 || (fields.ridgeField?.[i] || 0) > 0.46;
|
||||
if (urban) return "市";
|
||||
if (rural && rand(seed + ordinal * 17, 9021) < 0.58) return "村";
|
||||
return "町";
|
||||
}
|
||||
|
||||
function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) {
|
||||
let value = String(root || center?.name || "").trim();
|
||||
if (!value) value = `自治${ordinal + 1}`;
|
||||
value = value.replace(/[駅港城跡宿]$/u, "");
|
||||
if (Array.from(value).length < 2) value = `${value}${String(center?.generatedMunicipalityName || "里")}`.slice(0, 3);
|
||||
if (MUNICIPAL_SUFFIX_RE.test(value)) return value;
|
||||
return `${value}${municipalitySuffixForCenter(center, fields, seed, ordinal)}`;
|
||||
}
|
||||
|
||||
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,
|
||||
terrain,
|
||||
features,
|
||||
admin,
|
||||
}) {
|
||||
const {
|
||||
terrainTemplate,
|
||||
seaLevel,
|
||||
elevation,
|
||||
moisture,
|
||||
slope,
|
||||
sea,
|
||||
ocean,
|
||||
lake,
|
||||
river,
|
||||
floodplain,
|
||||
plain,
|
||||
agriculture,
|
||||
ridgeField,
|
||||
valleyField,
|
||||
visibleRavineField,
|
||||
surfaceTextureField,
|
||||
basinField,
|
||||
coastalLowland,
|
||||
flowAccum,
|
||||
erosionField,
|
||||
depositionField,
|
||||
arcSpineField,
|
||||
branchRidgeField,
|
||||
depositionalLowland,
|
||||
alluvialFanField,
|
||||
deltaField,
|
||||
naturalBarrierScore,
|
||||
riverPaths,
|
||||
mainRivers,
|
||||
tributaryRivers,
|
||||
smallStreams,
|
||||
prefectureMask,
|
||||
prefectureBorder,
|
||||
prefectureRegionId,
|
||||
regionalDebug,
|
||||
terrainDebug,
|
||||
regionalPrefectureBorders,
|
||||
} = terrain;
|
||||
|
||||
const {
|
||||
cityPopulationCap,
|
||||
stationInfluence,
|
||||
roadInfluence,
|
||||
railInfluence2,
|
||||
settlementCluster,
|
||||
villages: inputVillages,
|
||||
ports: inputPorts,
|
||||
crossings: inputCrossings,
|
||||
passes: inputPasses,
|
||||
markets: inputMarkets,
|
||||
castles: inputCastles,
|
||||
castleTowns: inputCastleTowns,
|
||||
premodernRoads,
|
||||
minorRoads,
|
||||
modernCities: inputModernCities,
|
||||
populationDensity,
|
||||
railways,
|
||||
branchRailways,
|
||||
ringRailways,
|
||||
externalRailways,
|
||||
stations: inputStations,
|
||||
industrialZones: inputIndustrialZones,
|
||||
nationalRoads,
|
||||
ringRoads,
|
||||
expressways,
|
||||
ringExpressways,
|
||||
icAccessRoads,
|
||||
externalRoads,
|
||||
externalExpressways,
|
||||
interchanges: inputInterchanges,
|
||||
logisticsParks: inputLogisticsParks,
|
||||
satelliteCities: inputSatelliteCities,
|
||||
newTowns: inputNewTowns,
|
||||
landuse,
|
||||
externalGateways: inputExternalGateways,
|
||||
transportDebug,
|
||||
} = features;
|
||||
|
||||
const {
|
||||
adminCentersRaw,
|
||||
adminId,
|
||||
adminBorders,
|
||||
adminDebug,
|
||||
} = admin;
|
||||
|
||||
let villages = inputVillages;
|
||||
let ports = inputPorts;
|
||||
let crossings = inputCrossings;
|
||||
let passes = inputPasses;
|
||||
let markets = inputMarkets;
|
||||
let castles = inputCastles;
|
||||
let castleTowns = inputCastleTowns;
|
||||
let modernCities = inputModernCities;
|
||||
let stations = inputStations;
|
||||
let industrialZones = inputIndustrialZones;
|
||||
let interchanges = inputInterchanges;
|
||||
let logisticsParks = inputLogisticsParks;
|
||||
let satelliteCities = inputSatelliteCities;
|
||||
let newTowns = inputNewTowns;
|
||||
let externalGateways = inputExternalGateways;
|
||||
|
||||
const outputProgress = (step) => options?.onProgress?.({ status: "output-step", key: "output", label: `Output: ${step}`, step });
|
||||
outputProgress("population recalculation");
|
||||
// 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);
|
||||
// Use all generated prefecture regions for human-geography density, not only the focused prefecture.
|
||||
const humanRegionMask = new Uint8Array(MAP_W * MAP_H);
|
||||
for (let i = 0; i < humanRegionMask.length; i++) {
|
||||
humanRegionMask[i] = !sea[i] && ((prefectureRegionId?.[i] ?? -1) >= 0 || prefectureMask[i]) ? 1 : 0;
|
||||
}
|
||||
recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, humanRegionMask, sea, stationInfluence, roadInfluence, railInfluence2);
|
||||
for (const city of modernCities) {
|
||||
if (city.isPrefecturalCapital) continue;
|
||||
const cap = cityPopulationCap(city);
|
||||
|
|
@ -95,6 +153,8 @@ export function finishMapOutput({
|
|||
}
|
||||
}
|
||||
|
||||
outputProgress("harbor works");
|
||||
|
||||
function makeHarborWorks(ports) {
|
||||
const out = [];
|
||||
for (const port of ports) {
|
||||
|
|
@ -115,18 +175,13 @@ export function finishMapOutput({
|
|||
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();
|
||||
|
||||
outputProgress("feature naming");
|
||||
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);
|
||||
|
|
@ -143,13 +198,14 @@ export function finishMapOutput({
|
|||
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);
|
||||
outputProgress("municipality naming");
|
||||
const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, humanRegionMask), "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);
|
||||
].filter((p) => p.name && (p.insidePrefecture || humanRegionMask[indexOf(p.x, p.y)]));
|
||||
for (const center of adminCenters) {
|
||||
const centerAdmin = adminId?.[indexOf(center.x, center.y)];
|
||||
let best = null;
|
||||
|
|
@ -167,25 +223,37 @@ export function finishMapOutput({
|
|||
}
|
||||
if (best) break;
|
||||
}
|
||||
center.generatedMunicipalityName = center.generatedMunicipalityName || center.name;
|
||||
if (best) {
|
||||
center.representativeFeatureId = best.id;
|
||||
center.representativeFeatureName = best.name;
|
||||
center.generatedMunicipalityName = center.name;
|
||||
center.name = best.name;
|
||||
center.municipalityRootName = best.name;
|
||||
} else {
|
||||
center.municipalityRootName = center.generatedMunicipalityName;
|
||||
}
|
||||
}
|
||||
const usedAdminNames = new Set();
|
||||
for (const center of adminCenters) {
|
||||
let candidate = center.name;
|
||||
const generated = String(center.generatedMunicipalityName || "");
|
||||
for (const [index, center] of adminCenters.entries()) {
|
||||
center.adminNumericId = index;
|
||||
center.municipalityId = index;
|
||||
let candidate = municipalityNameFromRoot(center.municipalityRootName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index);
|
||||
const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, index);
|
||||
if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) {
|
||||
candidate = generated;
|
||||
}
|
||||
if (usedAdminNames.has(candidate)) {
|
||||
const suffix = municipalitySuffixForCenter(center, nameFields, seed + 313, index);
|
||||
const base = String(center.generatedMunicipalityName || center.municipalityRootName || center.name || `自治${index + 1}`).replace(/[市町村区]$/u, "");
|
||||
candidate = `${base}${index + 1}${suffix}`;
|
||||
}
|
||||
center.name = candidate;
|
||||
center.labelName = candidate;
|
||||
center.municipalityName = candidate;
|
||||
usedAdminNames.add(center.name);
|
||||
}
|
||||
nameDebug.maxDerivedPerBase = 0;
|
||||
|
||||
outputProgress("final package");
|
||||
const entitiesForNames = [
|
||||
...modernCities,
|
||||
...ports,
|
||||
|
|
@ -210,6 +278,7 @@ export function finishMapOutput({
|
|||
terrainTemplate,
|
||||
seaLevel,
|
||||
prefectureMask,
|
||||
humanRegionMask,
|
||||
prefectureBorder,
|
||||
prefectureRegionId,
|
||||
regionalDebug,
|
||||
|
|
@ -228,6 +297,8 @@ export function finishMapOutput({
|
|||
settlementCluster,
|
||||
ridgeField,
|
||||
valleyField,
|
||||
visibleRavineField,
|
||||
surfaceTextureField,
|
||||
basinField,
|
||||
coastalLowland,
|
||||
flowAccum,
|
||||
|
|
@ -269,17 +340,13 @@ export function finishMapOutput({
|
|||
logisticsParks,
|
||||
satelliteCities,
|
||||
newTowns,
|
||||
bridges,
|
||||
tunnels,
|
||||
harborWorks,
|
||||
landuse,
|
||||
adminCenters,
|
||||
adminId,
|
||||
adminBorders,
|
||||
adminDebug,
|
||||
abandonedRailways,
|
||||
castleRuins,
|
||||
preservedOldRoads,
|
||||
riverPaths,
|
||||
mainRivers,
|
||||
tributaryRivers,
|
||||
|
|
|
|||
105
mapPipeline.js
105
mapPipeline.js
|
|
@ -1,26 +1,39 @@
|
|||
import { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
|
||||
import { generateTerrainAndRivers } from "./mapTerrain.js";
|
||||
import { generateMapFeatures } from "./mapFeatures.js";
|
||||
import { generateMapFeatures } from "./mapFeaturesV2.js";
|
||||
import { finishMapOutput } from "./mapOutput.js";
|
||||
import { generateAdminLayout } from "./mapAdminStage.js";
|
||||
|
||||
export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
|
||||
|
||||
function nowMs() {
|
||||
return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
||||
}
|
||||
|
||||
function timedStage(timings, options, key, label, fn) {
|
||||
options?.onProgress?.({ status: "start", key, label, timings: timings.slice() });
|
||||
const t0 = nowMs();
|
||||
const value = fn();
|
||||
const ms = Math.round((nowMs() - t0) * 10) / 10;
|
||||
const entry = { key, label, ms };
|
||||
timings.push(entry);
|
||||
options?.onProgress?.({ status: "done", key, label, ms, timings: timings.slice() });
|
||||
return value;
|
||||
}
|
||||
|
||||
export function generateMap(seedInput = 114514, options = {}) {
|
||||
const seed = Number(seedInput) >>> 0;
|
||||
if (typeof options.onProgress !== "function") options = { ...options, onProgress: () => {} };
|
||||
|
||||
const terrain = generateTerrainAndRivers(seed);
|
||||
const generationTimings = [];
|
||||
const stage = (key, label, fn) => timedStage(generationTimings, options, key, label, fn);
|
||||
|
||||
const terrain = stage("terrain", "Terrain, rivers, and prefecture regions", () => generateTerrainAndRivers(seed));
|
||||
const {
|
||||
terrainTemplate,
|
||||
seaLevel,
|
||||
elevation,
|
||||
moisture,
|
||||
slope,
|
||||
sea,
|
||||
ocean,
|
||||
lake,
|
||||
river,
|
||||
floodplain,
|
||||
plain,
|
||||
agriculture,
|
||||
ridgeField,
|
||||
|
|
@ -28,49 +41,55 @@ export function generateMap(seedInput = 114514, options = {}) {
|
|||
basinField,
|
||||
coastalLowland,
|
||||
flowAccum,
|
||||
erosionField,
|
||||
depositionField,
|
||||
arcSpineField,
|
||||
branchRidgeField,
|
||||
depositionalLowland,
|
||||
alluvialFanField,
|
||||
deltaField,
|
||||
naturalBarrierScore,
|
||||
portSuitability,
|
||||
crossingSuitability,
|
||||
passSuitability,
|
||||
prefectureMask,
|
||||
prefectureBorder,
|
||||
prefectureRegionId,
|
||||
regionalDebug,
|
||||
terrainDebug,
|
||||
regionalPrefectureBorders,
|
||||
riverPaths,
|
||||
mainRivers,
|
||||
tributaryRivers,
|
||||
smallStreams,
|
||||
adminPrefectureRegionId,
|
||||
} = terrain;
|
||||
|
||||
const features = generateMapFeatures(seed, terrain);
|
||||
const features = stage("human", "Human geography and transport", () => 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,
|
||||
settlementScore,
|
||||
villages,
|
||||
markets,
|
||||
modernCities,
|
||||
populationDensity,
|
||||
stations,
|
||||
industrialZones,
|
||||
logisticsParks,
|
||||
satelliteCities,
|
||||
newTowns,
|
||||
ports,
|
||||
landuse,
|
||||
stationInfluence,
|
||||
roadInfluence,
|
||||
railInfluence2,
|
||||
villageInfluence,
|
||||
} = features;
|
||||
|
||||
const { adminCentersRaw, adminId, adminBorders, adminDebug } = generateAdminLayout({
|
||||
seed, prefectureMask, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
|
||||
const admin = stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({
|
||||
seed, prefectureMask, prefectureRegionId: adminPrefectureRegionId || prefectureRegionId, 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,
|
||||
});
|
||||
adminProgress: (event) => options?.onProgress?.({
|
||||
...event,
|
||||
key: "admin",
|
||||
label: event.status === "region-done"
|
||||
? `Admin region ${event.regionId} done`
|
||||
: event.status === "admin-step"
|
||||
? `Admin region ${event.regionId}: ${event.step}`
|
||||
: `Admin region ${event.regionId}`,
|
||||
timings: generationTimings.slice(),
|
||||
}),
|
||||
}));
|
||||
|
||||
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,
|
||||
});
|
||||
const output = stage("output", "Names, population totals, and final packaging", () => finishMapOutput({
|
||||
seed,
|
||||
options,
|
||||
terrain,
|
||||
features,
|
||||
admin,
|
||||
}));
|
||||
output.generationTimings = generationTimings;
|
||||
output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0);
|
||||
return output;
|
||||
}
|
||||
|
|
|
|||
2139
mapTerrain.js
2139
mapTerrain.js
File diff suppressed because it is too large
Load diff
1804
mapTerrain.v4.bak.js
Normal file
1804
mapTerrain.v4.bak.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
export const MAP_W = 172;
|
||||
export const MAP_H = 122;
|
||||
export const CELL_SIZE = 6;
|
||||
export const MAP_W = 258;
|
||||
export const MAP_H = 183;
|
||||
export const CELL_SIZE = 4;
|
||||
|
||||
export const SIZE = MAP_W * MAP_H;
|
||||
export const INF = 1e9;
|
||||
|
|
|
|||
4
names.js
4
names.js
|
|
@ -30,11 +30,11 @@ export const NAME_KANJI_POOLS = {
|
|||
"池", "沼", "泉", "井",
|
||||
"滝", "梅", "沢", "澤", "谷", "津",
|
||||
"水", "清", "渡", "橋", "堀",
|
||||
"溝", "浦", "洲"
|
||||
"溝", "浦"
|
||||
],
|
||||
|
||||
coastalTerrain: [
|
||||
"津", "浦", "ヶ浦", "津", "崎",
|
||||
"津", "浦", "津", "崎",
|
||||
"島", "磯", "潟", "湊", "津",
|
||||
"州", "洲", "瀬", "砂", "潮", "塩", "汐",
|
||||
"泊", "江", "浦", "灘", "入",
|
||||
|
|
|
|||
291
renderer.js
291
renderer.js
|
|
@ -1,4 +1,4 @@
|
|||
import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf } from "./mapUtils.js";
|
||||
import { CELL_SIZE, MAP_H, MAP_W, clamp, fbm, indexOf, valueNoise } from "./mapUtils.js";
|
||||
|
||||
|
||||
const segmentVectorCache = new WeakMap();
|
||||
|
|
@ -287,6 +287,23 @@ function fieldSample(field, fx, fy) {
|
|||
return ((a * (1 - tx) + b * tx) * (1 - ty)) + ((c * (1 - tx) + d * tx) * ty);
|
||||
}
|
||||
|
||||
function interpolateColorStops(value, stops) {
|
||||
if (value <= stops[0][0]) return stops[0][1].slice();
|
||||
for (let i = 1; i < stops.length; i++) {
|
||||
const [v, c] = stops[i];
|
||||
const [pv, pc] = stops[i - 1];
|
||||
if (value <= v) {
|
||||
const t = clamp((value - pv) / Math.max(0.0001, v - pv));
|
||||
return [
|
||||
Math.round(pc[0] + (c[0] - pc[0]) * t),
|
||||
Math.round(pc[1] + (c[1] - pc[1]) * t),
|
||||
Math.round(pc[2] + (c[2] - pc[2]) * t),
|
||||
];
|
||||
}
|
||||
}
|
||||
return stops[stops.length - 1][1].slice();
|
||||
}
|
||||
|
||||
function terrainColorContinuous(map, fx, fy, mode) {
|
||||
const i = sampleCellIndex(fx, fy);
|
||||
const isInside = Boolean(map.prefectureMask[i]);
|
||||
|
|
@ -316,37 +333,90 @@ function terrainColorContinuous(map, fx, fy, mode) {
|
|||
Math.round(230 + density * 10),
|
||||
];
|
||||
} 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];
|
||||
color = interpolateColorStops(clamp(e), [
|
||||
[0.20, [231, 236, 223]],
|
||||
[0.30, [223, 231, 214]],
|
||||
[0.40, [213, 223, 201]],
|
||||
[0.50, [204, 215, 188]],
|
||||
[0.58, [195, 207, 173]],
|
||||
[0.65, [185, 196, 158]],
|
||||
[0.71, [177, 181, 141]],
|
||||
[0.76, [169, 164, 125]],
|
||||
[0.81, [157, 145, 105]],
|
||||
[0.86, [144, 128, 89]],
|
||||
[0.91, [130, 111, 79]],
|
||||
[0.95, [118, 103, 89]],
|
||||
[0.985, [146, 141, 133]],
|
||||
[1.00, [183, 179, 171]],
|
||||
]);
|
||||
}
|
||||
|
||||
return blendOutside(color, isInside);
|
||||
}
|
||||
|
||||
function terrainShadeContinuous(map, fx, fy) {
|
||||
const step = 0.50;
|
||||
const eC = fieldSample(map.elevation, fx, fy);
|
||||
const eL = fieldSample(map.elevation, fx - step, fy);
|
||||
const eR = fieldSample(map.elevation, fx + step, fy);
|
||||
const eU = fieldSample(map.elevation, fx, fy - step);
|
||||
const eD = fieldSample(map.elevation, fx, fy + step);
|
||||
|
||||
// x は東向き, y は南向き。法線は (-dz/dx, -dz/dy, 1)。
|
||||
// 描画では地形生成上の差分をやや誇張し、粗いDEMでも山腹の起伏を読ませる。
|
||||
const dzdx = (eR - eL) / (step * 2);
|
||||
const dzdy = (eD - eU) / (step * 2);
|
||||
const nx = -dzdx * 4.4;
|
||||
const ny = -dzdy * 4.4;
|
||||
const nz = 1.0;
|
||||
const nLen = Math.hypot(nx, ny, nz) || 1;
|
||||
|
||||
const lx = -0.5;
|
||||
const ly = -0.5;
|
||||
const lz = 0.7071067811865476;
|
||||
const hill = clamp((nx * lx + ny * ly + nz * lz) / nLen * 0.58 + 0.48);
|
||||
|
||||
const slope = map.slope ? fieldSample(map.slope, fx, fy) : 0;
|
||||
const valley = map.valleyField ? fieldSample(map.valleyField, fx, fy) : 0;
|
||||
const ravine = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy) : 0;
|
||||
const tex = map.surfaceTextureField ? fieldSample(map.surfaceTextureField, fx, fy) : 0;
|
||||
const rvL = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx - 0.90, fy) : 0;
|
||||
const rvR = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx + 0.90, fy) : 0;
|
||||
const rvU = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy - 0.90) : 0;
|
||||
const rvD = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy + 0.90) : 0;
|
||||
const ravineRelief = (rvL - rvR) * 0.26 + (rvU - rvD) * 0.20;
|
||||
const concavity = clamp(((eL + eR + eU + eD) * 0.25 - eC) * 9.0, -0.18, 0.18);
|
||||
|
||||
// 谷底の色保持は少し残すが、以前より圧縮を弱めて陰影の振幅を大きくする。
|
||||
const valleyFloor = clamp((valley - 0.18) * 1.45) * clamp((0.24 - slope) * 3.6);
|
||||
let shade = 0.66 + hill * 0.50 + ravineRelief - ravine * 0.12 - tex * 0.042 - concavity * 0.16 + slope * 0.030;
|
||||
if (shade < 1) shade = 1 - (1 - shade) * (1 - valleyFloor * 0.26);
|
||||
else shade = 1 + (shade - 1) * (1 - valleyFloor * 0.12);
|
||||
|
||||
return clamp(shade, 0.54, 1.26);
|
||||
}
|
||||
|
||||
function discreteColor(map, x, y, mode) {
|
||||
const i = indexOf(x, y);
|
||||
let color;
|
||||
|
||||
if (map.sea[i]) {
|
||||
color = [170, 218, 255];
|
||||
color = [160, 205, 239];
|
||||
} 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: [244, 247, 240], // rural / natural land
|
||||
1: [222, 236, 188], // farmland
|
||||
2: [232, 222, 214], // old urban
|
||||
3: [221, 188, 184], // CBD / DID core
|
||||
4: [235, 225, 236], // suburb
|
||||
5: [223, 224, 232], // industrial
|
||||
6: [232, 237, 232], // logistics
|
||||
7: [231, 236, 246], // new town
|
||||
8: [243, 233, 210], // roadside
|
||||
9: [221, 236, 216], // forest / mountain land
|
||||
};
|
||||
color = colors[map.landuse[i]] || colors[0];
|
||||
} else if (mode === "admin") {
|
||||
|
|
@ -374,12 +444,7 @@ function drawBase(ctx, map, mode, continuousTerrain) {
|
|||
for (let px = 0; px < width; px++) {
|
||||
const fx = px / CELL_SIZE;
|
||||
const [r, g, b] = terrainColorContinuous(map, fx, fy, mode === "all" ? "terrain" : mode);
|
||||
|
||||
const eL = fieldSample(map.elevation, fx - 0.6, fy);
|
||||
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 = terrainShadeContinuous(map, fx, fy);
|
||||
|
||||
const ii = (py * width + px) * 4;
|
||||
img.data[ii] = Math.round(r * shade);
|
||||
|
|
@ -407,6 +472,28 @@ function drawBase(ctx, map, mode, continuousTerrain) {
|
|||
ctx.putImageData(img, 0, 0);
|
||||
}
|
||||
|
||||
function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) {
|
||||
if (!path || path.length < 2) return;
|
||||
ctx.save();
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
for (let k = 0; k < path.length - 1; k++) {
|
||||
const [x1, y1] = path[k];
|
||||
const [x2, y2] = path[k + 1];
|
||||
const i1 = indexOf(x1, y1);
|
||||
const i2 = indexOf(x2, y2);
|
||||
const strength = Math.max((map.river?.[i1] || 0) + (map.flowAccum?.[i1] || 0) * 0.95, (map.river?.[i2] || 0) + (map.flowAccum?.[i2] || 0) * 0.95);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.lineWidth = widthFn(strength, k / Math.max(1, path.length - 1));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1 * CELL_SIZE + CELL_SIZE / 2, y1 * CELL_SIZE + CELL_SIZE / 2);
|
||||
ctx.lineTo(x2 * CELL_SIZE + CELL_SIZE / 2, y2 * CELL_SIZE + CELL_SIZE / 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawPath(ctx, path, color, width, dashed = false) {
|
||||
const points = vectorPath(path);
|
||||
if (points.length < 2) return;
|
||||
|
|
@ -486,24 +573,25 @@ function drawSegments(ctx, segments, color, width, dashed = false) {
|
|||
}
|
||||
|
||||
function drawUrbanAreas(ctx, map, mode) {
|
||||
const visibleModes = ["all", "modern", "development", "landuse", "roads", "admin"];
|
||||
const visibleModes = ["all", "modern", "development", "landuse", "admin"];
|
||||
if (!visibleModes.includes(mode)) return;
|
||||
|
||||
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(223, 214, 206, 0.72)",
|
||||
3: "rgba(215, 175, 172, 0.88)",
|
||||
4: "rgba(231, 219, 231, 0.68)",
|
||||
5: "rgba(218, 218, 226, 0.64)",
|
||||
6: "rgba(225, 230, 225, 0.56)",
|
||||
7: "rgba(229, 234, 242, 0.64)",
|
||||
8: "rgba(244, 230, 205, 0.62)",
|
||||
};
|
||||
|
||||
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]) continue;
|
||||
const areaMask = map.humanRegionMask || map.prefectureMask;
|
||||
if (areaMask && !areaMask[i]) continue;
|
||||
const lu = map.landuse[i];
|
||||
if (!colors[lu]) continue;
|
||||
|
||||
|
|
@ -522,7 +610,8 @@ function drawDebugCells(ctx, map, field, color) {
|
|||
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 debugMask = map.humanRegionMask || map.prefectureMask;
|
||||
if (!debugMask[i] || map.sea[i]) continue;
|
||||
const v = clamp(field[i] || 0, 0, 1);
|
||||
if (v <= 0.12) continue;
|
||||
ctx.fillStyle = color(v);
|
||||
|
|
@ -590,6 +679,42 @@ function drawLabels(ctx, points, limit = Infinity) {
|
|||
for (const p of prioritized.slice(0, limit)) labelWithCollision(ctx, p, occupied);
|
||||
}
|
||||
|
||||
function drawScaleBar(ctx) {
|
||||
const kmPerCell = 2;
|
||||
const targetKm = 20;
|
||||
const lengthCells = Math.max(8, Math.round(targetKm / kmPerCell));
|
||||
const lengthPx = lengthCells * CELL_SIZE;
|
||||
const margin = 14;
|
||||
const x = margin;
|
||||
const y = margin + 18;
|
||||
|
||||
ctx.save();
|
||||
ctx.lineCap = "butt";
|
||||
ctx.strokeStyle = "rgba(0,0,0,0.78)";
|
||||
ctx.lineWidth = 2.2;
|
||||
ctx.fillStyle = "rgba(255,255,255,0.92)";
|
||||
ctx.fillRect(x - 8, y - 18, lengthPx + 16, 30);
|
||||
ctx.strokeStyle = "rgba(80,80,80,0.22)";
|
||||
ctx.strokeRect(x - 8, y - 18, lengthPx + 16, 30);
|
||||
ctx.strokeStyle = "rgba(30,30,30,0.82)";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
ctx.lineTo(x + lengthPx, y);
|
||||
ctx.stroke();
|
||||
for (let t = 0; t <= 2; t++) {
|
||||
const tx = x + (lengthPx * t) / 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tx, y - 5);
|
||||
ctx.lineTo(tx, y + 5);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.fillStyle = "rgba(20,20,20,0.88)";
|
||||
ctx.font = "600 11px ui-sans-serif, system-ui, -apple-system, sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(`${targetKm} km`, x + lengthPx / 2, y - 7);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function drawMap(canvas, map, options) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
|
@ -612,13 +737,58 @@ export function drawMap(canvas, map, options) {
|
|||
|
||||
// 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);
|
||||
const mediumBlue = "rgba(160, 205, 240, 0.88)";
|
||||
const riverStrengthForPath = (path) => {
|
||||
if (!path || path.length === 0) return 0;
|
||||
let peak = 0;
|
||||
let tail = 0;
|
||||
const tailStart = Math.max(0, path.length - Math.min(path.length, 8));
|
||||
let tailCount = 0;
|
||||
for (let k = 0; k < path.length; k++) {
|
||||
const [x, y] = path[k];
|
||||
const i = indexOf(x, y);
|
||||
const strength = (map.river?.[i] || 0) + (map.flowAccum?.[i] || 0) * 0.75;
|
||||
peak = Math.max(peak, strength);
|
||||
if (k >= tailStart) {
|
||||
tail += strength;
|
||||
tailCount++;
|
||||
}
|
||||
}
|
||||
return Math.max(peak, tail / Math.max(1, tailCount));
|
||||
};
|
||||
// Draw a dendritic river network. Width is intentionally separated by
|
||||
// river order: small streams are hairline/low-alpha, tributaries are thin,
|
||||
// and only trunk rivers get a modestly wider stroke.
|
||||
for (const path of map.smallStreams || []) {
|
||||
const strength = riverStrengthForPath(path);
|
||||
if ((path?.length || 0) < 5 || strength < 0.045) continue;
|
||||
drawRiverPath(ctx, map, path, "rgba(150, 198, 235, 1)", (s) => s > 0.45 ? 0.58 : s > 0.22 ? 0.48 : 0.36, 0.34);
|
||||
}
|
||||
for (const path of map.tributaryRivers || []) {
|
||||
const strength = riverStrengthForPath(path);
|
||||
if ((path?.length || 0) < 9 || strength < 0.45) continue;
|
||||
drawRiverPath(ctx, map, path, mediumBlue, (s, t) => {
|
||||
const downstreamBoost = 0.92 + t * 0.18;
|
||||
if (s > 1.65) return 1.35 * downstreamBoost;
|
||||
if (s > 0.95) return 1.12 * downstreamBoost;
|
||||
return 0.94 * downstreamBoost;
|
||||
}, 0.92);
|
||||
}
|
||||
for (const path of map.mainRivers || []) {
|
||||
const strength = riverStrengthForPath(path);
|
||||
if ((path?.length || 0) < 9) continue;
|
||||
drawRiverPath(ctx, map, path, waterBlue, (s, t) => {
|
||||
const downstreamBoost = 0.96 + t * 0.24;
|
||||
if (s > 2.35) return 2.15 * downstreamBoost;
|
||||
if (s > 1.45) return 1.86 * downstreamBoost;
|
||||
return 1.55 * downstreamBoost;
|
||||
}, 1.0);
|
||||
}
|
||||
|
||||
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 showModern = ["modern", "all", "development", "landuse", "admin-debug", "borders-debug"].includes(mode);
|
||||
const showRoads = ["all", "development"].includes(mode);
|
||||
const showMinorRoads = ["all", "modern", "development"].includes(mode);
|
||||
const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode);
|
||||
|
||||
// 3. Borders
|
||||
|
|
@ -627,12 +797,19 @@ export function drawMap(canvas, map, options) {
|
|||
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);
|
||||
// Keep the natural barrier heatmap subtle. A dense cell fill can look like
|
||||
// artificial horizontal hatching, so only strong terrain dividers are shown.
|
||||
drawDebugCells(ctx, map, map.naturalBarrierScore, (v) => v < 0.42 ? "rgba(0,0,0,0)" : `rgba(255, 120, 40, ${0.025 + v * 0.075})`);
|
||||
if (map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(45, 95, 160, 0.72)", 1.0, 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 });
|
||||
}
|
||||
|
||||
|
||||
const showPrefectureRegions = ["all", "admin", "admin-debug", "borders-debug"].includes(mode);
|
||||
if (showPrefectureRegions && map.regionalPrefectureBorders) {
|
||||
drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(255, 255, 255, 0.90)", 4.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(82, 60, 102, 0.96)", 1.8, true, { 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 });
|
||||
|
||||
|
|
@ -683,12 +860,21 @@ export function drawMap(canvas, map, options) {
|
|||
}
|
||||
|
||||
// 6. Icons & Labels
|
||||
if (["admin", "admin-debug", "borders-debug"].includes(mode)) {
|
||||
for (const p of map.adminCenters || []) dot(ctx, p, 2.8, "rgba(255,255,255,0.96)", "rgba(75,60,90,0.95)");
|
||||
}
|
||||
|
||||
if (showModern) {
|
||||
for (const p of map.stations) dot(ctx, p, 2.5, "#fff", "#444");
|
||||
const allLayerTowns = mode === "all"
|
||||
? (map.markets || []).filter((p) => (p.population || 0) >= 25000 && !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y))
|
||||
: [];
|
||||
for (const p of allLayerTowns) dot(ctx, p, 3.1, "rgba(244, 164, 118, 0.92)", "rgba(255,255,255,0.88)");
|
||||
for (const p of map.modernCities) {
|
||||
const popRadius = p.population ? Math.min(8.5, 3.5 + Math.sqrt(p.population) / 400) : 4.5;
|
||||
const popRadius = p.population ? Math.min(9.4, 3.8 + Math.sqrt(p.population) / 360) : 4.8;
|
||||
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)");
|
||||
if (p.isPrefecturalCapital) dot(ctx, p, popRadius + 3.4, "transparent", "rgba(200,80,80,0.9)");
|
||||
else if (p.isRegionalCapital) dot(ctx, p, popRadius + 2.4, "transparent", "rgba(190,95,95,0.62)");
|
||||
}
|
||||
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") {
|
||||
|
|
@ -700,17 +886,24 @@ export function drawMap(canvas, map, options) {
|
|||
if (showLabels) {
|
||||
if (mode === "admin") {
|
||||
drawLabels(ctx, map.adminCenters || [], Infinity);
|
||||
drawScaleBar(ctx);
|
||||
return;
|
||||
}
|
||||
if (mode === "admin-debug" || mode === "borders-debug") {
|
||||
drawLabels(ctx, [...(map.adminCenters || []), ...(map.externalGateways || [])], Infinity);
|
||||
drawLabels(ctx, map.adminCenters || [], Infinity);
|
||||
drawScaleBar(ctx);
|
||||
return;
|
||||
}
|
||||
const allLayerTowns = mode === "all"
|
||||
? (map.markets || []).filter((p) => (p.population || 0) >= 25000 && !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y)).map((p) => ({ ...p, labelPriorityBase: 120 }))
|
||||
: [];
|
||||
const important = [
|
||||
...map.modernCities,
|
||||
...map.ports,
|
||||
...(map.satelliteCities || []),
|
||||
].filter((p) => p.insidePrefecture || p.kind === "External Gateway");
|
||||
drawLabels(ctx, important, 60);
|
||||
...allLayerTowns,
|
||||
].filter((p) => p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 25000);
|
||||
drawLabels(ctx, important, mode === "all" ? 85 : 60);
|
||||
}
|
||||
drawScaleBar(ctx);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,4 +65,11 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
|
|||
.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.visible{opacity:1;transform:translateY(0)}
|
||||
|
||||
.generation-progress{position:absolute;inset:24px auto auto 24px;z-index:30;min-width:300px;max-width:440px;background:rgba(255,255,255,0.96);border:1px solid rgba(0,0,0,0.12);border-radius:12px;box-shadow:0 14px 36px rgba(0,0,0,0.14);padding:14px 16px;color:#202124;font-size:13px;line-height:1.5}
|
||||
.generation-progress.hidden{display:none}
|
||||
.progress-title{font-weight:700;margin-bottom:4px}
|
||||
.progress-stage{color:#5f6368;margin-bottom:10px}
|
||||
.progress-timings{display:flex;flex-direction:column;gap:4px;font-family:ui-monospace,monospace;font-size:12px;color:#3c4043}
|
||||
.progress-timing-row{display:flex;justify-content:space-between;gap:16px;border-top:1px solid rgba(0,0,0,0.06);padding-top:4px}
|
||||
|
|
|
|||
2
test.js
2
test.js
|
|
@ -531,7 +531,7 @@ try {
|
|||
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(Array.isArray(map.harborWorks), "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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue