Compare commits
7 commits
b17be0e0d2
...
27ceb6568a
| Author | SHA1 | Date | |
|---|---|---|---|
| 27ceb6568a | |||
| f2d0306d96 | |||
| 112e6bf86b | |||
| ea0067dc0a | |||
| c523bf4380 | |||
| 860471f805 | |||
| d2e7a80e72 |
28 changed files with 8127 additions and 2017 deletions
|
|
@ -302,6 +302,101 @@ export function removeMunicipalExclaves(adminId, prefectureMask, sea, adminCente
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function enforceMunicipalityConnectivityStrict(adminId, prefectureMask, sea, adminCenters = [], protectedPoints = [], maxPasses = 6) {
|
||||||
|
// Final cell-level invariant: each municipality should be one contiguous land
|
||||||
|
// component. Earlier stages are allowed to leave sizeable satellite pieces
|
||||||
|
// while boundaries are still being snapped; this pass removes the remaining
|
||||||
|
// visual exclaves by attaching every non-primary component to the neighboring
|
||||||
|
// municipality with the largest shared boundary. A component that contains a
|
||||||
|
// protected point may become the primary component, but it no longer protects
|
||||||
|
// additional detached pieces.
|
||||||
|
const protectedByAdmin = new Map();
|
||||||
|
for (const p of [...(adminCenters || []), ...(protectedPoints || [])]) {
|
||||||
|
if (!p || !inside(p.x, p.y)) continue;
|
||||||
|
const i = indexOf(Math.round(p.x), Math.round(p.y));
|
||||||
|
const id = adminId[i];
|
||||||
|
if (id < 0) continue;
|
||||||
|
if (!protectedByAdmin.has(id)) protectedByAdmin.set(id, new Set());
|
||||||
|
protectedByAdmin.get(id).add(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = 0;
|
||||||
|
const queue = [];
|
||||||
|
for (let pass = 0; pass < maxPasses; pass++) {
|
||||||
|
let passChanged = 0;
|
||||||
|
const ids = new Set();
|
||||||
|
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && adminId[i] >= 0) ids.add(adminId[i]);
|
||||||
|
for (const id of ids) {
|
||||||
|
const seen = new Uint8Array(SIZE);
|
||||||
|
const components = [];
|
||||||
|
for (let i = 0; i < SIZE; i++) {
|
||||||
|
if (seen[i] || adminId[i] !== id || !prefectureMask[i] || sea[i]) continue;
|
||||||
|
const comp = [];
|
||||||
|
let protectedHits = 0;
|
||||||
|
queue.length = 0;
|
||||||
|
queue.push(i);
|
||||||
|
seen[i] = 1;
|
||||||
|
for (let q = 0; q < queue.length; q++) {
|
||||||
|
const cur = queue[q];
|
||||||
|
comp.push(cur);
|
||||||
|
if (protectedByAdmin.get(id)?.has(cur)) protectedHits++;
|
||||||
|
const [x, y] = xyOf(cur);
|
||||||
|
for (const [nx, ny] of neighbors4(x, y)) {
|
||||||
|
const ni = indexOf(nx, ny);
|
||||||
|
if (seen[ni] || adminId[ni] !== id || !prefectureMask[ni] || sea[ni]) continue;
|
||||||
|
seen[ni] = 1;
|
||||||
|
queue.push(ni);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
components.push({ cells: comp, protectedHits });
|
||||||
|
}
|
||||||
|
if (components.length <= 1) continue;
|
||||||
|
components.sort((a, b) =>
|
||||||
|
(b.protectedHits ? 1_000_000 : 0) + b.cells.length -
|
||||||
|
((a.protectedHits ? 1_000_000 : 0) + a.cells.length)
|
||||||
|
);
|
||||||
|
const primary = components[0];
|
||||||
|
for (const component of components.slice(1)) {
|
||||||
|
const counts = new Map();
|
||||||
|
for (const ci of component.cells) {
|
||||||
|
const [x, y] = xyOf(ci);
|
||||||
|
for (const [nx, ny] of neighbors4(x, y)) {
|
||||||
|
const ni = indexOf(nx, ny);
|
||||||
|
if (!prefectureMask[ni] || sea[ni]) continue;
|
||||||
|
const other = adminId[ni];
|
||||||
|
if (other >= 0 && other !== id) counts.set(other, (counts.get(other) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let target = -1;
|
||||||
|
let best = -1;
|
||||||
|
for (const [other, count] of counts) {
|
||||||
|
const bonus = protectedByAdmin.get(other)?.size ? 0.25 : 0;
|
||||||
|
const score = count + bonus;
|
||||||
|
if (score > best || (score === best && other < target)) { best = score; target = other; }
|
||||||
|
}
|
||||||
|
if (target < 0) {
|
||||||
|
// Very rare: a detached island component has no labeled neighbor.
|
||||||
|
// Keep the largest/protected primary and merge the component into it
|
||||||
|
// only if it is directly adjacent after previous changes; otherwise
|
||||||
|
// leave it for the next pass rather than inventing over-sea ownership.
|
||||||
|
target = id;
|
||||||
|
}
|
||||||
|
if (target >= 0 && target !== id) {
|
||||||
|
for (const ci of component.cells) adminId[ci] = target;
|
||||||
|
passChanged += component.cells.length;
|
||||||
|
} else if (component !== primary) {
|
||||||
|
// If no external target exists, still mark it as handled by keeping it;
|
||||||
|
// another pass may expose a target after surrounding cells change.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed += passChanged;
|
||||||
|
if (!passChanged) break;
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
export function terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse) {
|
export function terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse) {
|
||||||
const urbanPenalty = urbanBoundaryPenalty(i, populationDensity, landuse);
|
const urbanPenalty = urbanBoundaryPenalty(i, populationDensity, landuse);
|
||||||
const majorRiver = clamp(Math.max(river[i] - 0.32, 0) * 1.9 + Math.max(flowAccum[i] - 0.38, 0) * 0.75);
|
const majorRiver = clamp(Math.max(river[i] - 0.32, 0) * 1.9 + Math.max(flowAccum[i] - 0.38, 0) * 0.75);
|
||||||
|
|
|
||||||
791
app.js
791
app.js
|
|
@ -1,6 +1,10 @@
|
||||||
import { generateMapAsync } from "./mapGenerator.js";
|
import { generateMapAsync } from "./mapGenerator.js";
|
||||||
import { drawMap } from "./renderer.js";
|
import { drawMap } from "./renderer.js";
|
||||||
import { landuseLabel } from "./landuseCodes.js";
|
import { landuseLabel } from "./landuseCodes.js";
|
||||||
|
import { CELL_SIZE, MAP_H, MAP_W } from "./mapUtils.js";
|
||||||
|
import { clampCameraToWorld, createInitialCamera, createWorldMap, ensureWorldPaddingForCamera } from "./worldMap.js";
|
||||||
|
import { getViewportMap } from "./worldViewport.js";
|
||||||
|
import { PATCH_MIN_AREA, PATCH_MIN_HEIGHT, PATCH_MIN_WIDTH, buildPatchRects, generatePatch, validatePatchRect } from "./mapPatch.js";
|
||||||
|
|
||||||
const modes = [
|
const modes = [
|
||||||
["all", "All"],
|
["all", "All"],
|
||||||
|
|
@ -16,89 +20,487 @@ const modes = [
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
seedText: "114514",
|
seedText: "114514",
|
||||||
|
generationType: "auto",
|
||||||
mode: "all",
|
mode: "all",
|
||||||
showFeatures: true,
|
showFeatures: true,
|
||||||
showLabels: true,
|
showLabels: true,
|
||||||
map: null,
|
map: null,
|
||||||
|
world: null,
|
||||||
|
camera: { x: 0, y: 0 },
|
||||||
|
viewportMap: null,
|
||||||
|
viewWidth: MAP_W,
|
||||||
|
viewHeight: MAP_H,
|
||||||
hoverEntities: [],
|
hoverEntities: [],
|
||||||
|
selectionRect: null,
|
||||||
|
patchVariant: 0,
|
||||||
|
zoom: 1,
|
||||||
|
lastPatchResult: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const canvas = document.getElementById("mapCanvas");
|
const canvas = document.getElementById("mapCanvas");
|
||||||
const canvasShell = document.querySelector(".canvas-shell");
|
const canvasShell = document.querySelector(".canvas-shell");
|
||||||
const seedInput = document.getElementById("seed");
|
const seedInput = document.getElementById("seed");
|
||||||
|
const generationTypeInput = document.getElementById("generationType");
|
||||||
|
const patchTerrainTypeInput = document.getElementById("patchTerrainType");
|
||||||
|
const patchVariantInput = document.getElementById("patchVariant");
|
||||||
|
const generatePatchButton = document.getElementById("generatePatch");
|
||||||
|
const alternativePatchButton = document.getElementById("alternativePatch");
|
||||||
|
const patchStatusEl = document.getElementById("patchStatus");
|
||||||
const randomSeedButton = document.getElementById("randomSeed");
|
const randomSeedButton = document.getElementById("randomSeed");
|
||||||
const showFeaturesInput = document.getElementById("showFeatures");
|
const showFeaturesInput = document.getElementById("showFeatures");
|
||||||
const showLabelsInput = document.getElementById("showLabels");
|
const showLabelsInput = document.getElementById("showLabels");
|
||||||
const modeGrid = document.getElementById("modeGrid");
|
const modeGrid = document.getElementById("modeGrid");
|
||||||
const statsEl = document.getElementById("stats");
|
const statsEl = document.getElementById("stats");
|
||||||
const tooltipEl = document.getElementById("mapTooltip");
|
const tooltipEl = document.getElementById("mapTooltip");
|
||||||
|
const selectionSvgEl = document.getElementById("mapSelectionSvg");
|
||||||
|
const selectionEl = document.getElementById("mapSelection");
|
||||||
const progressEl = document.getElementById("generationProgress");
|
const progressEl = document.getElementById("generationProgress");
|
||||||
const progressStageEl = document.getElementById("generationProgressStage");
|
const progressStageEl = document.getElementById("generationProgressStage");
|
||||||
const progressTimingsEl = document.getElementById("generationProgressTimings");
|
const progressTimingsEl = document.getElementById("generationProgressTimings");
|
||||||
let generationStartedAt = 0;
|
let generationStartedAt = 0;
|
||||||
let generationCurrentStage = "";
|
let generationCurrentStage = "";
|
||||||
let generationTimer = null;
|
let generationTimer = null;
|
||||||
|
let zoomRedrawRaf = null;
|
||||||
|
let zoomSettledTimer = null;
|
||||||
|
|
||||||
const panState = { keys: new Set(), raf: null, lastTime: 0, speedPxPerSecond: 520 };
|
const dragState = {
|
||||||
|
mode: null,
|
||||||
|
pointerId: null,
|
||||||
|
startClientX: 0,
|
||||||
|
startClientY: 0,
|
||||||
|
startCameraX: 0,
|
||||||
|
startCameraY: 0,
|
||||||
|
selectStart: null,
|
||||||
|
selectEnd: null,
|
||||||
|
selectPath: null,
|
||||||
|
pendingCamera: null,
|
||||||
|
panRaf: null,
|
||||||
|
};
|
||||||
|
|
||||||
function mapClientToCell(event) {
|
function activeMap() {
|
||||||
if (!state.map) return null;
|
return state.viewportMap || state.map;
|
||||||
const rect = canvas.getBoundingClientRect();
|
}
|
||||||
if (!rect.width || !rect.height) return null;
|
|
||||||
const relX = (event.clientX - rect.left) / rect.width;
|
function clampZoom(value) {
|
||||||
const relY = (event.clientY - rect.top) / rect.height;
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed)) return 1;
|
||||||
|
return Math.min(Math.max(parsed, 0.55), 2.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewportSizeForZoom(zoom = state.zoom) {
|
||||||
|
const z = clampZoom(zoom || 1);
|
||||||
return {
|
return {
|
||||||
x: Math.floor(relX * state.map.width),
|
width: Math.max(1, Math.ceil(MAP_W / z)),
|
||||||
y: Math.floor(relY * state.map.height),
|
height: Math.max(1, Math.ceil(MAP_H / z)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEditableTarget(target) {
|
function clampCameraForView(camera, size = viewportSizeForZoom(state.zoom)) {
|
||||||
if (!target) return false;
|
return clampCameraToWorld(camera, state.world, size?.width || MAP_W, size?.height || MAP_H);
|
||||||
const tag = target.tagName?.toLowerCase?.();
|
|
||||||
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function panFrame(time) {
|
function syncViewportSize() {
|
||||||
if (!canvasShell || panState.keys.size === 0) {
|
const size = viewportSizeForZoom(state.zoom);
|
||||||
panState.raf = null;
|
state.viewWidth = size.width;
|
||||||
panState.lastTime = 0;
|
state.viewHeight = size.height;
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCellScreenSize(map = activeMap()) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mapWidth = Math.max(1, map?.width || state.viewWidth || MAP_W);
|
||||||
|
return rect.width ? rect.width / mapWidth : CELL_SIZE * clampZoom(state.zoom || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCanvasZoom() {
|
||||||
|
if (!canvas) return;
|
||||||
|
state.zoom = clampZoom(state.zoom || 1);
|
||||||
|
// Keep the canvas element at a stable size. Zoom is applied inside the
|
||||||
|
// renderer transform, not by resizing the scrollable shell.
|
||||||
|
canvas.style.width = `${MAP_W * CELL_SIZE}px`;
|
||||||
|
canvas.style.height = `${MAP_H * CELL_SIZE}px`;
|
||||||
|
updateSelectionOverlayFromWorldRect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayedCellSize() {
|
||||||
|
return mapCellScreenSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function screenPointToMapPixel(clientX, clientY, sizeOverride = null) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
if (!rect.width || !rect.height) return null;
|
||||||
|
const map = activeMap();
|
||||||
|
const viewWidth = Math.max(1, sizeOverride?.width || map?.width || state.viewWidth || MAP_W);
|
||||||
|
const viewHeight = Math.max(1, sizeOverride?.height || map?.height || state.viewHeight || MAP_H);
|
||||||
|
const canvasX = (clientX - rect.left) * ((canvas.width || MAP_W * CELL_SIZE) / rect.width);
|
||||||
|
const canvasY = (clientY - rect.top) * ((canvas.height || MAP_H * CELL_SIZE) / rect.height);
|
||||||
|
const cellX = canvasX / Math.max(1e-6, (canvas.width || MAP_W * CELL_SIZE) / viewWidth);
|
||||||
|
const cellY = canvasY / Math.max(1e-6, (canvas.height || MAP_H * CELL_SIZE) / viewHeight);
|
||||||
|
return { x: cellX * CELL_SIZE, y: cellY * CELL_SIZE };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPixelToScreenPoint(px, py) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const map = activeMap();
|
||||||
|
const viewWidth = Math.max(1, map?.width || state.viewWidth || MAP_W);
|
||||||
|
const viewHeight = Math.max(1, map?.height || state.viewHeight || MAP_H);
|
||||||
|
const canvasX = (px / CELL_SIZE) * ((canvas.width || MAP_W * CELL_SIZE) / viewWidth);
|
||||||
|
const canvasY = (py / CELL_SIZE) * ((canvas.height || MAP_H * CELL_SIZE) / viewHeight);
|
||||||
|
return {
|
||||||
|
x: canvas.offsetLeft + canvasX * (rect.width / Math.max(1, canvas.width || MAP_W * CELL_SIZE)),
|
||||||
|
y: canvas.offsetTop + canvasY * (rect.height / Math.max(1, canvas.height || MAP_H * CELL_SIZE)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapClientToCell(event, sizeOverride = null) {
|
||||||
|
const map = activeMap();
|
||||||
|
if (!map && !sizeOverride) return null;
|
||||||
|
const p = screenPointToMapPixel(event.clientX, event.clientY, sizeOverride);
|
||||||
|
if (!p) return null;
|
||||||
|
return {
|
||||||
|
x: Math.floor(p.x / CELL_SIZE),
|
||||||
|
y: Math.floor(p.y / CELL_SIZE),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewportCellToWorldCell(cell) {
|
||||||
|
if (!cell || !state.camera) return null;
|
||||||
|
return {
|
||||||
|
x: Math.round(state.camera.x || 0) + cell.x,
|
||||||
|
y: Math.round(state.camera.y || 0) + cell.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampCanvasPoint(event) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: Math.min(Math.max(event.clientX - rect.left, 0), rect.width),
|
||||||
|
y: Math.min(Math.max(event.clientY - rect.top, 0), rect.height),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function screenPointToWorldCell(point) {
|
||||||
|
const map = activeMap();
|
||||||
|
if (!map || !point) return null;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
if (!rect.width || !rect.height) return null;
|
||||||
|
const viewWidth = Math.max(1, map.width || state.viewWidth || MAP_W);
|
||||||
|
const viewHeight = Math.max(1, map.height || state.viewHeight || MAP_H);
|
||||||
|
const canvasX = point.x * ((canvas.width || MAP_W * CELL_SIZE) / rect.width);
|
||||||
|
const canvasY = point.y * ((canvas.height || MAP_H * CELL_SIZE) / rect.height);
|
||||||
|
const localX = Math.floor((canvasX / Math.max(1e-6, (canvas.width || MAP_W * CELL_SIZE) / viewWidth)));
|
||||||
|
const localY = Math.floor((canvasY / Math.max(1e-6, (canvas.height || MAP_H * CELL_SIZE) / viewHeight)));
|
||||||
|
const cameraX = Math.round(state.camera?.x || 0);
|
||||||
|
const cameraY = Math.round(state.camera?.y || 0);
|
||||||
|
return {
|
||||||
|
x: cameraX + Math.min(Math.max(localX, 0), Math.max(0, map.width - 1)),
|
||||||
|
y: cameraY + Math.min(Math.max(localY, 0), Math.max(0, map.height - 1)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function worldCellToOverlayPoint(point) {
|
||||||
|
const cameraX = Math.round(state.camera?.x || 0);
|
||||||
|
const cameraY = Math.round(state.camera?.y || 0);
|
||||||
|
const screen = mapPixelToScreenPoint((point.x - cameraX + 0.5) * CELL_SIZE, (point.y - cameraY + 0.5) * CELL_SIZE);
|
||||||
|
return { x: screen.x - canvas.offsetLeft, y: screen.y - canvas.offsetTop };
|
||||||
|
}
|
||||||
|
|
||||||
|
function simplifySelectionPath(points) {
|
||||||
|
const out = [];
|
||||||
|
for (const p of points || []) {
|
||||||
|
if (!out.length || Math.hypot(out[out.length - 1].x - p.x, out[out.length - 1].y - p.y) >= 6) out.push(p);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function polygonArea(points) {
|
||||||
|
let area = 0;
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const a = points[i];
|
||||||
|
const b = points[(i + 1) % points.length];
|
||||||
|
area += a.x * b.y - b.x * a.y;
|
||||||
|
}
|
||||||
|
return Math.abs(area) * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionPathToShape(points) {
|
||||||
|
const simplified = simplifySelectionPath(points || []);
|
||||||
|
if (simplified.length < 3) return null;
|
||||||
|
const polygon = simplified.map(screenPointToWorldCell).filter(Boolean);
|
||||||
|
if (polygon.length < 3) return null;
|
||||||
|
const xs = polygon.map((p) => p.x);
|
||||||
|
const ys = polygon.map((p) => p.y);
|
||||||
|
return {
|
||||||
|
kind: "lasso",
|
||||||
|
polygon,
|
||||||
|
x0: Math.min(...xs),
|
||||||
|
y0: Math.min(...ys),
|
||||||
|
x1: Math.max(...xs) + 1,
|
||||||
|
y1: Math.max(...ys) + 1,
|
||||||
|
areaCells: Math.max(1, Math.round(polygonArea(polygon))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSelectionSvg(points, invalid = false) {
|
||||||
|
if (!selectionSvgEl) return;
|
||||||
|
if (!points || points.length < 3) {
|
||||||
|
selectionSvgEl.style.display = "none";
|
||||||
|
selectionSvgEl.innerHTML = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const dt = panState.lastTime ? Math.min(0.05, (time - panState.lastTime) / 1000) : 0;
|
const pts = points.map((p) => `${p.x},${p.y}`).join(" ");
|
||||||
panState.lastTime = time;
|
selectionSvgEl.setAttribute("viewBox", `0 0 ${canvas.clientWidth || canvas.width || 1} ${canvas.clientHeight || canvas.height || 1}`);
|
||||||
let dx = 0;
|
selectionSvgEl.innerHTML = `<polygon points="${pts}" />`;
|
||||||
let dy = 0;
|
selectionSvgEl.style.display = "block";
|
||||||
if (panState.keys.has("a")) dx -= 1;
|
selectionSvgEl.classList.toggle("invalid", !!invalid);
|
||||||
if (panState.keys.has("d")) dx += 1;
|
}
|
||||||
if (panState.keys.has("w")) dy -= 1;
|
|
||||||
if (panState.keys.has("s")) dy += 1;
|
function hideSelectionSvg() {
|
||||||
if (dx || dy) {
|
if (!selectionSvgEl) return;
|
||||||
const normalizer = dx && dy ? Math.SQRT1_2 : 1;
|
selectionSvgEl.style.display = "none";
|
||||||
const amount = panState.speedPxPerSecond * dt;
|
selectionSvgEl.innerHTML = "";
|
||||||
canvasShell.scrollLeft += dx * normalizer * amount;
|
selectionSvgEl.classList.remove("invalid");
|
||||||
canvasShell.scrollTop += dy * normalizer * amount;
|
}
|
||||||
tooltipEl?.classList.remove("visible");
|
|
||||||
|
function updateSelectionOverlay() {
|
||||||
|
if (!dragState.selectPath?.length) return;
|
||||||
|
const liveShape = selectionPathToShape(dragState.selectPath);
|
||||||
|
const validation = validatePatchRect(liveShape, state.world);
|
||||||
|
drawSelectionSvg(dragState.selectPath, !validation.ok);
|
||||||
|
if (selectionEl) selectionEl.style.display = "none";
|
||||||
|
if (generatePatchButton) generatePatchButton.disabled = true;
|
||||||
|
if (alternativePatchButton) alternativePatchButton.disabled = true;
|
||||||
|
if (patchStatusEl) {
|
||||||
|
const current = validation.rect || liveShape;
|
||||||
|
patchStatusEl.textContent = validation.ok
|
||||||
|
? `Selected: ${formatRectSize(validation.rect)}. Release to confirm patch selection.`
|
||||||
|
: `${validation.reason} Current: ${formatRectSize(current)}.`;
|
||||||
|
patchStatusEl.classList.toggle("invalid", !validation.ok);
|
||||||
}
|
}
|
||||||
panState.raf = requestAnimationFrame(panFrame);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startKeyboardPan() {
|
function updateSelectionOverlayFromWorldRect() {
|
||||||
if (panState.raf == null) panState.raf = requestAnimationFrame(panFrame);
|
if (!state.selectionRect || !state.camera || !activeMap()) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
if (!rect.width || !rect.height) return;
|
||||||
|
if (Array.isArray(state.selectionRect.polygon) && state.selectionRect.polygon.length >= 3) {
|
||||||
|
const points = state.selectionRect.polygon.map(worldCellToOverlayPoint)
|
||||||
|
.map((p) => ({ x: Math.min(Math.max(p.x, 0), rect.width), y: Math.min(Math.max(p.y, 0), rect.height) }));
|
||||||
|
drawSelectionSvg(points, !validatePatchRect(state.selectionRect, state.world).ok);
|
||||||
|
if (selectionEl) selectionEl.style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cameraX = Math.round(state.camera.x || 0);
|
||||||
|
const cameraY = Math.round(state.camera.y || 0);
|
||||||
|
const p0 = mapPixelToScreenPoint((state.selectionRect.x0 - cameraX) * CELL_SIZE, (state.selectionRect.y0 - cameraY) * CELL_SIZE);
|
||||||
|
const p1 = mapPixelToScreenPoint((state.selectionRect.x1 - cameraX) * CELL_SIZE, (state.selectionRect.y1 - cameraY) * CELL_SIZE);
|
||||||
|
const vx0 = p0.x - canvas.offsetLeft;
|
||||||
|
const vy0 = p0.y - canvas.offsetTop;
|
||||||
|
const vx1 = p1.x - canvas.offsetLeft;
|
||||||
|
const vy1 = p1.y - canvas.offsetTop;
|
||||||
|
const x0 = Math.min(Math.max(Math.min(vx0, vx1), 0), rect.width);
|
||||||
|
const y0 = Math.min(Math.max(Math.min(vy0, vy1), 0), rect.height);
|
||||||
|
const x1 = Math.min(Math.max(Math.max(vx0, vx1), 0), rect.width);
|
||||||
|
const y1 = Math.min(Math.max(Math.max(vy0, vy1), 0), rect.height);
|
||||||
|
if (x1 - x0 < 1 || y1 - y0 < 1) {
|
||||||
|
selectionEl.style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hideSelectionSvg();
|
||||||
|
selectionEl.style.display = "block";
|
||||||
|
selectionEl.style.left = `${canvas.offsetLeft + x0}px`;
|
||||||
|
selectionEl.style.top = `${canvas.offsetTop + y0}px`;
|
||||||
|
selectionEl.style.width = `${Math.max(1, x1 - x0)}px`;
|
||||||
|
selectionEl.style.height = `${Math.max(1, y1 - y0)}px`;
|
||||||
|
const validation = validatePatchRect(state.selectionRect, state.world);
|
||||||
|
selectionEl.classList.toggle("invalid", !validation.ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePanKeyDown(event) {
|
function formatRectSize(rect) {
|
||||||
const key = event.key?.toLowerCase?.();
|
if (!rect) return "-";
|
||||||
if (!key || !"wasd".includes(key) || isEditableTarget(event.target)) return;
|
const w = Math.max(0, rect.x1 - rect.x0);
|
||||||
panState.keys.add(key);
|
const h = Math.max(0, rect.y1 - rect.y0);
|
||||||
startKeyboardPan();
|
const area = Math.max(0, rect.areaCells || (w * h));
|
||||||
|
return `${w} x ${h} cells / ${area.toLocaleString()} cells`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePatchVariant(value) {
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) ? Math.max(0, parsed) >>> 0 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPatchVariant(value, { update = true } = {}) {
|
||||||
|
state.patchVariant = normalizePatchVariant(value);
|
||||||
|
if (patchVariantInput && patchVariantInput.value !== String(state.patchVariant)) {
|
||||||
|
patchVariantInput.value = String(state.patchVariant);
|
||||||
|
}
|
||||||
|
if (update) updatePatchControls();
|
||||||
|
return state.patchVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPatchVariant() {
|
||||||
|
return setPatchVariant(patchVariantInput?.value ?? state.patchVariant, { update: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPatchVariant({ update = true } = {}) {
|
||||||
|
return setPatchVariant(0, { update });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePatchControls() {
|
||||||
|
if (!patchStatusEl && !generatePatchButton) return;
|
||||||
|
const validation = validatePatchRect(state.selectionRect, state.world);
|
||||||
|
const variant = readPatchVariant();
|
||||||
|
if (generatePatchButton) generatePatchButton.disabled = !validation.ok;
|
||||||
|
if (alternativePatchButton) alternativePatchButton.disabled = !validation.ok;
|
||||||
|
if (!patchStatusEl) return;
|
||||||
|
if (!state.selectionRect) {
|
||||||
|
patchStatusEl.textContent = `Right-drag a freeform area. Minimum: ${PATCH_MIN_WIDTH} x ${PATCH_MIN_HEIGHT} cells and ${PATCH_MIN_AREA.toLocaleString()} cells. Current variant: ${variant}.`;
|
||||||
|
patchStatusEl.classList.toggle("invalid", false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!validation.ok) {
|
||||||
|
patchStatusEl.textContent = `${validation.reason} Current: ${formatRectSize(validation.rect || state.selectionRect)}.`;
|
||||||
|
patchStatusEl.classList.toggle("invalid", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rects = buildPatchRects(validation.rect, state.world);
|
||||||
|
const patchText = state.lastPatchResult
|
||||||
|
? ` Last patch: ${state.lastPatchResult.label}, variant ${state.lastPatchResult.variant ?? "-"}, mode ${state.lastPatchResult.patchGenerationMode || "legacy-full-pipeline"}, terrain ${state.lastPatchResult.updatedCells.toLocaleString()} cells, coast ${state.lastPatchResult.coastCellsChanged || 0}, natural ${state.lastPatchResult.naturalRegionsUpdated || 0}${state.lastPatchResult.humanGeography?.ok ? `, connectors ${(state.lastPatchResult.humanGeography.roadConnectorsCreated || 0) + (state.lastPatchResult.humanGeography.railwayConnectorsCreated || 0)}, admin ${state.lastPatchResult.humanGeography.adminCellsReassigned || 0}, invalid ports ${state.lastPatchResult.humanGeography.invalidPortsRemoved || 0}` : ""}.`
|
||||||
|
: "";
|
||||||
|
patchStatusEl.textContent = `Variant: ${variant}. Core: ${formatRectSize(rects.coreRect)}. Write: ${formatRectSize(rects.writeRect)}. Context: ${formatRectSize(rects.contextRect)}. Repair: ${formatRectSize(rects.repairRect)}.${patchText}`;
|
||||||
|
patchStatusEl.classList.toggle("invalid", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDragMode() {
|
||||||
|
dragState.mode = null;
|
||||||
|
dragState.pointerId = null;
|
||||||
|
dragState.pendingCamera = null;
|
||||||
|
if (dragState.panRaf != null) {
|
||||||
|
cancelAnimationFrame(dragState.panRaf);
|
||||||
|
dragState.panRaf = null;
|
||||||
|
}
|
||||||
|
canvasShell?.classList.remove("panning", "selecting");
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePanRedraw(camera) {
|
||||||
|
dragState.pendingCamera = camera;
|
||||||
|
if (dragState.panRaf != null) return;
|
||||||
|
dragState.panRaf = requestAnimationFrame(() => {
|
||||||
|
dragState.panRaf = null;
|
||||||
|
if (!dragState.pendingCamera) return;
|
||||||
|
const next = dragState.pendingCamera;
|
||||||
|
dragState.pendingCamera = null;
|
||||||
|
if (next.x === state.camera.x && next.y === state.camera.y) return;
|
||||||
|
state.camera = next;
|
||||||
|
redraw({ fastTerrain: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSelectionOverlay() {
|
||||||
|
dragState.selectStart = null;
|
||||||
|
dragState.selectEnd = null;
|
||||||
|
dragState.selectPath = null;
|
||||||
|
state.selectionRect = null;
|
||||||
|
resetPatchVariant({ update: false });
|
||||||
|
hideSelectionSvg();
|
||||||
|
if (selectionEl) selectionEl.style.display = "none";
|
||||||
|
updatePatchControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionPixelsToCells(start, end) {
|
||||||
|
const a = screenPointToWorldCell(start);
|
||||||
|
const b = screenPointToWorldCell(end);
|
||||||
|
if (!a || !b) return null;
|
||||||
|
return {
|
||||||
|
x0: Math.min(a.x, b.x),
|
||||||
|
y0: Math.min(a.y, b.y),
|
||||||
|
x1: Math.max(a.x, b.x) + 1,
|
||||||
|
y1: Math.max(a.y, b.y) + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionPixelsToShape(start, end, path = null) {
|
||||||
|
if (Array.isArray(path) && path.length >= 3) return selectionPathToShape(path);
|
||||||
|
return selectionPixelsToCells(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMapPointerDown(event) {
|
||||||
|
if (!state.world || !canvasShell) return;
|
||||||
|
if (event.button !== 0 && event.button !== 2) return;
|
||||||
|
dragState.pointerId = event.pointerId;
|
||||||
|
dragState.startClientX = event.clientX;
|
||||||
|
dragState.startClientY = event.clientY;
|
||||||
|
dragState.startCameraX = state.camera.x;
|
||||||
|
dragState.startCameraY = state.camera.y;
|
||||||
|
tooltipEl?.classList.remove("visible");
|
||||||
|
|
||||||
|
if (event.button === 0) {
|
||||||
|
dragState.mode = "pan";
|
||||||
|
canvasShell.classList.add("panning");
|
||||||
|
} else {
|
||||||
|
dragState.mode = "select";
|
||||||
|
dragState.selectStart = clampCanvasPoint(event);
|
||||||
|
dragState.selectEnd = dragState.selectStart;
|
||||||
|
dragState.selectPath = [dragState.selectStart];
|
||||||
|
canvasShell.classList.add("selecting");
|
||||||
|
updateSelectionOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.setPointerCapture?.(event.pointerId);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePanKeyUp(event) {
|
function handleMapPointerMove(event) {
|
||||||
const key = event.key?.toLowerCase?.();
|
if (!dragState.mode || dragState.pointerId !== event.pointerId || !canvasShell) return;
|
||||||
if (!key || !"wasd".includes(key)) return;
|
tooltipEl?.classList.remove("visible");
|
||||||
panState.keys.delete(key);
|
|
||||||
|
if (dragState.mode === "pan") {
|
||||||
|
const cellSize = Math.max(1, displayedCellSize());
|
||||||
|
const dxCells = Math.round((event.clientX - dragState.startClientX) / cellSize);
|
||||||
|
const dyCells = Math.round((event.clientY - dragState.startClientY) / cellSize);
|
||||||
|
const nextCamera = clampCameraForView({
|
||||||
|
x: dragState.startCameraX - dxCells,
|
||||||
|
y: dragState.startCameraY - dyCells,
|
||||||
|
}, viewportSizeForZoom(state.zoom));
|
||||||
|
schedulePanRedraw(nextCamera);
|
||||||
|
} else if (dragState.mode === "select") {
|
||||||
|
dragState.selectEnd = clampCanvasPoint(event);
|
||||||
|
if (!dragState.selectPath || Math.hypot(dragState.selectEnd.x - dragState.selectPath[dragState.selectPath.length - 1].x, dragState.selectEnd.y - dragState.selectPath[dragState.selectPath.length - 1].y) >= 3) {
|
||||||
|
dragState.selectPath = [...(dragState.selectPath || []), dragState.selectEnd];
|
||||||
|
}
|
||||||
|
updateSelectionOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMapPointerUp(event) {
|
||||||
|
if (dragState.pointerId !== event.pointerId) return;
|
||||||
|
const wasPanning = dragState.mode === "pan";
|
||||||
|
if (dragState.mode === "select") {
|
||||||
|
dragState.selectEnd = clampCanvasPoint(event);
|
||||||
|
if (!dragState.selectPath || dragState.selectPath.length < 2) dragState.selectPath = [dragState.selectStart, dragState.selectEnd];
|
||||||
|
else dragState.selectPath = [...dragState.selectPath, dragState.selectEnd];
|
||||||
|
const shape = selectionPixelsToShape(dragState.selectStart, dragState.selectEnd, dragState.selectPath);
|
||||||
|
const width = Math.abs(dragState.selectEnd.x - dragState.selectStart.x);
|
||||||
|
const height = Math.abs(dragState.selectEnd.y - dragState.selectStart.y);
|
||||||
|
if (shape && (dragState.selectPath.length >= 3 || (width >= 4 && height >= 4))) {
|
||||||
|
state.selectionRect = shape;
|
||||||
|
state.lastPatchResult = null;
|
||||||
|
resetPatchVariant({ update: false });
|
||||||
|
updateSelectionOverlayFromWorldRect();
|
||||||
|
updatePatchControls();
|
||||||
|
} else {
|
||||||
|
hideSelectionOverlay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canvas.releasePointerCapture?.(event.pointerId);
|
||||||
|
if (wasPanning && dragState.pendingCamera) {
|
||||||
|
state.camera = dragState.pendingCamera;
|
||||||
|
dragState.pendingCamera = null;
|
||||||
|
}
|
||||||
|
clearDragMode();
|
||||||
|
if (wasPanning) redraw({ fastTerrain: false });
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -253,7 +655,8 @@ function buildHoverEntities(map) {
|
||||||
function nearestEntity(items, x, y, maxDistance = 5) {
|
function nearestEntity(items, x, y, maxDistance = 5) {
|
||||||
let best = null;
|
let best = null;
|
||||||
let bestD = maxDistance;
|
let bestD = maxDistance;
|
||||||
for (const item of items) {
|
for (const item of items || []) {
|
||||||
|
if (!item || !Number.isFinite(item.x) || !Number.isFinite(item.y)) continue;
|
||||||
const d = Math.hypot(item.x - x, item.y - y);
|
const d = Math.hypot(item.x - x, item.y - y);
|
||||||
if (d < bestD) { best = item; bestD = d; }
|
if (d < bestD) { best = item; bestD = d; }
|
||||||
}
|
}
|
||||||
|
|
@ -264,46 +667,140 @@ function landuseName(value) {
|
||||||
return landuseLabel(value);
|
return landuseLabel(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function adminName(map, adminId) {
|
const ADMIN_ID_KEYS = ["adminId", "municipalityId", "adminNumericId", "id", "numericId"];
|
||||||
const center = (map.adminCenters || [])[adminId];
|
const PREFECTURE_ID_KEYS = ["prefectureRegionId", "prefectureId", "id", "numericId"];
|
||||||
return center?.name || (adminId >= 0 ? `Municipality ${adminId + 1}` : "-");
|
const MUNICIPALITY_NAME_KEYS = ["municipalityName", "name", "canonicalSettlementName", "municipalityRootName", "generatedMunicipalityName", "label"];
|
||||||
|
const PREFECTURE_NAME_KEYS = ["prefectureName", "prefectureRegionName", "regionName", "name", "label"];
|
||||||
|
const POPULATION_KEYS = ["municipalityPopulation", "adminPopulation", "population", "estimatedPopulation"];
|
||||||
|
|
||||||
|
function numericIdOf(item, keys = ADMIN_ID_KEYS) {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = item?.[key];
|
||||||
|
if (Number.isFinite(value)) return Math.floor(value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function adminPopulation(map, adminId) {
|
function hasNumericId(item, id, keys = ADMIN_ID_KEYS) {
|
||||||
const center = (map.adminCenters || [])[adminId];
|
if (!item || id == null || id < 0) return false;
|
||||||
return Number.isFinite(center?.municipalityPopulation) ? center.municipalityPopulation : null;
|
return keys.some((key) => Number.isFinite(item?.[key]) && Math.floor(item[key]) === Math.floor(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstUsableText(item, keys) {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = item?.[key];
|
||||||
|
if (!looksNumericName(value)) return String(value).trim();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstPopulation(item) {
|
||||||
|
for (const key of POPULATION_KEYS) {
|
||||||
|
const value = item?.[key];
|
||||||
|
if (Number.isFinite(value) && value > 0) return Math.round(value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminCenterForId(map, adminId) {
|
||||||
|
if (!map || adminId == null || adminId < 0) return null;
|
||||||
|
const centers = (map.adminCenters || []).filter(Boolean);
|
||||||
|
const exact = centers.find((center) => hasNumericId(center, adminId));
|
||||||
|
if (exact) return exact;
|
||||||
|
// Some legacy/admin debug arrays were once addressed by array index. Keep this
|
||||||
|
// only as a guarded fallback so numeric IDs are not mistaken for indexes.
|
||||||
|
const direct = map.adminCenters?.[adminId];
|
||||||
|
return hasNumericId(direct, adminId) ? direct : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksNumericName(name) {
|
||||||
|
if (!name) return true;
|
||||||
|
const text = String(name).trim();
|
||||||
|
return !text || /^-?\d+(?:\s*[,,]\s*\d+)*$/u.test(text) || /^(Municipality|Prefecture|Admin|Region)\s*-?\d+/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestNamedAdminCenter(map, cellIndex, maxDistance = 36, adminId = null) {
|
||||||
|
if (!map || cellIndex < 0) return null;
|
||||||
|
const x = cellIndex % map.width;
|
||||||
|
const y = Math.floor(cellIndex / map.width);
|
||||||
|
let best = null;
|
||||||
|
let bestD = maxDistance;
|
||||||
|
for (const center of map.adminCenters || []) {
|
||||||
|
if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y)) continue;
|
||||||
|
if (adminId != null && adminId >= 0 && !hasNumericId(center, adminId)) continue;
|
||||||
|
if (!firstUsableText(center, MUNICIPALITY_NAME_KEYS)) continue;
|
||||||
|
const d = Math.hypot(center.x - x, center.y - y);
|
||||||
|
if (d < bestD) { best = center; bestD = d; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminName(map, adminId, cellIndex = -1) {
|
||||||
|
const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex, 54, adminId);
|
||||||
|
const name = firstUsableText(center, MUNICIPALITY_NAME_KEYS);
|
||||||
|
if (name) return name;
|
||||||
|
return adminId >= 0 ? "Unnamed municipality" : "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminPopulation(map, adminId, cellIndex = -1) {
|
||||||
|
const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex, 54, adminId);
|
||||||
|
const centerPop = firstPopulation(center);
|
||||||
|
if (centerPop !== null) return centerPop;
|
||||||
|
let sum = 0;
|
||||||
|
let found = false;
|
||||||
|
for (const key of ["modernCities", "satelliteCities", "ports", "markets", "villages"]) {
|
||||||
|
for (const p of map?.[key] || []) {
|
||||||
|
if (!hasNumericId(p, adminId)) continue;
|
||||||
|
const pop = firstPopulation(p);
|
||||||
|
if (pop !== null) { sum += pop; found = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found ? sum : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function prefectureNameForCell(map, i) {
|
function prefectureNameForCell(map, i) {
|
||||||
const id = map.prefectureRegionId?.[i] ?? -1;
|
const id = map.prefectureRegionId?.[i] ?? -1;
|
||||||
const region = (map.prefectureRegions || []).find((p) => p.id === id);
|
const region = (map.prefectureRegions || []).find((p) => hasNumericId(p, id, PREFECTURE_ID_KEYS));
|
||||||
return region?.name || (id >= 0 ? `Prefecture ${id + 1}` : "-");
|
const regionName = firstUsableText(region, PREFECTURE_NAME_KEYS);
|
||||||
|
if (regionName) return regionName;
|
||||||
|
const adminId = map.adminId?.[i] ?? -1;
|
||||||
|
const mappedPref = adminId >= 0 ? map.municipalityToPrefectureId?.[adminId] ?? -1 : -1;
|
||||||
|
const center = mappedPref === id ? nearestNamedAdminCenter(map, i, 90, adminId) : null;
|
||||||
|
const fromCenter = firstUsableText(center, ["prefectureName", "prefectureRegionName", "regionName"]);
|
||||||
|
return fromCenter || (id >= 0 ? "Unnamed prefecture" : "-");
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTooltip(event) {
|
function updateTooltip(event) {
|
||||||
if (!state.map || !tooltipEl) return;
|
const map = activeMap();
|
||||||
|
if (!map || !tooltipEl || dragState.mode) return;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const cell = mapClientToCell(event);
|
const cell = mapClientToCell(event);
|
||||||
if (!cell) return;
|
if (!cell) return;
|
||||||
const { x, y } = cell;
|
const { x, y } = cell;
|
||||||
if (x < 0 || y < 0 || x >= state.map.width || y >= state.map.height) {
|
if (x < 0 || y < 0 || x >= map.width || y >= map.height) {
|
||||||
tooltipEl.classList.remove("visible");
|
tooltipEl.classList.remove("visible");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const i = y * state.map.width + x;
|
const i = y * map.width + x;
|
||||||
|
const worldCell = viewportCellToWorldCell({ x, y });
|
||||||
const entity = nearestEntity(state.hoverEntities, x, y);
|
const entity = nearestEntity(state.hoverEntities, x, y);
|
||||||
const elevation = state.map.elevation?.[i] ?? 0;
|
const elevation = map.elevation?.[i] ?? 0;
|
||||||
const density = state.map.populationDensity?.[i] ?? 0;
|
const density = map.populationDensity?.[i] ?? map.settlementScore?.[i] ?? 0;
|
||||||
const hoveredAdminId = state.map.adminId?.[i] ?? -1;
|
const hoveredAdminId = map.adminId?.[i] ?? -1;
|
||||||
const hoveredAdminPopulation = adminPopulation(state.map, hoveredAdminId);
|
const hoveredAdminPopulation = adminPopulation(map, hoveredAdminId, i);
|
||||||
|
const coordinateText = worldCell ? `World ${worldCell.x}, ${worldCell.y} / View ${x}, ${y}` : `${x}, ${y}`;
|
||||||
|
const entityName = firstUsableText(entity, ["name", "facilityLabel", "municipalityName", "canonicalSettlementName", "kind"]);
|
||||||
|
const entityTitle = entity
|
||||||
|
? `${entityName || adminName(map, hoveredAdminId, i) || entity.kind || "Feature"} / ${entity.kind || "Feature"}`
|
||||||
|
: coordinateText;
|
||||||
const lines = [
|
const lines = [
|
||||||
`<strong>${entity ? `${entity.name} / ${entity.kind}` : `${x}, ${y}`}</strong>`,
|
`<strong>${entityTitle}</strong>`,
|
||||||
`Prefecture: ${prefectureNameForCell(state.map, i)}`,
|
`Prefecture: ${prefectureNameForCell(map, i)}`,
|
||||||
`Admin: ${adminName(state.map, hoveredAdminId)}`,
|
`Admin: ${adminName(map, hoveredAdminId, i)}`,
|
||||||
`Admin Pop: ${hoveredAdminPopulation === null ? "-" : hoveredAdminPopulation.toLocaleString()}`,
|
`Admin Pop: ${hoveredAdminPopulation === null ? "-" : hoveredAdminPopulation.toLocaleString()}`,
|
||||||
`Land: ${state.map.sea?.[i] ? "Sea" : landuseName(state.map.landuse?.[i])}`,
|
`Land: ${map.sea?.[i] ? "Sea" : landuseName(map.landuse?.[i])}`,
|
||||||
`Elevation: ${elevation.toFixed(3)} / Slope: ${(state.map.slope?.[i] ?? 0).toFixed(3)}`,
|
`Elevation: ${elevation.toFixed(3)} / Slope: ${(map.slope?.[i] ?? 0).toFixed(3)}`,
|
||||||
`River: ${(state.map.river?.[i] ?? 0).toFixed(2)} / Density: ${density.toFixed(2)}`,
|
`River: ${(map.river?.[i] ?? 0).toFixed(2)} / Density: ${density.toFixed(2)}`,
|
||||||
];
|
];
|
||||||
if (entity?.population) lines.splice(1, 0, `Population: ${entity.population.toLocaleString()}`);
|
if (entity?.population) lines.splice(1, 0, `Population: ${entity.population.toLocaleString()}`);
|
||||||
tooltipEl.innerHTML = lines.join("<br>");
|
tooltipEl.innerHTML = lines.join("<br>");
|
||||||
|
|
@ -336,11 +833,16 @@ function renderModeButtons() {
|
||||||
|
|
||||||
async function regenerate() {
|
async function regenerate() {
|
||||||
state.seedText = seedInput.value;
|
state.seedText = seedInput.value;
|
||||||
|
state.generationType = generationTypeInput?.value || "auto";
|
||||||
setProgressVisible(true, "Preparing generation...");
|
setProgressVisible(true, "Preparing generation...");
|
||||||
await nextFrame();
|
await nextFrame();
|
||||||
try {
|
try {
|
||||||
state.map = await generateMapAsync(parseSeed(state.seedText), { onProgress: updateGenerationProgress });
|
state.map = await generateMapAsync(parseSeed(state.seedText), { onProgress: updateGenerationProgress, terrainType: state.generationType });
|
||||||
state.hoverEntities = buildHoverEntities(state.map);
|
state.world = createWorldMap(state.map);
|
||||||
|
state.camera = createInitialCamera(state.world);
|
||||||
|
state.lastPatchResult = null;
|
||||||
|
resetPatchVariant({ update: false });
|
||||||
|
hideSelectionOverlay();
|
||||||
renderStats(state.map);
|
renderStats(state.map);
|
||||||
redraw();
|
redraw();
|
||||||
if (progressStageEl) progressStageEl.textContent = `Done in ${formatMs(state.map.generationTotalMs || 0)}`;
|
if (progressStageEl) progressStageEl.textContent = `Done in ${formatMs(state.map.generationTotalMs || 0)}`;
|
||||||
|
|
@ -352,13 +854,133 @@ async function regenerate() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function redraw() {
|
|
||||||
if (!state.map) return;
|
function derivePatchSeed(rect, terrainType, variant = 0) {
|
||||||
drawMap(canvas, state.map, {
|
let h = parseSeed(state.seedText) ^ 0x9e3779b9;
|
||||||
|
h = Math.imul(h ^ (rect.x0 | 0), 1664525) >>> 0;
|
||||||
|
h = Math.imul(h ^ (rect.y0 | 0), 1013904223) >>> 0;
|
||||||
|
h = Math.imul(h ^ (rect.x1 | 0), 2246822519) >>> 0;
|
||||||
|
h = Math.imul(h ^ (rect.y1 | 0), 3266489917) >>> 0;
|
||||||
|
h = Math.imul(h ^ normalizePatchVariant(variant), 668265263) >>> 0;
|
||||||
|
for (const ch of String(terrainType || "auto")) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function handleCanvasWheel(event) {
|
||||||
|
if (!state.world || !activeMap()) return;
|
||||||
|
event.preventDefault();
|
||||||
|
tooltipEl?.classList.remove("visible");
|
||||||
|
const beforeSize = viewportSizeForZoom(state.zoom);
|
||||||
|
const beforeCell = mapClientToCell(event, beforeSize);
|
||||||
|
const beforeWorld = beforeCell ? viewportCellToWorldCell(beforeCell) : null;
|
||||||
|
const oldZoom = clampZoom(state.zoom || 1);
|
||||||
|
const delta = event.deltaY < 0 ? 1.10 : 1 / 1.10;
|
||||||
|
const nextZoom = clampZoom(oldZoom * delta);
|
||||||
|
if (Math.abs(nextZoom - oldZoom) < 0.001) return;
|
||||||
|
state.zoom = nextZoom;
|
||||||
|
const nextSize = syncViewportSize();
|
||||||
|
if (beforeWorld) {
|
||||||
|
const afterCell = mapClientToCell(event, nextSize);
|
||||||
|
if (afterCell) {
|
||||||
|
state.camera = clampCameraForView({
|
||||||
|
x: beforeWorld.x - afterCell.x,
|
||||||
|
y: beforeWorld.y - afterCell.y,
|
||||||
|
}, nextSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wheel events can fire dozens of times per second. Do one lightweight redraw
|
||||||
|
// per frame, then a full labeled/continuous redraw once zooming settles.
|
||||||
|
if (zoomRedrawRaf == null) {
|
||||||
|
zoomRedrawRaf = requestAnimationFrame(() => {
|
||||||
|
zoomRedrawRaf = null;
|
||||||
|
redraw({ fastTerrain: true, allowWorldExpand: false });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (zoomSettledTimer != null) clearTimeout(zoomSettledTimer);
|
||||||
|
zoomSettledTimer = window.setTimeout(() => {
|
||||||
|
zoomSettledTimer = null;
|
||||||
|
redraw({ fastTerrain: false, allowWorldExpand: false });
|
||||||
|
}, 140);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateSelectedPatch() {
|
||||||
|
const validation = validatePatchRect(state.selectionRect, state.world);
|
||||||
|
if (!validation.ok) {
|
||||||
|
updatePatchControls();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const terrainType = patchTerrainTypeInput?.value || generationTypeInput?.value || "auto";
|
||||||
|
const variant = readPatchVariant();
|
||||||
|
const seed = derivePatchSeed(validation.rect, terrainType, variant);
|
||||||
|
setProgressVisible(true, "Generating selected patch...");
|
||||||
|
await nextFrame();
|
||||||
|
try {
|
||||||
|
const result = generatePatch(state.world, validation.rect, { terrainType, seed, variant });
|
||||||
|
if (!result.ok) {
|
||||||
|
if (progressStageEl) progressStageEl.textContent = `Patch failed: ${result.reason || "invalid selection"}`;
|
||||||
|
updatePatchControls();
|
||||||
|
window.setTimeout(() => setProgressVisible(false), 1200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.lastPatchResult = result;
|
||||||
|
redraw();
|
||||||
|
renderStats(state.map);
|
||||||
|
updatePatchControls();
|
||||||
|
const human = result.humanGeography;
|
||||||
|
const humanText = human?.ok ? ` / human: ${human.modernCities || 0} cities, ${human.ports || 0} ports, ${human.villages || 0} villages, ${(human.roadConnectorsCreated || 0) + (human.railwayConnectorsCreated || 0)} connectors, admin ${human.adminCellsReassigned || 0}, invalid ports ${human.invalidPortsRemoved || 0}` : "";
|
||||||
|
if (progressStageEl) progressStageEl.textContent = `Patch generated: ${result.label} / variant ${result.variant ?? variant} / mode ${result.patchGenerationMode || "legacy-full-pipeline"} / core ${formatRectSize(result.rects.coreRect)} / write ${formatRectSize(result.rects.writeRect)} / terrain ${result.updatedCells.toLocaleString()} cells / coast ${result.coastCellsChanged || 0} / natural ${result.naturalRegionsUpdated || 0}${humanText}`;
|
||||||
|
renderTimingRows(result.patchTimings || []);
|
||||||
|
window.setTimeout(() => setProgressVisible(false), 900);
|
||||||
|
} catch (error) {
|
||||||
|
if (progressStageEl) progressStageEl.textContent = `Patch failed: ${error?.message || error}`;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateAlternativePatch() {
|
||||||
|
const validation = validatePatchRect(state.selectionRect, state.world);
|
||||||
|
if (!validation.ok) {
|
||||||
|
updatePatchControls();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPatchVariant(readPatchVariant() + 1, { update: false });
|
||||||
|
await generateSelectedPatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function redraw(options = {}) {
|
||||||
|
if (!state.world) return;
|
||||||
|
const viewSize = syncViewportSize();
|
||||||
|
const expansion = options.allowWorldExpand === false ? null : ensureWorldPaddingForCamera(state.world, state.camera, viewSize.width, viewSize.height);
|
||||||
|
if (expansion?.expanded) {
|
||||||
|
state.camera = { x: (state.camera?.x || 0) + (expansion.dx || 0), y: (state.camera?.y || 0) + (expansion.dy || 0) };
|
||||||
|
if (state.selectionRect) {
|
||||||
|
const dx = expansion.dx || 0;
|
||||||
|
const dy = expansion.dy || 0;
|
||||||
|
state.selectionRect = {
|
||||||
|
...state.selectionRect,
|
||||||
|
x0: state.selectionRect.x0 + dx,
|
||||||
|
y0: state.selectionRect.y0 + dy,
|
||||||
|
x1: state.selectionRect.x1 + dx,
|
||||||
|
y1: state.selectionRect.y1 + dy,
|
||||||
|
polygon: Array.isArray(state.selectionRect.polygon) ? state.selectionRect.polygon.map((p) => ({ x: p.x + dx, y: p.y + dy })) : state.selectionRect.polygon,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.camera = clampCameraForView(state.camera, viewSize);
|
||||||
|
state.viewportMap = getViewportMap(state.world, state.camera, viewSize.width, viewSize.height, { light: !!options.fastTerrain });
|
||||||
|
state.hoverEntities = options.fastTerrain ? [] : buildHoverEntities(state.viewportMap);
|
||||||
|
drawMap(canvas, state.viewportMap, {
|
||||||
mode: state.mode,
|
mode: state.mode,
|
||||||
showFeatures: state.showFeatures,
|
showFeatures: state.showFeatures && !options.fastTerrain,
|
||||||
showLabels: state.showLabels,
|
showLabels: state.showLabels && !options.fastTerrain,
|
||||||
|
continuousTerrain: !options.fastTerrain,
|
||||||
|
fastTerrain: !!options.fastTerrain,
|
||||||
|
zoom: state.zoom || 1,
|
||||||
});
|
});
|
||||||
|
applyCanvasZoom();
|
||||||
|
if (state.selectionRect && dragState.mode !== "select") updateSelectionOverlayFromWorldRect();
|
||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
|
|
@ -369,6 +991,22 @@ function init() {
|
||||||
if (event.key === "Enter") regenerate();
|
if (event.key === "Enter") regenerate();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
generationTypeInput?.addEventListener("change", regenerate);
|
||||||
|
patchTerrainTypeInput?.addEventListener("change", () => {
|
||||||
|
state.lastPatchResult = null;
|
||||||
|
resetPatchVariant({ update: false });
|
||||||
|
updatePatchControls();
|
||||||
|
});
|
||||||
|
patchVariantInput?.addEventListener("change", () => setPatchVariant(patchVariantInput.value));
|
||||||
|
patchVariantInput?.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
setPatchVariant(patchVariantInput.value);
|
||||||
|
generateSelectedPatch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
generatePatchButton?.addEventListener("click", generateSelectedPatch);
|
||||||
|
alternativePatchButton?.addEventListener("click", generateAlternativePatch);
|
||||||
|
|
||||||
randomSeedButton.addEventListener("click", () => {
|
randomSeedButton.addEventListener("click", () => {
|
||||||
seedInput.value = String(Math.floor(Math.random() * 9999999));
|
seedInput.value = String(Math.floor(Math.random() * 9999999));
|
||||||
regenerate();
|
regenerate();
|
||||||
|
|
@ -385,13 +1023,18 @@ function init() {
|
||||||
});
|
});
|
||||||
|
|
||||||
canvasShell?.setAttribute("tabindex", "0");
|
canvasShell?.setAttribute("tabindex", "0");
|
||||||
window.addEventListener("keydown", handlePanKeyDown);
|
canvas.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||||
window.addEventListener("keyup", handlePanKeyUp);
|
canvas.addEventListener("wheel", handleCanvasWheel, { passive: false });
|
||||||
|
canvas.addEventListener("pointerdown", handleMapPointerDown);
|
||||||
|
canvas.addEventListener("pointermove", handleMapPointerMove);
|
||||||
|
canvas.addEventListener("pointerup", handleMapPointerUp);
|
||||||
|
canvas.addEventListener("pointercancel", handleMapPointerUp);
|
||||||
canvas.addEventListener("mousemove", updateTooltip);
|
canvas.addEventListener("mousemove", updateTooltip);
|
||||||
canvas.addEventListener("mouseleave", () => {
|
canvas.addEventListener("mouseleave", () => {
|
||||||
tooltipEl?.classList.remove("visible");
|
tooltipEl?.classList.remove("visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updatePatchControls();
|
||||||
regenerate();
|
regenerate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
40
index.html
40
index.html
|
|
@ -13,12 +13,14 @@
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Prefecture Map Generator v17</h1>
|
<h1>Prefecture Map Generator v17</h1>
|
||||||
<p>Terrain, municipalities, transport, land use, and hover inspection in one generated map.</p>
|
<p>Terrain, municipalities, transport, viewport panning, patch terrain generation, and hover inspection in one generated map.</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="canvas-shell">
|
<div class="canvas-shell">
|
||||||
<canvas id="mapCanvas" class="map-canvas"></canvas>
|
<canvas id="mapCanvas" class="map-canvas"></canvas>
|
||||||
|
<svg id="mapSelectionSvg" class="map-selection-svg" aria-hidden="true"></svg>
|
||||||
|
<div id="mapSelection" class="map-selection" aria-hidden="true"></div>
|
||||||
<div id="generationProgress" class="generation-progress hidden" role="status" aria-live="polite">
|
<div id="generationProgress" class="generation-progress hidden" role="status" aria-live="polite">
|
||||||
<div class="progress-title">Generating map...</div>
|
<div class="progress-title">Generating map...</div>
|
||||||
<div id="generationProgressStage" class="progress-stage">Preparing</div>
|
<div id="generationProgressStage" class="progress-stage">Preparing</div>
|
||||||
|
|
@ -32,9 +34,43 @@
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<label class="label" for="seed">Seed</label>
|
<label class="label" for="seed">Seed</label>
|
||||||
<input id="seed" class="input" value="114514" />
|
<input id="seed" class="input" value="114514" />
|
||||||
|
<label class="label inline-label" for="generationType">Generation Type</label>
|
||||||
|
<select id="generationType" class="input">
|
||||||
|
<option value="auto">Auto</option>
|
||||||
|
<option value="tohoku_spine">Tohoku spine</option>
|
||||||
|
<option value="chubu_mountain">Chubu mountain</option>
|
||||||
|
<option value="setouchi_inland_sea">Setouchi inland sea</option>
|
||||||
|
<option value="oceanic_archipelago">Oceanic archipelago</option>
|
||||||
|
<option value="kanto_alluvial">Kanto alluvial plain</option>
|
||||||
|
<option value="mixed_archipelago">Mixed archipelago</option>
|
||||||
|
</select>
|
||||||
<button id="randomSeed" type="button" class="primary-button">Generate Random Seed</button>
|
<button id="randomSeed" type="button" class="primary-button">Generate Random Seed</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-title">Patch Generation</div>
|
||||||
|
<label class="label" for="patchTerrainType">Patch Terrain Type</label>
|
||||||
|
<select id="patchTerrainType" class="input">
|
||||||
|
<option value="auto">Auto</option>
|
||||||
|
<option value="tohoku_spine">Tohoku spine</option>
|
||||||
|
<option value="chubu_mountain">Chubu mountain</option>
|
||||||
|
<option value="setouchi_inland_sea">Setouchi inland sea</option>
|
||||||
|
<option value="oceanic_archipelago">Oceanic archipelago</option>
|
||||||
|
<option value="kanto_alluvial">Kanto alluvial plain</option>
|
||||||
|
<option value="mixed_archipelago">Mixed archipelago</option>
|
||||||
|
</select>
|
||||||
|
<div class="patch-variant-row">
|
||||||
|
<label class="label patch-variant-label" for="patchVariant">Patch Variant</label>
|
||||||
|
<input id="patchVariant" class="input patch-variant-input" type="number" min="0" step="1" value="0" />
|
||||||
|
</div>
|
||||||
|
<div class="patch-button-row">
|
||||||
|
<button id="generatePatch" type="button" class="primary-button" disabled>Generate Selected Area</button>
|
||||||
|
<button id="alternativePatch" type="button" class="secondary-button" disabled>Alternative</button>
|
||||||
|
</div>
|
||||||
|
<p id="patchStatus" class="patch-status">Right-drag to lasso a freeform patch area.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card-title">Display Layers</div>
|
<div class="card-title">Display Layers</div>
|
||||||
<div id="modeGrid" class="mode-grid"></div>
|
<div id="modeGrid" class="mode-grid"></div>
|
||||||
|
|
@ -77,7 +113,7 @@
|
||||||
|
|
||||||
<section class="card legend">
|
<section class="card legend">
|
||||||
<div class="card-title">Notes</div>
|
<div class="card-title">Notes</div>
|
||||||
<p>Open <code>index.html</code> with Live Server. Open <code>test.html</code> to run browser tests.</p>
|
<p>Open <code>index.html</code> with Live Server. Left-drag pans the viewport; right-drag draws a freeform regeneration area; use Patch Generation to write terrain into that area.</p>
|
||||||
<p>Add preferred reusable place names in <code>CUSTOM_NAME_LIST</code> inside <code>names.js</code>.</p>
|
<p>Add preferred reusable place names in <code>CUSTOM_NAME_LIST</code> inside <code>names.js</code>.</p>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
lockSmallUrbanComponentsToMunicipality,
|
lockSmallUrbanComponentsToMunicipality,
|
||||||
mergeTinyMunicipalities,
|
mergeTinyMunicipalities,
|
||||||
removeMunicipalExclaves,
|
removeMunicipalExclaves,
|
||||||
|
enforceMunicipalityConnectivityStrict,
|
||||||
smoothAdminRegionsTerrainAware,
|
smoothAdminRegionsTerrainAware,
|
||||||
snapAdminBoundariesToTerrain,
|
snapAdminBoundariesToTerrain,
|
||||||
} from "./adminRegions.js";
|
} from "./adminRegions.js";
|
||||||
|
|
@ -162,8 +163,10 @@ function generateAdminLayoutForMask({
|
||||||
});
|
});
|
||||||
const changedAfterFinalCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
|
const changedAfterFinalCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
|
||||||
const compacted = compactWholeCompartmentMunicipalities(adminId, adminCentersRaw, prefectureMask, sea, { populationDensity, plain, slope });
|
const compacted = compactWholeCompartmentMunicipalities(adminId, adminCentersRaw, prefectureMask, sea, { populationDensity, plain, slope });
|
||||||
|
const strictConnectivityChangedCells = enforceMunicipalityConnectivityStrict(compacted.adminId, prefectureMask, sea, compacted.adminCentersRaw, [...modernCities, ...(compacted.adminCentersRaw || [])], 8);
|
||||||
|
const strictEnclaveRepairChangedCells = repairAdminSingleOwnerEnclaves(compacted.adminId, prefectureMask, sea, 4);
|
||||||
const adminBorders = extractAdminBorderSegments(compacted.adminId, prefectureMask);
|
const adminBorders = extractAdminBorderSegments(compacted.adminId, prefectureMask);
|
||||||
const actualMunicipalityCount = compacted.activeMunicipalityCount;
|
const actualMunicipalityCount = new Set([...compacted.adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i])).size;
|
||||||
const naturalCompartmentCount = compartmentAssignment.debug?.naturalCompartmentCount || naturalCompartments.filter((unit) => unit && unit.area > 0).length;
|
const naturalCompartmentCount = compartmentAssignment.debug?.naturalCompartmentCount || naturalCompartments.filter((unit) => unit && unit.area > 0).length;
|
||||||
const adminDebug = {
|
const adminDebug = {
|
||||||
...compartmentAssignment.debug,
|
...compartmentAssignment.debug,
|
||||||
|
|
@ -179,6 +182,8 @@ function generateAdminLayoutForMask({
|
||||||
targetMunicipalityCount,
|
targetMunicipalityCount,
|
||||||
actualMunicipalityCount,
|
actualMunicipalityCount,
|
||||||
finalMunicipalityCount: actualMunicipalityCount,
|
finalMunicipalityCount: actualMunicipalityCount,
|
||||||
|
changedAfterStrictMunicipalityConnectivity: strictConnectivityChangedCells,
|
||||||
|
changedAfterStrictMunicipalityEnclaveRepair: strictEnclaveRepairChangedCells,
|
||||||
candidateSeedCount: adminCentersRaw.length,
|
candidateSeedCount: adminCentersRaw.length,
|
||||||
municipalOfficePointCount: compacted.adminCentersRaw.length,
|
municipalOfficePointCount: compacted.adminCentersRaw.length,
|
||||||
seedCellRevivalCount: 0,
|
seedCellRevivalCount: 0,
|
||||||
|
|
@ -436,6 +441,8 @@ function generateAdminLayoutForMask({
|
||||||
adminDebug.changedAfterUrbanUnification = lockCompactUrbanAreasToDominantAdmin(adminId, prefectureMask, sea, landuse, populationDensity, 980);
|
adminDebug.changedAfterUrbanUnification = lockCompactUrbanAreasToDominantAdmin(adminId, prefectureMask, sea, landuse, populationDensity, 980);
|
||||||
adminDebug.changedAfterAdminEnclaveRepair = repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, 6);
|
adminDebug.changedAfterAdminEnclaveRepair = repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, 6);
|
||||||
adminDebug.changedAfterFinalCompartmentOwnership += enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
|
adminDebug.changedAfterFinalCompartmentOwnership += enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
|
||||||
|
adminDebug.changedAfterStrictMunicipalityConnectivity = enforceMunicipalityConnectivityStrict(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 8);
|
||||||
|
adminDebug.changedAfterStrictMunicipalityEnclaveRepair = repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, 4);
|
||||||
|
|
||||||
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
|
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
|
||||||
const satelliteAreas = [];
|
const satelliteAreas = [];
|
||||||
|
|
|
||||||
327
mapFeatureContext.js
Normal file
327
mapFeatureContext.js
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
import { INF, MAP_H, MAP_W, SIZE, clamp, fbm, hash2, indexOf, inside, pickEntities, valueNoise } from "./mapUtils.js";
|
||||||
|
|
||||||
|
export function buildFeatureContext(seed, terrain) {
|
||||||
|
const {
|
||||||
|
elevation,
|
||||||
|
slope,
|
||||||
|
sea,
|
||||||
|
river,
|
||||||
|
floodplain,
|
||||||
|
plain,
|
||||||
|
agriculture,
|
||||||
|
ridgeField,
|
||||||
|
valleyField,
|
||||||
|
basinField,
|
||||||
|
coastalLowland,
|
||||||
|
arcSpineField,
|
||||||
|
branchRidgeField,
|
||||||
|
depositionalLowland,
|
||||||
|
alluvialFanField,
|
||||||
|
deltaField,
|
||||||
|
portSuitability,
|
||||||
|
prefectureMask,
|
||||||
|
prefectureRegionId,
|
||||||
|
naturalBarrierScore,
|
||||||
|
} = terrain;
|
||||||
|
|
||||||
|
const geography = terrain.geography || {};
|
||||||
|
const geoHabitability = geography.habitability || null;
|
||||||
|
const geoAccessibility = geography.accessibility || null;
|
||||||
|
const geoNaturalCentrality = geography.naturalCentrality || geography.centrality || null;
|
||||||
|
const geoLowlandCapacity = geography.lowlandCapacity || null;
|
||||||
|
const geoValleyAccess = geography.valleyAccess || null;
|
||||||
|
const geoCoastalAccess = geography.coastalAccess || null;
|
||||||
|
const geoBarrier = geography.geographicBarrier || naturalBarrierScore || null;
|
||||||
|
const geoCorridorSuitability = geography.corridorSuitability || null;
|
||||||
|
|
||||||
|
function fieldValue(field, i, fallback = 0) {
|
||||||
|
const v = field?.[i];
|
||||||
|
return Number.isFinite(v) ? v : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (!prefectureRegionId) 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 naturalBarrier = naturalBarrierScore?.[i] || 0;
|
||||||
|
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 openPlainPotential = clamp(plain[i] * 0.68 + agriculture[i] * 0.54 + basinField[i] * 0.30 + lowSlope * 0.22 - river[i] * 0.18 - valleyField[i] * 0.08 - ridgeField[i] * 0.22 - slope[i] * 0.26);
|
||||||
|
const spine = (arcSpineField?.[i] || 0) * 0.58 + (branchRidgeField?.[i] || 0) * 0.38;
|
||||||
|
confluenceField[i] = confluence;
|
||||||
|
|
||||||
|
const geoH = fieldValue(geoHabitability, i, 0);
|
||||||
|
const geoLow = fieldValue(geoLowlandCapacity, i, 0);
|
||||||
|
const geoValley = fieldValue(geoValleyAccess, i, 0);
|
||||||
|
const geoCoast = fieldValue(geoCoastalAccess, i, 0);
|
||||||
|
const geoB = fieldValue(geoBarrier, i, naturalBarrier);
|
||||||
|
const localDevelopable = 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
|
||||||
|
);
|
||||||
|
developable[i] = clamp(localDevelopable * 0.68 + geoH * 0.34 + geoLow * 0.16 - geoB * 0.05);
|
||||||
|
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
|
||||||
|
) * 0.74 + geoValley * 0.30 + geoH * 0.08 - geoB * 0.04);
|
||||||
|
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
|
||||||
|
) * 0.76 + geoCoast * 0.32 + geoH * 0.06 - geoB * 0.04);
|
||||||
|
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.42 + valleySettlement[i] * 0.12 + coastalSettlement[i] * 0.22 + agriculture[i] * 0.48 + plain[i] * 0.32 + openPlainPotential * 0.46) * clusterNoise);
|
||||||
|
ruralSuitability[i] = clamp(
|
||||||
|
agriculture[i] * 0.54 +
|
||||||
|
developable[i] * 0.30 +
|
||||||
|
valleySettlement[i] * 0.18 +
|
||||||
|
coastalSettlement[i] * 0.20 +
|
||||||
|
openPlainPotential * 0.34 +
|
||||||
|
settlementCluster[i] * 0.30 -
|
||||||
|
Math.max(0, elevation[i] - 0.64) * 0.56
|
||||||
|
);
|
||||||
|
townSuitability[i] = clamp(
|
||||||
|
developable[i] * 0.38 +
|
||||||
|
agriculture[i] * 0.18 +
|
||||||
|
valleySettlement[i] * 0.16 +
|
||||||
|
coastalSettlement[i] * 0.30 +
|
||||||
|
confluence * 0.20 +
|
||||||
|
basinField[i] * 0.18 +
|
||||||
|
plain[i] * 0.26 +
|
||||||
|
openPlainPotential * 0.44 +
|
||||||
|
settlementCluster[i] * 0.22 -
|
||||||
|
slope[i] * 0.34 -
|
||||||
|
ridgeField[i] * 0.17 -
|
||||||
|
spine * 0.10
|
||||||
|
);
|
||||||
|
settlementScore[i] = clamp(
|
||||||
|
ruralSuitability[i] * 0.48 +
|
||||||
|
townSuitability[i] * 0.30 +
|
||||||
|
confluence * 0.08 +
|
||||||
|
fieldValue(geoHabitability, i, developable[i]) * 0.18 +
|
||||||
|
fieldValue(geoNaturalCentrality, i, 0) * 0.12 -
|
||||||
|
fieldValue(geoBarrier, i, 0) * 0.06
|
||||||
|
);
|
||||||
|
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,
|
||||||
|
highCentralityCells: 0,
|
||||||
|
habitabilitySum: 0,
|
||||||
|
accessibilitySum: 0,
|
||||||
|
centralitySum: 0,
|
||||||
|
lowlandCapacitySum: 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++;
|
||||||
|
const gHabit = fieldValue(geoHabitability, i, developable[i]);
|
||||||
|
const gAccess = fieldValue(geoAccessibility, i, 0);
|
||||||
|
const gCentral = fieldValue(geoNaturalCentrality, i, townSuitability[i]);
|
||||||
|
const gLow = fieldValue(geoLowlandCapacity, i, plain[i]);
|
||||||
|
st.developableSum += developable[i];
|
||||||
|
st.habitabilitySum += gHabit;
|
||||||
|
st.accessibilitySum += gAccess;
|
||||||
|
st.centralitySum += gCentral;
|
||||||
|
st.lowlandCapacitySum += gLow;
|
||||||
|
if (developable[i] > 0.16 || gHabit > 0.24) st.developableCells++;
|
||||||
|
if (valleySettlement[i] > 0.24 || fieldValue(geoValleyAccess, i, 0) > 0.25) st.valleyCells++;
|
||||||
|
if (coastalSettlement[i] > 0.25 || fieldValue(geoCoastalAccess, i, 0) > 0.24) st.coastCells++;
|
||||||
|
if (townSuitability[i] > 0.28 || gCentral > 0.31) st.townCells++;
|
||||||
|
if (plain[i] > 0.24 || gLow > 0.26) st.plainCells++;
|
||||||
|
if (gCentral > 0.36 && gHabit > 0.18) st.highCentralityCells++;
|
||||||
|
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 (!st || st.area <= 0) return 0;
|
||||||
|
// Treat the focused prefecture and neighboring prefectures with the same
|
||||||
|
// density curve. Only genuinely clipped map-edge slivers are downscaled.
|
||||||
|
return clamp(Math.sqrt(st.area / 1900), 0.32, 1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
geography,
|
||||||
|
geoHabitability,
|
||||||
|
geoAccessibility,
|
||||||
|
geoNaturalCentrality,
|
||||||
|
geoLowlandCapacity,
|
||||||
|
geoValleyAccess,
|
||||||
|
geoCoastalAccess,
|
||||||
|
geoBarrier,
|
||||||
|
geoCorridorSuitability,
|
||||||
|
fieldValue,
|
||||||
|
regionIdAt,
|
||||||
|
inFocusedPrefecture,
|
||||||
|
developable,
|
||||||
|
ruralSuitability,
|
||||||
|
townSuitability,
|
||||||
|
valleySettlement,
|
||||||
|
coastalSettlement,
|
||||||
|
confluenceField,
|
||||||
|
barrierCost,
|
||||||
|
corridorCost,
|
||||||
|
settlementCluster,
|
||||||
|
settlementScore,
|
||||||
|
regionStats,
|
||||||
|
visibilityFactor,
|
||||||
|
pickRegionalPoints,
|
||||||
|
pickGlobalPoints,
|
||||||
|
};
|
||||||
|
}
|
||||||
213
mapFeatureLanduse.js
Normal file
213
mapFeatureLanduse.js
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
import { MAP_H, MAP_W, SIZE, clamp, hash2, indexOf, inside } from "./mapUtils.js";
|
||||||
|
import { LANDUSE } from "./landuseCodes.js";
|
||||||
|
|
||||||
|
export function buildFeatureLanduse(ctx) {
|
||||||
|
const {
|
||||||
|
seed,
|
||||||
|
elevation, slope, sea, river, floodplain, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland,
|
||||||
|
developable, ruralSuitability, valleySettlement, coastalSettlement,
|
||||||
|
modernCities, logisticsParks,
|
||||||
|
roadLanduseInfluence, roadInfluence, roadDensityInfluence, railInfluence2, stationInfluence, stationDensityInfluence, villageInfluence,
|
||||||
|
cityInfluence, coreInfluence, oldTownInfluence, townInfluence, industrialInfluence, logisticsInfluence,
|
||||||
|
} = ctx;
|
||||||
|
const populationDensity = new Float32Array(SIZE);
|
||||||
|
|
||||||
|
const 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);
|
||||||
|
const ruralDensityFloor = 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(roadLanduseInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82);
|
||||||
|
const densityTransport = Math.max(roadDensityInfluence[i] * 0.95, stationDensityInfluence[i] * 1.05, railInfluence2[i] * 0.85);
|
||||||
|
const urban = cityInfluence[i] * 0.76 + stationInfluence[i] * 0.30 + roadInfluence[i] * 0.14 + 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 +
|
||||||
|
roadInfluence[i] * 0.14 + roadDensityInfluence[i] * 0.16 + transport * 0.08 +
|
||||||
|
riverUrban * 0.14 -
|
||||||
|
slope[i] * 0.18 -
|
||||||
|
ridgeField[i] * 0.12 -
|
||||||
|
floodplain[i] * 0.08
|
||||||
|
);
|
||||||
|
const highPenaltyDensity = Math.max(0, elevation[i] - 0.58);
|
||||||
|
const agrarianDensity = clamp(
|
||||||
|
agriculture[i] * 0.045 +
|
||||||
|
ruralSuitability[i] * 0.035 +
|
||||||
|
developable[i] * 0.028 +
|
||||||
|
plain[i] * 0.018 +
|
||||||
|
basinField[i] * 0.014 +
|
||||||
|
valleySettlement[i] * 0.014 +
|
||||||
|
coastalSettlement[i] * 0.012 +
|
||||||
|
villageInfluence[i] * 0.040 +
|
||||||
|
townInfluence[i] * 0.022 +
|
||||||
|
roadDensityInfluence[i] * 0.038 +
|
||||||
|
stationDensityInfluence[i] * 0.020 +
|
||||||
|
railInfluence2[i] * 0.012 -
|
||||||
|
slope[i] * 0.030 -
|
||||||
|
ridgeField[i] * 0.020 -
|
||||||
|
highPenaltyDensity * 0.058
|
||||||
|
);
|
||||||
|
const remoteWilderness = elevation[i] > 0.60 && slope[i] > 0.34 && ridgeField[i] > 0.38 && densityTransport < 0.035 && villageInfluence[i] < 0.025 && townInfluence[i] < 0.025 && cityInfluence[i] < 0.025;
|
||||||
|
ruralDensityFloor[i] = remoteWilderness ? 0 : clamp(agrarianDensity, 0, elevation[i] > 0.62 || slope[i] > 0.42 ? 0.052 : 0.105);
|
||||||
|
populationDensity[i] = clamp(
|
||||||
|
urban * 0.66 +
|
||||||
|
core * 0.46 +
|
||||||
|
oldTown * 0.28 +
|
||||||
|
townInfluence[i] * 0.16 +
|
||||||
|
villageInfluence[i] * 0.14 +
|
||||||
|
roadDensityInfluence[i] * 0.42 +
|
||||||
|
stationDensityInfluence[i] * 0.34 +
|
||||||
|
railInfluence2[i] * 0.12 +
|
||||||
|
transport * 0.05 +
|
||||||
|
agrarianDensity * 0.34
|
||||||
|
);
|
||||||
|
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 (logisticsInfluence[i] > 0.24 && urbanCapacity[i] > 0.08 && (roadInfluence[i] > 0.08 || railInfluence2[i] > 0.06)) {
|
||||||
|
landuse[i] = LANDUSE.LOGISTICS;
|
||||||
|
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 + roadLanduseInfluence[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 (roadLanduseInfluence[i] > 0.30 && urbanCapacity[i] > 0.10 && (townInfluence[i] > 0.05 || cityInfluence[i] > 0.11)) {
|
||||||
|
landuse[i] = LANDUSE.SUBURB;
|
||||||
|
} else if (agriculture[i] > 0.16 || rural > 0.18 || (developable[i] > 0.13 && plain[i] > 0.13) || (basinField[i] > 0.18 && slope[i] < 0.34) || (coastalLowland[i] > 0.16 && slope[i] < 0.32)) {
|
||||||
|
landuse[i] = LANDUSE.FARMLAND;
|
||||||
|
} else {
|
||||||
|
const usablePlain = slope[i] < 0.30 && (plain[i] > 0.18 || developable[i] > 0.20 || basinField[i] > 0.20 || coastalLowland[i] > 0.18);
|
||||||
|
landuse[i] = elevation[i] > 0.58 || slope[i] > 0.38 ? LANDUSE.FOREST : usablePlain ? LANDUSE.FARMLAND : 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(roadLanduseInfluence[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[i + dy * MAP_W + dx];
|
||||||
|
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] = LANDUSE.SUBURB;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((river[i] > 0.10 || railInfluence2[i] > 0.16 || roadLanduseInfluence[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const park of logisticsParks) {
|
||||||
|
const r = 3;
|
||||||
|
for (let dy = -r; dy <= r; dy++) {
|
||||||
|
for (let dx = -r; dx <= r; dx++) {
|
||||||
|
const x = park.x + dx;
|
||||||
|
const y = park.y + dy;
|
||||||
|
if (!inside(x, y)) continue;
|
||||||
|
const i = indexOf(x, y);
|
||||||
|
if (sea[i] || landuse[i] === LANDUSE.CBD || landuse[i] === LANDUSE.FOREST) continue;
|
||||||
|
if (Math.hypot(dx, dy) <= r && (agriculture[i] > 0.18 || plain[i] > 0.15 || roadInfluence[i] > 0.06 || railInfluence2[i] > 0.05)) {
|
||||||
|
landuse[i] = LANDUSE.LOGISTICS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxDensity > 0) {
|
||||||
|
for (let i = 0; i < SIZE; i++) {
|
||||||
|
if (sea[i]) continue;
|
||||||
|
const lu = landuse[i];
|
||||||
|
let floor = ruralDensityFloor[i];
|
||||||
|
if (lu === LANDUSE.FARMLAND) {
|
||||||
|
floor = Math.max(floor, clamp(0.024 + agriculture[i] * 0.044 + ruralSuitability[i] * 0.024 + roadDensityInfluence[i] * 0.030 + stationDensityInfluence[i] * 0.026 + villageInfluence[i] * 0.018, 0, 0.110));
|
||||||
|
} else if (lu === LANDUSE.LOGISTICS) {
|
||||||
|
floor = Math.max(floor, clamp(0.018 + roadDensityInfluence[i] * 0.026 + railInfluence2[i] * 0.014 + logisticsInfluence[i] * 0.012, 0, 0.060));
|
||||||
|
} else if (lu === LANDUSE.RURAL) {
|
||||||
|
floor = Math.max(floor, clamp(0.012 + ruralSuitability[i] * 0.020 + developable[i] * 0.014 + roadDensityInfluence[i] * 0.024 + stationDensityInfluence[i] * 0.020, 0, 0.070));
|
||||||
|
} else if (lu === LANDUSE.FOREST) {
|
||||||
|
floor = Math.min(floor, (roadDensityInfluence[i] > 0.04 || villageInfluence[i] > 0.03) ? 0.026 : 0);
|
||||||
|
}
|
||||||
|
const normalized = populationDensity[i] / maxDensity;
|
||||||
|
populationDensity[i] = clamp(Math.max(normalized, floor));
|
||||||
|
if (lu === LANDUSE.FOREST && floor === 0 && populationDensity[i] < 0.012) populationDensity[i] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { landuse, populationDensity };
|
||||||
|
}
|
||||||
47
mapFeatureSettlements.js
Normal file
47
mapFeatureSettlements.js
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { MAP_H, MAP_W, SIZE, clamp, indexOf } from "./mapUtils.js";
|
||||||
|
import { influenceFromPoints } from "./mapGeneratorHelpers.js";
|
||||||
|
|
||||||
|
export function buildSettlementDemandFields(ctx) {
|
||||||
|
const {
|
||||||
|
sea, agriculture, plain, basinField, coastalLowland, slope, ridgeField,
|
||||||
|
modernCities, markets, commercialPorts, villages,
|
||||||
|
} = ctx;
|
||||||
|
|
||||||
|
const preliminaryUrbanInfluence = influenceFromPoints(modernCities, 18, (c) => clamp((c.population || 60000) / 260000, 0.55, 2.0));
|
||||||
|
const preliminaryTownInfluence = influenceFromPoints([...markets, ...commercialPorts], 10, (p) => p.portClass === "major" ? 1.35 : clamp((p.population || 12000) / 36000, 0.42, 1.1));
|
||||||
|
const preliminaryVillageInfluence = influenceFromPoints(villages, 7, (v) => clamp((v.population || 1800) / 5200, 0.22, 0.9));
|
||||||
|
const settlementDemand = new Float32Array(SIZE);
|
||||||
|
const urbanEdge = new Float32Array(SIZE);
|
||||||
|
const logisticsPreSuitability = 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 density = clamp(preliminaryUrbanInfluence[i] * 0.62 + preliminaryTownInfluence[i] * 0.34 + preliminaryVillageInfluence[i] * 0.16);
|
||||||
|
settlementDemand[i] = density;
|
||||||
|
urbanEdge[i] = clamp(1 - Math.abs(density - 0.46) / 0.32);
|
||||||
|
logisticsPreSuitability[i] = clamp(
|
||||||
|
agriculture[i] * 0.30 +
|
||||||
|
plain[i] * 0.24 +
|
||||||
|
basinField[i] * 0.14 +
|
||||||
|
coastalLowland[i] * 0.12 +
|
||||||
|
preliminaryTownInfluence[i] * 0.18 +
|
||||||
|
urbanEdge[i] * 0.34 -
|
||||||
|
preliminaryUrbanInfluence[i] * 0.20 -
|
||||||
|
slope[i] * 0.50 -
|
||||||
|
ridgeField[i] * 0.32
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
preliminaryUrbanInfluence,
|
||||||
|
preliminaryTownInfluence,
|
||||||
|
preliminaryVillageInfluence,
|
||||||
|
settlementDemand,
|
||||||
|
urbanEdge,
|
||||||
|
logisticsPreSuitability,
|
||||||
|
};
|
||||||
|
}
|
||||||
191
mapFeatureTransportTools.js
Normal file
191
mapFeatureTransportTools.js
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
import { INF, MAP_H, MAP_W, SIZE, clamp, hash2, indexOf, inside } from "./mapUtils.js";
|
||||||
|
|
||||||
|
export function buildFeatureTransportCostFields(ctx) {
|
||||||
|
const {
|
||||||
|
seed,
|
||||||
|
sea, elevation, slope, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland,
|
||||||
|
portSuitability, passSuitability, crossingSuitability, naturalBarrierScore,
|
||||||
|
settlementDemand, urbanEdge, logisticsPreSuitability,
|
||||||
|
preliminaryTownInfluence, preliminaryVillageInfluence,
|
||||||
|
valleySettlement, coastalSettlement, developable,
|
||||||
|
} = ctx;
|
||||||
|
const expressway = new Float32Array(SIZE);
|
||||||
|
const rail = new Float32Array(SIZE);
|
||||||
|
const national = new Float32Array(SIZE);
|
||||||
|
const local = new Float32Array(SIZE);
|
||||||
|
const expresswayPotential = new Float32Array(SIZE);
|
||||||
|
const railPotential = new Float32Array(SIZE);
|
||||||
|
const nationalPotential = new Float32Array(SIZE);
|
||||||
|
const localPotential = new Float32Array(SIZE);
|
||||||
|
|
||||||
|
function seaAdjacency(x, y, radius = 1) {
|
||||||
|
let sum = 0;
|
||||||
|
let total = 0;
|
||||||
|
for (let dy = -radius; dy <= radius; dy++) {
|
||||||
|
for (let dx = -radius; dx <= radius; dx++) {
|
||||||
|
if (!dx && !dy) continue;
|
||||||
|
const nx = x + dx;
|
||||||
|
const ny = y + dy;
|
||||||
|
if (!inside(nx, ny)) continue;
|
||||||
|
total++;
|
||||||
|
if (sea[indexOf(nx, ny)]) sum += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total > 0 ? sum / total : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function highAltitudeTransportClosed(i) {
|
||||||
|
// Above this contour the generator should treat mountains as no-road
|
||||||
|
// terrain. A strong mapped pass is the exception, so genuine saddle
|
||||||
|
// crossings can still exist without roads drilling through entire ranges.
|
||||||
|
return elevation[i] >= 0.70;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let y = 0; y < MAP_H; y++) {
|
||||||
|
for (let x = 0; x < MAP_W; x++) {
|
||||||
|
const i = indexOf(x, y);
|
||||||
|
if (sea[i] || highAltitudeTransportClosed(i)) {
|
||||||
|
expressway[i] = rail[i] = national[i] = local[i] = INF;
|
||||||
|
expresswayPotential[i] = railPotential[i] = nationalPotential[i] = localPotential[i] = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const density = settlementDemand[i];
|
||||||
|
const mediumDensity = clamp(1 - Math.abs(density - 0.42) / 0.30);
|
||||||
|
const highDensity = clamp((density - 0.32) / 0.50);
|
||||||
|
const lowland = clamp(plain[i] * 0.48 + basinField[i] * 0.28 + valleyField[i] * 0.24 + coastalLowland[i] * 0.26 + agriculture[i] * 0.16);
|
||||||
|
const pass = passSuitability?.[i] || 0;
|
||||||
|
const crossing = crossingSuitability?.[i] || 0;
|
||||||
|
const waterCrossingPenalty = river[i] > 0.18 ? (1 - crossing) * (0.72 + river[i] * 1.35) : 0;
|
||||||
|
const seaNear = seaAdjacency(x, y, 1);
|
||||||
|
const seaBroad = seaAdjacency(x, y, 3);
|
||||||
|
const seaWide = seaAdjacency(x, y, 5);
|
||||||
|
// Roads should use coastal lowlands when there is a settlement/port reason,
|
||||||
|
// but should not casually trace beaches or hop over small bays.
|
||||||
|
const coastalTraversePenalty = clamp(seaBroad * 1.72 + seaWide * 0.82 - coastalLowland[i] * 0.48 - (portSuitability?.[i] || 0) * 0.30);
|
||||||
|
const highMountain = clamp((elevation[i] - 0.52) * 3.6 + slope[i] * 0.95 + ridgeField[i] * 1.05 - pass * 0.55 - valleyField[i] * 0.12);
|
||||||
|
const extremeMountain = clamp((elevation[i] - 0.64) * 4.8 + slope[i] * 1.55 + ridgeField[i] * 1.45 - pass * 0.80);
|
||||||
|
const denseCorePenalty = clamp((density - 0.66) / 0.28);
|
||||||
|
const openPlainParallelPenalty = clamp(plain[i] * 0.48 + agriculture[i] * 0.26 - density * 0.20 - valleyField[i] * 0.20 - coastalLowland[i] * 0.12);
|
||||||
|
const boundaryRidgePenalty = Math.pow(naturalBarrierScore?.[i] || 0, 2);
|
||||||
|
|
||||||
|
if (extremeMountain > 0.92 && pass < 0.34) {
|
||||||
|
expressway[i] = rail[i] = INF;
|
||||||
|
national[i] = elevation[i] >= 0.68 ? INF : 2.9 + extremeMountain * 2.8 + waterCrossingPenalty;
|
||||||
|
local[i] = elevation[i] >= 0.69 ? INF : 2.2 + extremeMountain * 2.3 + waterCrossingPenalty * 0.55;
|
||||||
|
expresswayPotential[i] = 0;
|
||||||
|
railPotential[i] = 0;
|
||||||
|
nationalPotential[i] = clamp(lowland * 0.18 + pass * 0.24 - extremeMountain * 0.55);
|
||||||
|
localPotential[i] = clamp(valleySettlement[i] * 0.14 + pass * 0.18 - extremeMountain * 0.28);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
expresswayPotential[i] = clamp(
|
||||||
|
mediumDensity * 0.62 +
|
||||||
|
urbanEdge[i] * 0.38 +
|
||||||
|
logisticsPreSuitability[i] * 0.54 +
|
||||||
|
lowland * 0.36 +
|
||||||
|
agriculture[i] * 0.16 -
|
||||||
|
denseCorePenalty * 0.54 -
|
||||||
|
slope[i] * 0.82 -
|
||||||
|
highMountain * 0.92 -
|
||||||
|
coastalTraversePenalty * 0.32 -
|
||||||
|
river[i] * 0.14
|
||||||
|
);
|
||||||
|
railPotential[i] = clamp(
|
||||||
|
highDensity * 0.80 +
|
||||||
|
preliminaryTownInfluence[i] * 0.22 +
|
||||||
|
lowland * 0.46 +
|
||||||
|
valleyField[i] * 0.22 +
|
||||||
|
coastalLowland[i] * 0.22 -
|
||||||
|
slope[i] * 1.28 -
|
||||||
|
highMountain * 1.10 -
|
||||||
|
coastalTraversePenalty * 0.26 -
|
||||||
|
ridgeField[i] * 0.34
|
||||||
|
);
|
||||||
|
nationalPotential[i] = clamp(
|
||||||
|
density * 0.46 +
|
||||||
|
preliminaryTownInfluence[i] * 0.32 +
|
||||||
|
preliminaryVillageInfluence[i] * 0.20 +
|
||||||
|
agriculture[i] * 0.24 +
|
||||||
|
valleyField[i] * 0.28 +
|
||||||
|
coastalLowland[i] * 0.26 +
|
||||||
|
pass * 0.18 +
|
||||||
|
crossing * 0.18 -
|
||||||
|
slope[i] * 0.48 -
|
||||||
|
highMountain * 0.24 -
|
||||||
|
coastalTraversePenalty * 0.16 -
|
||||||
|
ridgeField[i] * 0.18
|
||||||
|
);
|
||||||
|
localPotential[i] = clamp(
|
||||||
|
preliminaryVillageInfluence[i] * 0.52 +
|
||||||
|
agriculture[i] * 0.38 +
|
||||||
|
coastalSettlement[i] * 0.30 +
|
||||||
|
valleySettlement[i] * 0.30 +
|
||||||
|
developable[i] * 0.18 -
|
||||||
|
slope[i] * 0.34 -
|
||||||
|
coastalTraversePenalty * 0.08 -
|
||||||
|
ridgeField[i] * 0.10
|
||||||
|
);
|
||||||
|
|
||||||
|
expressway[i] = Math.max(0.18,
|
||||||
|
1.62 - expresswayPotential[i] * 0.96 +
|
||||||
|
denseCorePenalty * 1.30 +
|
||||||
|
slope[i] * 5.4 +
|
||||||
|
highMountain * 5.8 +
|
||||||
|
extremeMountain * 4.2 +
|
||||||
|
boundaryRidgePenalty * 4.2 +
|
||||||
|
waterCrossingPenalty * 2.1 +
|
||||||
|
coastalTraversePenalty * 2.65 +
|
||||||
|
seaNear * 2.35 +
|
||||||
|
seaWide * 1.10 +
|
||||||
|
openPlainParallelPenalty * 0.12 +
|
||||||
|
hash2(x, y, seed + 13301) * 0.04
|
||||||
|
);
|
||||||
|
rail[i] = Math.max(0.16,
|
||||||
|
1.48 - railPotential[i] * 1.02 +
|
||||||
|
slope[i] * 7.2 +
|
||||||
|
highMountain * 7.0 +
|
||||||
|
extremeMountain * 4.8 +
|
||||||
|
boundaryRidgePenalty * 2.4 +
|
||||||
|
waterCrossingPenalty * 1.7 +
|
||||||
|
coastalTraversePenalty * 1.75 +
|
||||||
|
seaNear * 1.50 +
|
||||||
|
seaWide * 0.70 +
|
||||||
|
hash2(x, y, seed + 13302) * 0.03
|
||||||
|
);
|
||||||
|
national[i] = Math.max(0.16,
|
||||||
|
1.28 - nationalPotential[i] * 0.84 +
|
||||||
|
slope[i] * 2.8 +
|
||||||
|
ridgeField[i] * 1.18 +
|
||||||
|
Math.max(0, elevation[i] - 0.62) * 3.0 +
|
||||||
|
highMountain * 2.9 +
|
||||||
|
boundaryRidgePenalty * 1.8 +
|
||||||
|
waterCrossingPenalty * 1.25 -
|
||||||
|
valleyField[i] * 0.18 -
|
||||||
|
coastalLowland[i] * 0.08 +
|
||||||
|
coastalTraversePenalty * 1.55 +
|
||||||
|
seaNear * 0.84 +
|
||||||
|
seaWide * 0.48 -
|
||||||
|
pass * 0.42 +
|
||||||
|
hash2(x, y, seed + 13303) * 0.05
|
||||||
|
);
|
||||||
|
local[i] = Math.max(0.14,
|
||||||
|
1.12 - localPotential[i] * 0.86 +
|
||||||
|
slope[i] * 1.72 +
|
||||||
|
ridgeField[i] * 0.82 +
|
||||||
|
Math.max(0, elevation[i] - 0.68) * 1.9 +
|
||||||
|
highMountain * 1.24 +
|
||||||
|
boundaryRidgePenalty * 0.72 +
|
||||||
|
waterCrossingPenalty * 0.65 -
|
||||||
|
valleyField[i] * 0.22 -
|
||||||
|
coastalLowland[i] * 0.10 +
|
||||||
|
coastalTraversePenalty * 0.82 +
|
||||||
|
seaNear * 0.48 +
|
||||||
|
seaWide * 0.26 +
|
||||||
|
hash2(x, y, seed + 13304) * 0.07
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { expressway, rail, national, local, expresswayPotential, railPotential, nationalPotential, localPotential };
|
||||||
|
}
|
||||||
|
|
||||||
1215
mapFeatures.js
1215
mapFeatures.js
File diff suppressed because it is too large
Load diff
|
|
@ -31,6 +31,61 @@ export function distanceToNearest(points, x, y, fallback = 999) {
|
||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createPointSpatialIndex(points, cellSize = 12) {
|
||||||
|
const buckets = new Map();
|
||||||
|
const normalized = (points || [])
|
||||||
|
.filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y))
|
||||||
|
.map((p) => ({ ...p, x: Math.round(p.x), y: Math.round(p.y) }));
|
||||||
|
const keyOf = (cx, cy) => `${cx},${cy}`;
|
||||||
|
for (const p of normalized) {
|
||||||
|
const cx = Math.floor(p.x / cellSize);
|
||||||
|
const cy = Math.floor(p.y / cellSize);
|
||||||
|
const key = keyOf(cx, cy);
|
||||||
|
let bucket = buckets.get(key);
|
||||||
|
if (!bucket) {
|
||||||
|
bucket = [];
|
||||||
|
buckets.set(key, bucket);
|
||||||
|
}
|
||||||
|
bucket.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestDistanceSq(x, y, maxDistance = Math.max(MAP_W, MAP_H)) {
|
||||||
|
if (!normalized.length) return maxDistance * maxDistance;
|
||||||
|
const cx = Math.floor(x / cellSize);
|
||||||
|
const cy = Math.floor(y / cellSize);
|
||||||
|
const maxRing = Number.isFinite(maxDistance) ? Math.ceil(maxDistance / cellSize) : Math.ceil(Math.max(MAP_W, MAP_H) / cellSize);
|
||||||
|
let best = maxDistance * maxDistance;
|
||||||
|
for (let ring = 0; ring <= maxRing; ring++) {
|
||||||
|
for (let by = cy - ring; by <= cy + ring; by++) {
|
||||||
|
for (let bx = cx - ring; bx <= cx + ring; bx++) {
|
||||||
|
if (ring > 0 && bx > cx - ring && bx < cx + ring && by > cy - ring && by < cy + ring) continue;
|
||||||
|
const bucket = buckets.get(keyOf(bx, by));
|
||||||
|
if (!bucket) continue;
|
||||||
|
for (const p of bucket) {
|
||||||
|
const dx = p.x - x;
|
||||||
|
const dy = p.y - y;
|
||||||
|
const d2 = dx * dx + dy * dy;
|
||||||
|
if (d2 < best) best = d2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
points: normalized,
|
||||||
|
hasWithin(x, y, radius) {
|
||||||
|
return nearestDistanceSq(x, y, radius) < radius * radius;
|
||||||
|
},
|
||||||
|
distance(x, y, fallback = 999) {
|
||||||
|
const d2 = nearestDistanceSq(x, y, fallback);
|
||||||
|
return d2 < fallback * fallback ? Math.sqrt(d2) : fallback;
|
||||||
|
},
|
||||||
|
nearestDistanceSq,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function aStar(start, goal, costAt) {
|
export function aStar(start, goal, costAt) {
|
||||||
const startIndex = indexOf(start.x, start.y);
|
const startIndex = indexOf(start.x, start.y);
|
||||||
const goalIndex = indexOf(goal.x, goal.y);
|
const goalIndex = indexOf(goal.x, goal.y);
|
||||||
|
|
@ -83,15 +138,18 @@ export function aStar(start, goal, costAt) {
|
||||||
|
|
||||||
export function influenceFromPaths(paths, radius) {
|
export function influenceFromPaths(paths, radius) {
|
||||||
const grid = new Float32Array(SIZE);
|
const grid = new Float32Array(SIZE);
|
||||||
|
const r = Math.ceil(radius);
|
||||||
|
const r2 = radius * radius;
|
||||||
for (const path of paths) {
|
for (const path of paths) {
|
||||||
for (const [x, y] of path) {
|
for (const [x, y] of path) {
|
||||||
for (let dy = -radius; dy <= radius; dy++) {
|
for (let dy = -r; dy <= r; dy++) {
|
||||||
for (let dx = -radius; dx <= radius; dx++) {
|
for (let dx = -r; dx <= r; dx++) {
|
||||||
const nx = x + dx;
|
const nx = x + dx;
|
||||||
const ny = y + dy;
|
const ny = y + dy;
|
||||||
if (!inside(nx, ny)) continue;
|
if (!inside(nx, ny)) continue;
|
||||||
const d = Math.hypot(dx, dy);
|
const d2 = dx * dx + dy * dy;
|
||||||
if (d > radius) continue;
|
if (d2 > r2) continue;
|
||||||
|
const d = Math.sqrt(d2);
|
||||||
const i = indexOf(nx, ny);
|
const i = indexOf(nx, ny);
|
||||||
grid[i] = Math.max(grid[i], 1 / (1 + d));
|
grid[i] = Math.max(grid[i], 1 / (1 + d));
|
||||||
}
|
}
|
||||||
|
|
@ -239,15 +297,18 @@ export function averagePathField(path, field) {
|
||||||
|
|
||||||
export function influenceFromPoints(points, radius, weightFn = () => 1) {
|
export function influenceFromPoints(points, radius, weightFn = () => 1) {
|
||||||
const grid = new Float32Array(SIZE);
|
const grid = new Float32Array(SIZE);
|
||||||
|
const r = Math.ceil(radius);
|
||||||
|
const r2 = radius * radius;
|
||||||
for (const p of points) {
|
for (const p of points) {
|
||||||
const weight = weightFn(p);
|
const weight = weightFn(p);
|
||||||
for (let dy = -radius; dy <= radius; dy++) {
|
for (let dy = -r; dy <= r; dy++) {
|
||||||
for (let dx = -radius; dx <= radius; dx++) {
|
for (let dx = -r; dx <= r; dx++) {
|
||||||
const nx = p.x + dx;
|
const nx = p.x + dx;
|
||||||
const ny = p.y + dy;
|
const ny = p.y + dy;
|
||||||
if (!inside(nx, ny)) continue;
|
if (!inside(nx, ny)) continue;
|
||||||
const d = Math.hypot(dx, dy);
|
const d2 = dx * dx + dy * dy;
|
||||||
if (d > radius) continue;
|
if (d2 > r2) continue;
|
||||||
|
const d = Math.sqrt(d2);
|
||||||
const i = indexOf(nx, ny);
|
const i = indexOf(nx, ny);
|
||||||
grid[i] = Math.max(grid[i], weight / (1 + d));
|
grid[i] = Math.max(grid[i], weight / (1 + d));
|
||||||
}
|
}
|
||||||
|
|
@ -1266,6 +1327,18 @@ export function attachIdsAndNames(points, prefix, seed, kindOverride = null, nam
|
||||||
return points.map((p, i) => {
|
return points.map((p, i) => {
|
||||||
const id = `${prefix}-${i}`;
|
const id = `${prefix}-${i}`;
|
||||||
const kind = kindOverride || p.kind;
|
const kind = kindOverride || p.kind;
|
||||||
|
if (prefix === "logistics") {
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
id,
|
||||||
|
name: null,
|
||||||
|
facilityLabel: p.facilityLabel || "Logistics Park",
|
||||||
|
kind,
|
||||||
|
labelStyle: "facility",
|
||||||
|
suppressSettlementLabel: true,
|
||||||
|
insidePrefecture: Boolean(p.insidePrefecture),
|
||||||
|
};
|
||||||
|
}
|
||||||
const name = generateEntityName(seed + prefix.length * 1000, id, { ...p, kind }, nameFields, usedNames, nameDebug);
|
const name = generateEntityName(seed + prefix.length * 1000, id, { ...p, kind }, nameFields, usedNames, nameDebug);
|
||||||
if (usedNames) usedNames.add(name);
|
if (usedNames) usedNames.add(name);
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
265
mapMunicipalCoherence.js
Normal file
265
mapMunicipalCoherence.js
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
import { INF, MAP_H, MAP_W } from "./mapUtils.js";
|
||||||
|
|
||||||
|
const ADMIN_ID_KEYS = ["adminId", "municipalityId", "adminNumericId"];
|
||||||
|
|
||||||
|
function coordIndex(width, height, x, y) {
|
||||||
|
if (x < 0 || y < 0 || x >= width || y >= height) return -1;
|
||||||
|
return y * width + x;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numericAdminId(point) {
|
||||||
|
for (const key of ADMIN_ID_KEYS) {
|
||||||
|
const value = point?.[key];
|
||||||
|
if (Number.isFinite(value) && value >= 0) return Math.floor(value);
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldValue(fields, name, i) {
|
||||||
|
return fields?.[name]?.[i] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bestCellScore(fields, i) {
|
||||||
|
return fieldValue(fields, "populationDensity", i) * 3.0
|
||||||
|
+ fieldValue(fields, "plain", i) * 0.32
|
||||||
|
+ fieldValue(fields, "agriculture", i) * 0.16
|
||||||
|
- fieldValue(fields, "slope", i) * 0.30
|
||||||
|
- fieldValue(fields, "ridgeField", i) * 0.18;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMunicipalStats({ adminId, prefectureRegionId, sea, fields = {}, width = MAP_W, height = MAP_H }) {
|
||||||
|
const stats = new Map();
|
||||||
|
for (let i = 0; i < adminId.length; i++) {
|
||||||
|
const id = adminId[i];
|
||||||
|
if (!Number.isFinite(id) || id < 0 || sea?.[i]) continue;
|
||||||
|
const row = stats.get(id) || { id, area: 0, sx: 0, sy: 0, bestI: -1, bestScore: -INF, prefVotes: new Map() };
|
||||||
|
const x = i % width;
|
||||||
|
const y = Math.floor(i / width);
|
||||||
|
row.area++;
|
||||||
|
row.sx += x;
|
||||||
|
row.sy += y;
|
||||||
|
const pref = prefectureRegionId?.[i] ?? -1;
|
||||||
|
if (pref >= 0) row.prefVotes.set(pref, (row.prefVotes.get(pref) || 0) + 1);
|
||||||
|
const score = bestCellScore(fields, i);
|
||||||
|
if (score > row.bestScore) {
|
||||||
|
row.bestScore = score;
|
||||||
|
row.bestI = i;
|
||||||
|
}
|
||||||
|
stats.set(id, row);
|
||||||
|
}
|
||||||
|
for (const row of stats.values()) {
|
||||||
|
let bestPref = -1;
|
||||||
|
let bestVotes = -1;
|
||||||
|
for (const [pref, count] of row.prefVotes) {
|
||||||
|
if (count > bestVotes || (count === bestVotes && pref < bestPref)) {
|
||||||
|
bestPref = pref;
|
||||||
|
bestVotes = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.prefectureRegionId = bestPref;
|
||||||
|
row.x = row.bestI >= 0 ? row.bestI % width : Math.round(row.sx / Math.max(1, row.area));
|
||||||
|
row.y = row.bestI >= 0 ? Math.floor(row.bestI / width) : Math.round(row.sy / Math.max(1, row.area));
|
||||||
|
}
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointFieldCoord(point, pointOffsetX, pointOffsetY) {
|
||||||
|
return {
|
||||||
|
x: Math.round((point?.x || 0) + pointOffsetX),
|
||||||
|
y: Math.round((point?.y || 0) + pointOffsetY),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function centerQuality(center, id, stat, context) {
|
||||||
|
if (!center) return -INF;
|
||||||
|
const { adminId, sea, width, height, pointOffsetX, pointOffsetY } = context;
|
||||||
|
const p = pointFieldCoord(center, pointOffsetX, pointOffsetY);
|
||||||
|
const i = coordIndex(width, height, p.x, p.y);
|
||||||
|
const ownsCell = i >= 0 && !sea?.[i] && adminId?.[i] === id;
|
||||||
|
return (ownsCell ? 100000 : 0)
|
||||||
|
+ (center.name ? 5000 : 0)
|
||||||
|
+ (center.representativeFeatureName || center.canonicalSettlementName ? 1200 : 0)
|
||||||
|
+ (center.generatedOfficePoint ? -200 : 0)
|
||||||
|
- Math.hypot(p.x - stat.x, p.y - stat.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCenter(center, id, stat, context, generated = false) {
|
||||||
|
const { pointOffsetX, pointOffsetY, fields = {}, seed = 0 } = context;
|
||||||
|
const out = {
|
||||||
|
...(center || {}),
|
||||||
|
x: stat.x - pointOffsetX,
|
||||||
|
y: stat.y - pointOffsetY,
|
||||||
|
adminId: id,
|
||||||
|
adminNumericId: id,
|
||||||
|
municipalityId: id,
|
||||||
|
prefectureRegionId: stat.prefectureRegionId,
|
||||||
|
municipalArea: stat.area,
|
||||||
|
insidePrefecture: true,
|
||||||
|
};
|
||||||
|
if (generated) {
|
||||||
|
out.generatedOfficePoint = true;
|
||||||
|
out.seedKind ||= "coherenceFallbackMunicipalityOffice";
|
||||||
|
out.kind ||= "Municipal Center";
|
||||||
|
out.generatedMunicipalityName ||= `自治${id + 1}`;
|
||||||
|
out.name ||= out.generatedMunicipalityName;
|
||||||
|
out.labelName ||= out.generatedMunicipalityName;
|
||||||
|
out.municipalityName ||= out.generatedMunicipalityName;
|
||||||
|
}
|
||||||
|
const i = coordIndex(context.width, context.height, stat.x, stat.y);
|
||||||
|
if (i >= 0) {
|
||||||
|
out.officePopulationDensity = fields.populationDensity?.[i] || 0;
|
||||||
|
out.officeLanduse = fields.landuse?.[i] ?? out.officeLanduse;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reconcileMunicipalMetadata({
|
||||||
|
adminId,
|
||||||
|
municipalityId = null,
|
||||||
|
prefectureRegionId = null,
|
||||||
|
sea = null,
|
||||||
|
adminCenters = [],
|
||||||
|
municipalityToPrefectureId = null,
|
||||||
|
fields = {},
|
||||||
|
width = MAP_W,
|
||||||
|
height = MAP_H,
|
||||||
|
pointOffsetX = 0,
|
||||||
|
pointOffsetY = 0,
|
||||||
|
seed = 0,
|
||||||
|
} = {}) {
|
||||||
|
if (!adminId) return { adminCenters: adminCenters || [], municipalityToPrefectureId, stats: new Map(), debug: { activeMunicipalities: 0 } };
|
||||||
|
const stats = buildMunicipalStats({ adminId, prefectureRegionId, sea, fields, width, height });
|
||||||
|
if (municipalityId) {
|
||||||
|
for (let i = 0; i < adminId.length; i++) municipalityId[i] = sea?.[i] ? -1 : (adminId[i] >= 0 ? adminId[i] : -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = new Map();
|
||||||
|
let ghostCentersRemoved = 0;
|
||||||
|
let centersMovedToOwnedCells = 0;
|
||||||
|
for (const [index, center] of (adminCenters || []).entries()) {
|
||||||
|
if (!center) continue;
|
||||||
|
const explicitId = numericAdminId(center);
|
||||||
|
const id = explicitId >= 0 ? explicitId : (stats.has(index) ? index : -1);
|
||||||
|
const stat = stats.get(id);
|
||||||
|
if (!stat) {
|
||||||
|
ghostCentersRemoved++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const current = byId.get(id);
|
||||||
|
if (!current || centerQuality(center, id, stat, { adminId, sea, width, height, pointOffsetX, pointOffsetY }) > centerQuality(current, id, stat, { adminId, sea, width, height, pointOffsetX, pointOffsetY })) {
|
||||||
|
byId.set(id, center);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fallbackCentersAdded = 0;
|
||||||
|
const nextCenters = [];
|
||||||
|
for (const [id, stat] of [...stats.entries()].sort((a, b) => a[0] - b[0])) {
|
||||||
|
const existing = byId.get(id);
|
||||||
|
if (!existing) fallbackCentersAdded++;
|
||||||
|
else {
|
||||||
|
const p = pointFieldCoord(existing, pointOffsetX, pointOffsetY);
|
||||||
|
const pi = coordIndex(width, height, p.x, p.y);
|
||||||
|
if (pi < 0 || sea?.[pi] || adminId[pi] !== id) centersMovedToOwnedCells++;
|
||||||
|
}
|
||||||
|
nextCenters.push(normalizeCenter(existing, id, stat, { pointOffsetX, pointOffsetY, fields, width, height, seed }, !existing));
|
||||||
|
}
|
||||||
|
|
||||||
|
let maxId = Math.max(-1, ...stats.keys());
|
||||||
|
if (municipalityToPrefectureId?.length) maxId = Math.max(maxId, municipalityToPrefectureId.length - 1);
|
||||||
|
const nextMapping = new Int32Array(Math.max(0, maxId + 1));
|
||||||
|
nextMapping.fill(-1);
|
||||||
|
if (municipalityToPrefectureId) {
|
||||||
|
for (let i = 0; i < municipalityToPrefectureId.length && i < nextMapping.length; i++) nextMapping[i] = municipalityToPrefectureId[i] ?? -1;
|
||||||
|
}
|
||||||
|
for (const [id, stat] of stats) if (stat.prefectureRegionId >= 0) nextMapping[id] = stat.prefectureRegionId;
|
||||||
|
|
||||||
|
return {
|
||||||
|
adminCenters: nextCenters,
|
||||||
|
municipalityToPrefectureId: nextMapping,
|
||||||
|
stats,
|
||||||
|
debug: {
|
||||||
|
activeMunicipalities: stats.size,
|
||||||
|
ghostCentersRemoved,
|
||||||
|
fallbackCentersAdded,
|
||||||
|
centersMovedToOwnedCells,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshPrefectureRegionsMetadata({
|
||||||
|
prefectureRegionId,
|
||||||
|
sea,
|
||||||
|
existing = [],
|
||||||
|
fields = {},
|
||||||
|
width = MAP_W,
|
||||||
|
height = MAP_H,
|
||||||
|
pointOffsetX = 0,
|
||||||
|
pointOffsetY = 0,
|
||||||
|
} = {}) {
|
||||||
|
if (!prefectureRegionId) return { prefectureRegions: existing || [], debug: { activePrefectureRegions: 0 } };
|
||||||
|
const byId = new Map();
|
||||||
|
for (let i = 0; i < prefectureRegionId.length; i++) {
|
||||||
|
const id = prefectureRegionId[i];
|
||||||
|
if (!Number.isFinite(id) || id < 0 || sea?.[i]) continue;
|
||||||
|
const row = byId.get(id) || { id, area: 0, sx: 0, sy: 0, bestI: -1, bestScore: -INF };
|
||||||
|
const x = i % width;
|
||||||
|
const y = Math.floor(i / width);
|
||||||
|
row.area++;
|
||||||
|
row.sx += x;
|
||||||
|
row.sy += y;
|
||||||
|
const score = bestCellScore(fields, i) - Math.hypot(x - row.sx / Math.max(1, row.area), y - row.sy / Math.max(1, row.area)) * 0.02;
|
||||||
|
if (score > row.bestScore) {
|
||||||
|
row.bestScore = score;
|
||||||
|
row.bestI = i;
|
||||||
|
}
|
||||||
|
byId.set(id, row);
|
||||||
|
}
|
||||||
|
const existingById = new Map();
|
||||||
|
for (const region of existing || []) {
|
||||||
|
const id = Number.isFinite(region?.prefectureRegionId) ? Math.floor(region.prefectureRegionId) : Number.isFinite(region?.id) ? Math.floor(region.id) : -1;
|
||||||
|
if (id >= 0 && !existingById.has(id)) existingById.set(id, region);
|
||||||
|
}
|
||||||
|
let fallbackRegionsAdded = 0;
|
||||||
|
const prefectureRegions = [];
|
||||||
|
for (const [id, row] of [...byId.entries()].sort((a, b) => a[0] - b[0])) {
|
||||||
|
const base = existingById.get(id);
|
||||||
|
if (!base) fallbackRegionsAdded++;
|
||||||
|
const x = row.bestI >= 0 ? row.bestI % width : Math.round(row.sx / Math.max(1, row.area));
|
||||||
|
const y = row.bestI >= 0 ? Math.floor(row.bestI / width) : Math.round(row.sy / Math.max(1, row.area));
|
||||||
|
prefectureRegions.push({
|
||||||
|
...(base || {}),
|
||||||
|
id,
|
||||||
|
prefectureRegionId: id,
|
||||||
|
featureId: id,
|
||||||
|
x: x - pointOffsetX,
|
||||||
|
y: y - pointOffsetY,
|
||||||
|
area: row.area,
|
||||||
|
kind: base?.kind || (id === 0 ? "Current Prefecture" : "Prefecture"),
|
||||||
|
name: base?.name || `県域${id + 1}`,
|
||||||
|
labelName: base?.labelName || base?.name || `県域${id + 1}`,
|
||||||
|
forceLabel: true,
|
||||||
|
labelPriorityBase: base?.labelPriorityBase || 950 + Math.sqrt(row.area),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
prefectureRegions,
|
||||||
|
debug: {
|
||||||
|
activePrefectureRegions: byId.size,
|
||||||
|
fallbackRegionsAdded,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function municipalCoherenceForMap(map) {
|
||||||
|
return reconcileMunicipalMetadata({
|
||||||
|
adminId: map?.adminId,
|
||||||
|
municipalityId: map?.municipalityId,
|
||||||
|
prefectureRegionId: map?.prefectureRegionId,
|
||||||
|
sea: map?.sea,
|
||||||
|
adminCenters: map?.adminCenters,
|
||||||
|
municipalityToPrefectureId: map?.municipalityToPrefectureId,
|
||||||
|
fields: map,
|
||||||
|
width: map?.width || MAP_W,
|
||||||
|
height: map?.height || MAP_H,
|
||||||
|
});
|
||||||
|
}
|
||||||
481
mapOutput.js
481
mapOutput.js
|
|
@ -1,6 +1,7 @@
|
||||||
import { createNameDebug } from "./names.js";
|
import { createNameDebug } from "./names.js";
|
||||||
import { CELL_SIZE, INF, MAP_H, MAP_W, MinHeap, clamp, indexOf, inside, rand, xyOf } from "./mapUtils.js";
|
import { CELL_SIZE, INF, MAP_H, MAP_W, MinHeap, clamp, indexOf, inside, rand, xyOf } from "./mapUtils.js";
|
||||||
import { applyOutputOptions, attachIdsAndNames, influenceFromPaths, tagInsidePrefecture } from "./mapGeneratorHelpers.js";
|
import { applyOutputOptions, attachIdsAndNames, influenceFromPaths, tagInsidePrefecture } from "./mapGeneratorHelpers.js";
|
||||||
|
import { reconcileMunicipalMetadata } from "./mapMunicipalCoherence.js";
|
||||||
import { routeQualityAcceptable } from "./mapTransport.js";
|
import { routeQualityAcceptable } from "./mapTransport.js";
|
||||||
|
|
||||||
const MUNICIPAL_SUFFIX_RE = /[市町村区]$/u;
|
const MUNICIPAL_SUFFIX_RE = /[市町村区]$/u;
|
||||||
|
|
@ -30,15 +31,33 @@ function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function centerMunicipalityId(center, fallback = -1) {
|
||||||
|
for (const key of ["adminId", "municipalityId", "adminNumericId"]) {
|
||||||
|
const value = center?.[key];
|
||||||
|
if (Number.isFinite(value) && value >= 0) return Math.floor(value);
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function assignMunicipalityPopulations(adminCenters, adminId, fields, settlementFeatures = []) {
|
function assignMunicipalityPopulations(adminCenters, adminId, fields, settlementFeatures = []) {
|
||||||
if (!adminCenters?.length || !adminId) return;
|
if (!adminCenters?.length || !adminId) return;
|
||||||
const totals = new Float64Array(adminCenters.length);
|
const centerById = new Map();
|
||||||
const settlementTotals = new Float64Array(adminCenters.length);
|
let maxId = -1;
|
||||||
const landCells = new Uint32Array(adminCenters.length);
|
for (const center of adminCenters) {
|
||||||
const inhabitedCells = new Uint32Array(adminCenters.length);
|
const id = centerMunicipalityId(center);
|
||||||
|
if (id >= 0 && !centerById.has(id)) {
|
||||||
|
centerById.set(id, center);
|
||||||
|
maxId = Math.max(maxId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 0; i < adminId.length; i++) if (adminId[i] >= 0) maxId = Math.max(maxId, adminId[i]);
|
||||||
|
const totals = new Float64Array(maxId + 1);
|
||||||
|
const settlementTotals = new Float64Array(maxId + 1);
|
||||||
|
const landCells = new Uint32Array(maxId + 1);
|
||||||
|
const inhabitedCells = new Uint32Array(maxId + 1);
|
||||||
for (let i = 0; i < adminId.length; i++) {
|
for (let i = 0; i < adminId.length; i++) {
|
||||||
const id = adminId[i];
|
const id = adminId[i];
|
||||||
if (id < 0 || id >= totals.length || fields.sea?.[i]) continue;
|
if (id < 0 || fields.sea?.[i]) continue;
|
||||||
landCells[id]++;
|
landCells[id]++;
|
||||||
const density = fields.populationDensity?.[i] || 0;
|
const density = fields.populationDensity?.[i] || 0;
|
||||||
const lu = fields.landuse?.[i] ?? 0;
|
const lu = fields.landuse?.[i] ?? 0;
|
||||||
|
|
@ -68,7 +87,7 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields, settlement
|
||||||
let skippedDuplicateSettlementPopulation = 0;
|
let skippedDuplicateSettlementPopulation = 0;
|
||||||
for (const { feature, i } of uniqueSettlementByCell.values()) {
|
for (const { feature, i } of uniqueSettlementByCell.values()) {
|
||||||
const id = adminId[i];
|
const id = adminId[i];
|
||||||
if (id < 0 || id >= totals.length) continue;
|
if (id < 0 || id >= settlementTotals.length) continue;
|
||||||
settlementTotals[id] += feature.population;
|
settlementTotals[id] += feature.population;
|
||||||
}
|
}
|
||||||
for (const feature of settlementFeatures || []) {
|
for (const feature of settlementFeatures || []) {
|
||||||
|
|
@ -77,7 +96,7 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields, settlement
|
||||||
const kept = uniqueSettlementByCell.get(key)?.feature;
|
const kept = uniqueSettlementByCell.get(key)?.feature;
|
||||||
if (kept && kept !== feature) skippedDuplicateSettlementPopulation += feature.population || 0;
|
if (kept && kept !== feature) skippedDuplicateSettlementPopulation += feature.population || 0;
|
||||||
}
|
}
|
||||||
for (let id = 0; id < adminCenters.length; id++) {
|
for (const [id, center] of centerById) {
|
||||||
const raw = (totals[id] || 0) + (settlementTotals[id] || 0);
|
const raw = (totals[id] || 0) + (settlementTotals[id] || 0);
|
||||||
const minimumResidentPopulation = landCells[id] > 0
|
const minimumResidentPopulation = landCells[id] > 0
|
||||||
? Math.round(Math.max(100, Math.min(3800, 80 + landCells[id] * 9 + inhabitedCells[id] * 16)) / 100) * 100
|
? Math.round(Math.max(100, Math.min(3800, 80 + landCells[id] * 9 + inhabitedCells[id] * 16)) / 100) * 100
|
||||||
|
|
@ -85,18 +104,18 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields, settlement
|
||||||
const adjustedRaw = Math.max(raw, minimumResidentPopulation);
|
const adjustedRaw = Math.max(raw, minimumResidentPopulation);
|
||||||
const rounded = adjustedRaw >= 10000 ? Math.round(adjustedRaw / 1000) * 1000 : Math.max(minimumResidentPopulation, Math.round(adjustedRaw / 100) * 100);
|
const rounded = adjustedRaw >= 10000 ? Math.round(adjustedRaw / 1000) * 1000 : Math.max(minimumResidentPopulation, Math.round(adjustedRaw / 100) * 100);
|
||||||
const safePopulation = Math.max(landCells[id] > 0 ? 100 : 0, rounded);
|
const safePopulation = Math.max(landCells[id] > 0 ? 100 : 0, rounded);
|
||||||
adminCenters[id].municipalityPopulation = safePopulation;
|
center.municipalityPopulation = safePopulation;
|
||||||
// Some consumers still read the generic `population` field from municipal
|
// Some consumers still read the generic `population` field from municipal
|
||||||
// centers. Mirror the municipality total there so no municipality is shown
|
// centers. Mirror the municipality total there so no municipality is shown
|
||||||
// as 0人 merely because it is not a canonical city/market entity.
|
// as 0人 merely because it is not a canonical city/market entity.
|
||||||
adminCenters[id].population = Math.max(adminCenters[id].population || 0, safePopulation);
|
center.population = Math.max(center.population || 0, safePopulation);
|
||||||
adminCenters[id].municipalitySettlementPopulation = Math.max(0, Math.round((settlementTotals[id] || 0) / 100) * 100);
|
center.municipalitySettlementPopulation = Math.max(0, Math.round((settlementTotals[id] || 0) / 100) * 100);
|
||||||
adminCenters[id].municipalitySkippedDuplicateSettlementPopulation = Math.max(0, Math.round(skippedDuplicateSettlementPopulation / 100) * 100);
|
center.municipalitySkippedDuplicateSettlementPopulation = Math.max(0, Math.round(skippedDuplicateSettlementPopulation / 100) * 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, fields, seed, minPopulation = 200000) {
|
function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, fields, seed, minPopulation = 200000, focusedPrefectureMask = null) {
|
||||||
if (!prefectureRegionId) return 0;
|
if (!prefectureRegionId) return 0;
|
||||||
const prefIds = new Set();
|
const prefIds = new Set();
|
||||||
for (let i = 0; i < prefectureRegionId.length; i++) {
|
for (let i = 0; i < prefectureRegionId.length; i++) {
|
||||||
|
|
@ -135,6 +154,24 @@ function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCiti
|
||||||
if (!p || !inside(p.x, p.y)) return -1;
|
if (!p || !inside(p.x, p.y)) return -1;
|
||||||
return prefectureRegionId[indexOf(p.x, p.y)] ?? -1;
|
return prefectureRegionId[indexOf(p.x, p.y)] ?? -1;
|
||||||
}
|
}
|
||||||
|
const focusedPrefCounts = new Map();
|
||||||
|
if (focusedPrefectureMask) {
|
||||||
|
for (let i = 0; i < focusedPrefectureMask.length; i++) {
|
||||||
|
if (!focusedPrefectureMask[i] || sea[i]) continue;
|
||||||
|
const prefId = prefectureRegionId[i] ?? -1;
|
||||||
|
if (prefId >= 0) focusedPrefCounts.set(prefId, (focusedPrefCounts.get(prefId) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const focusedPrefId = focusedPrefCounts.size
|
||||||
|
? [...focusedPrefCounts.entries()].sort((a, b) => b[1] - a[1])[0][0]
|
||||||
|
: 0;
|
||||||
|
for (const city of modernCities || []) {
|
||||||
|
if (city.isPrefecturalCapital) {
|
||||||
|
city.isPrefecturalCapital = false;
|
||||||
|
city.rank = city.isRegionalCapital ? "Regional Capital" : city.population >= 200000 ? "Regional City" : "Local City";
|
||||||
|
city.kind = city.rank;
|
||||||
|
}
|
||||||
|
}
|
||||||
for (const prefId of [...prefIds].sort((a, b) => a - b)) {
|
for (const prefId of [...prefIds].sort((a, b) => a - b)) {
|
||||||
const cities = (modernCities || []).filter((p) => prefAt(p) === prefId);
|
const cities = (modernCities || []).filter((p) => prefAt(p) === prefId);
|
||||||
let target = cities.slice().sort((a, b) =>
|
let target = cities.slice().sort((a, b) =>
|
||||||
|
|
@ -175,10 +212,11 @@ function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCiti
|
||||||
target.population = promotedPopulation;
|
target.population = promotedPopulation;
|
||||||
promoted++;
|
promoted++;
|
||||||
}
|
}
|
||||||
target.isPrefecturalCapital = true;
|
|
||||||
target.isRegionalCapital = true;
|
target.isRegionalCapital = true;
|
||||||
target.rank = target.rank || "Regional Capital";
|
target.isPrefecturalCapital = prefId === focusedPrefId;
|
||||||
target.kind = target.kind === "Market Town" || target.kind === "Port Town" || target.kind === "Valley Market Town" ? "Regional Capital" : (target.kind || "Regional Capital");
|
target.rank = target.isPrefecturalCapital ? "Prefectural Capital" : "Regional Capital";
|
||||||
|
target.kind = target.rank;
|
||||||
|
target.labelPriorityBase = Math.max(target.labelPriorityBase || 0, 1150);
|
||||||
target.urbanRadius = clamp(6.2 + Math.sqrt(target.population) / 80, 9, 30);
|
target.urbanRadius = clamp(6.2 + Math.sqrt(target.population) / 80, 9, 30);
|
||||||
target.coreRadius = clamp(2.0 + Math.sqrt(target.population) / 390, 2.4, 6.4);
|
target.coreRadius = clamp(2.0 + Math.sqrt(target.population) / 390, 2.4, 6.4);
|
||||||
target.sprawlRadius = clamp(target.urbanRadius * 1.34, target.urbanRadius + 2, 38);
|
target.sprawlRadius = clamp(target.urbanRadius * 1.34, target.urbanRadius + 2, 38);
|
||||||
|
|
@ -210,8 +248,9 @@ function buildPrefectureRegions(prefectureRegionId, sea, fields, seed, usedNames
|
||||||
const prefId = prefectureRegionId[indexOf(city.x, city.y)];
|
const prefId = prefectureRegionId[indexOf(city.x, city.y)];
|
||||||
if (prefId < 0) continue;
|
if (prefId < 0) continue;
|
||||||
const current = capitalNameByPref.get(prefId);
|
const current = capitalNameByPref.get(prefId);
|
||||||
const score = (city.isPrefecturalCapital ? 2_000_000 : 0) + (city.isRegionalCapital ? 500_000 : 0) + (city.population || 0);
|
const tier = city.isPrefecturalCapital || city.rank === "Prefectural Capital" || city.kind === "Prefectural Capital" ? 3 : city.isRegionalCapital || city.rank === "Regional Capital" || city.kind === "Regional Capital" ? 2 : 1;
|
||||||
if (!current || score > current.score) capitalNameByPref.set(prefId, { name: stripMunicipalSuffix(city.name), score, x: city.x, y: city.y, population: city.population || 0, source: "city" });
|
const score = tier * 50_000_000 + (city.population || 0);
|
||||||
|
if (!current || score > current.score) capitalNameByPref.set(prefId, { name: stripMunicipalSuffix(city.name), score, x: city.x, y: city.y, population: city.population || 0, source: tier === 3 ? "prefecture-capital" : "city" });
|
||||||
}
|
}
|
||||||
for (const center of adminCenters || []) {
|
for (const center of adminCenters || []) {
|
||||||
if (!center || !inside(center.x, center.y) || !center.name) continue;
|
if (!center || !inside(center.x, center.y) || !center.name) continue;
|
||||||
|
|
@ -265,7 +304,7 @@ function buildPrefectureRegions(prefectureRegionId, sea, fields, seed, usedNames
|
||||||
x = labelI % MAP_W;
|
x = labelI % MAP_W;
|
||||||
y = Math.floor(labelI / MAP_W);
|
y = Math.floor(labelI / MAP_W);
|
||||||
}
|
}
|
||||||
regions.push({ id: row.id, x, y, area: row.area, kind: row.id === 0 ? "Current Prefecture" : "Prefecture", labelPriorityBase: 900 + Math.sqrt(row.area), capitalX: capitalInfo?.x, capitalY: capitalInfo?.y });
|
regions.push({ id: row.id, x, y, area: row.area, kind: row.id === 0 ? "Current Prefecture" : "Prefecture", labelPriorityBase: 950 + Math.sqrt(row.area), forceLabel: true, capitalX: capitalInfo?.x, capitalY: capitalInfo?.y });
|
||||||
}
|
}
|
||||||
const named = attachIdsAndNames(regions, "prefecture", seed + 41000, null, fields, usedNames, nameDebug)
|
const named = attachIdsAndNames(regions, "prefecture", seed + 41000, null, fields, usedNames, nameDebug)
|
||||||
.map((region, index) => ({ ...region, featureId: region.id, id: regions[index].id, labelName: region.name }));
|
.map((region, index) => ({ ...region, featureId: region.id, id: regions[index].id, labelName: region.name }));
|
||||||
|
|
@ -447,7 +486,20 @@ export function finishMapOutput({
|
||||||
castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", 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);
|
externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug);
|
||||||
outputProgress("municipality naming");
|
outputProgress("municipality naming");
|
||||||
const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, humanRegionMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug);
|
const municipalCoherence = reconcileMunicipalMetadata({
|
||||||
|
adminId,
|
||||||
|
prefectureRegionId,
|
||||||
|
sea,
|
||||||
|
adminCenters: adminCentersRaw,
|
||||||
|
municipalityToPrefectureId,
|
||||||
|
fields: nameFields,
|
||||||
|
width: MAP_W,
|
||||||
|
height: MAP_H,
|
||||||
|
seed,
|
||||||
|
});
|
||||||
|
if (adminDebug) adminDebug.municipalCoherence = municipalCoherence.debug;
|
||||||
|
const coherentMunicipalityToPrefectureId = municipalCoherence.municipalityToPrefectureId || municipalityToPrefectureId;
|
||||||
|
const adminCenters = attachIdsAndNames(tagInsidePrefecture(municipalCoherence.adminCenters, humanRegionMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug);
|
||||||
const representativeFeatures = [
|
const representativeFeatures = [
|
||||||
...modernCities.map((p) => ({ ...p, representativeWeight: 5.0 + (p.population || 0) / 180000 })),
|
...modernCities.map((p) => ({ ...p, representativeWeight: 5.0 + (p.population || 0) / 180000 })),
|
||||||
...markets.map((p) => ({ ...p, representativeWeight: 3.2 })),
|
...markets.map((p) => ({ ...p, representativeWeight: 3.2 })),
|
||||||
|
|
@ -478,31 +530,44 @@ export function finishMapOutput({
|
||||||
center.canonicalSettlementId = best.id;
|
center.canonicalSettlementId = best.id;
|
||||||
center.canonicalSettlementName = best.name;
|
center.canonicalSettlementName = best.name;
|
||||||
center.municipalityRootName = best.name;
|
center.municipalityRootName = best.name;
|
||||||
|
const bestAdmin = adminId?.[indexOf(best.x, best.y)];
|
||||||
|
const canSnapOffice = inside(best.x, best.y) && !sea[indexOf(best.x, best.y)] && (
|
||||||
|
centerAdmin == null || centerAdmin < 0 || bestAdmin == null || bestAdmin < 0 || bestAdmin === centerAdmin
|
||||||
|
);
|
||||||
|
if (canSnapOffice) {
|
||||||
|
center.generatedOfficeX = center.generatedOfficeX ?? center.x;
|
||||||
|
center.generatedOfficeY = center.generatedOfficeY ?? center.y;
|
||||||
|
center.x = best.x;
|
||||||
|
center.y = best.y;
|
||||||
|
center.officeSnappedToSettlement = true;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
center.municipalityRootName = center.generatedMunicipalityName;
|
center.municipalityRootName = center.generatedMunicipalityName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const usedAdminNames = new Set();
|
const usedAdminNames = new Set();
|
||||||
for (const [index, center] of adminCenters.entries()) {
|
for (const [index, center] of adminCenters.entries()) {
|
||||||
center.adminNumericId = index;
|
const municipalId = centerMunicipalityId(center, index);
|
||||||
center.municipalityId = index;
|
center.adminId = municipalId;
|
||||||
let candidate = municipalityNameFromRoot(center.municipalityRootName || center.canonicalSettlementName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index);
|
center.adminNumericId = municipalId;
|
||||||
const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, index);
|
center.municipalityId = municipalId;
|
||||||
|
let candidate = municipalityNameFromRoot(center.municipalityRootName || center.canonicalSettlementName || center.generatedMunicipalityName || center.name, center, nameFields, seed, municipalId);
|
||||||
|
const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, municipalId);
|
||||||
if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) {
|
if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) {
|
||||||
candidate = generated;
|
candidate = generated;
|
||||||
}
|
}
|
||||||
if (usedAdminNames.has(candidate)) {
|
if (usedAdminNames.has(candidate)) {
|
||||||
const suffix = municipalitySuffixForCenter(center, nameFields, seed + 313, index);
|
const suffix = municipalitySuffixForCenter(center, nameFields, seed + 313, municipalId);
|
||||||
const rootSource = String(center.generatedMunicipalityName || center.name || center.municipalityRootName || `自治${index + 1}`).replace(/[市町村区駅港城跡宿]$/gu, "");
|
const rootSource = String(center.generatedMunicipalityName || center.name || center.municipalityRootName || `自治${municipalId + 1}`).replace(/[市町村区駅港城跡宿]$/gu, "");
|
||||||
const chars = Array.from(rootSource || "里郷");
|
const chars = Array.from(rootSource || "里郷");
|
||||||
const alternates = [
|
const alternates = [
|
||||||
chars.slice(0, 2).join(""),
|
chars.slice(0, 2).join(""),
|
||||||
chars.slice(-2).join(""),
|
chars.slice(-2).join(""),
|
||||||
`${chars[0] || "里"}${["里", "郷", "野", "田", "川", "原", "浜", "浦"][(seed + index) % 8]}`,
|
`${chars[0] || "里"}${["里", "郷", "野", "田", "川", "原", "浜", "浦"][(seed + municipalId) % 8]}`,
|
||||||
`${["東", "西", "南", "北", "上", "下", "中"][(seed + index) % 7]}${chars[0] || "里"}`,
|
`${["東", "西", "南", "北", "上", "下", "中"][(seed + municipalId) % 7]}${chars[0] || "里"}`,
|
||||||
].filter((v) => Array.from(v).length >= 2);
|
].filter((v) => Array.from(v).length >= 2);
|
||||||
for (let attempt = 0; attempt < alternates.length + 12 && usedAdminNames.has(candidate); attempt++) {
|
for (let attempt = 0; attempt < alternates.length + 12 && usedAdminNames.has(candidate); attempt++) {
|
||||||
const root = attempt < alternates.length ? alternates[attempt] : `第${(index + attempt) % 10}`;
|
const root = attempt < alternates.length ? alternates[attempt] : `第${(municipalId + attempt) % 10}`;
|
||||||
candidate = `${root}${suffix}`;
|
candidate = `${root}${suffix}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -511,7 +576,7 @@ export function finishMapOutput({
|
||||||
center.municipalityName = candidate;
|
center.municipalityName = candidate;
|
||||||
usedAdminNames.add(center.name);
|
usedAdminNames.add(center.name);
|
||||||
}
|
}
|
||||||
const promotedPrefectureCapitals = promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, nameFields, seed, 220000);
|
const promotedPrefectureCapitals = promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, nameFields, seed, 220000, prefectureMask);
|
||||||
assignMunicipalityPopulations(adminCenters, adminId, nameFields, [
|
assignMunicipalityPopulations(adminCenters, adminId, nameFields, [
|
||||||
...modernCities,
|
...modernCities,
|
||||||
...markets,
|
...markets,
|
||||||
|
|
@ -553,7 +618,7 @@ export function finishMapOutput({
|
||||||
return bs - as;
|
return bs - as;
|
||||||
})
|
})
|
||||||
.filter((p) => {
|
.filter((p) => {
|
||||||
const prefId = municipalityToPrefectureId?.[p.municipalityId] ?? -1;
|
const prefId = coherentMunicipalityToPrefectureId?.[p.municipalityId] ?? -1;
|
||||||
const used = perPrefectureQuota.get(prefId) || 0;
|
const used = perPrefectureQuota.get(prefId) || 0;
|
||||||
if (used >= 18) return false;
|
if (used >= 18) return false;
|
||||||
perPrefectureQuota.set(prefId, used + 1);
|
perPrefectureQuota.set(prefId, used + 1);
|
||||||
|
|
@ -720,6 +785,24 @@ export function finishMapOutput({
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pathNearRequiredExpresswayCity(path) {
|
||||||
|
if (!path || path.length < 2) return false;
|
||||||
|
for (const city of modernCities || []) {
|
||||||
|
if (!city || (city.population || 0) < 100000 || !inside(city.x, city.y)) continue;
|
||||||
|
const inner = Math.max(7, (city.coreRadius || 4) + 4.5);
|
||||||
|
const outer = Math.max(inner + 7, (city.urbanRadius || 12) * 2.15);
|
||||||
|
let inBand = false;
|
||||||
|
let exits = false;
|
||||||
|
for (const [x, y] of path) {
|
||||||
|
const d = Math.hypot(city.x - x, city.y - y);
|
||||||
|
if (d >= inner && d <= outer) inBand = true;
|
||||||
|
if (d >= Math.max(22, (city.urbanRadius || 12) * 1.45)) exits = true;
|
||||||
|
if (inBand && exits) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function components() {
|
function components() {
|
||||||
const occ = new Uint8Array(MAP_W * MAP_H);
|
const occ = new Uint8Array(MAP_W * MAP_H);
|
||||||
for (const [, paths] of groups) for (const path of paths || []) rasterize(path, (x, y) => {
|
for (const [, paths] of groups) for (const path of paths || []) rasterize(path, (x, y) => {
|
||||||
|
|
@ -751,8 +834,165 @@ export function finishMapOutput({
|
||||||
}
|
}
|
||||||
return out.sort((a, b) => b.size - a.size);
|
return out.sort((a, b) => b.size - a.size);
|
||||||
}
|
}
|
||||||
|
function buildLandComponentIds() {
|
||||||
|
const ids = new Int32Array(MAP_W * MAP_H);
|
||||||
|
ids.fill(-1);
|
||||||
|
let id = 0;
|
||||||
|
const q = [];
|
||||||
|
for (let i = 0; i < ids.length; i++) {
|
||||||
|
if (ids[i] >= 0 || sea[i]) continue;
|
||||||
|
ids[i] = id;
|
||||||
|
q.length = 0;
|
||||||
|
q.push(i);
|
||||||
|
for (let h = 0; h < q.length; h++) {
|
||||||
|
const cur = q[h];
|
||||||
|
const [x, y] = xyOf(cur);
|
||||||
|
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
if (!dx && !dy) continue;
|
||||||
|
const nx = x + dx, ny = y + dy;
|
||||||
|
if (!inside(nx, ny)) continue;
|
||||||
|
const ni = indexOf(nx, ny);
|
||||||
|
if (sea[ni] || ids[ni] >= 0) continue;
|
||||||
|
ids[ni] = id;
|
||||||
|
q.push(ni);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
id++;
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function majorityLandId(cells, landIds) {
|
||||||
|
const counts = new Map();
|
||||||
|
for (const i of cells || []) {
|
||||||
|
const id = landIds[i];
|
||||||
|
if (id < 0) continue;
|
||||||
|
counts.set(id, (counts.get(id) || 0) + 1);
|
||||||
|
}
|
||||||
|
let best = -1, bestN = 0;
|
||||||
|
for (const [id, n] of counts) if (n > bestN) { best = id; bestN = n; }
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function componentNearAdminCenter(comp, radius = 3.2) {
|
||||||
|
if (!comp?.cells?.length) return false;
|
||||||
|
const mask = new Uint8Array(MAP_W * MAP_H);
|
||||||
|
for (const ci of comp.cells) mask[ci] = 1;
|
||||||
|
const r = Math.ceil(radius);
|
||||||
|
for (const center of adminCenters || []) {
|
||||||
|
if (!center || !inside(center.x, center.y)) continue;
|
||||||
|
const cx = Math.round(center.x), cy = Math.round(center.y);
|
||||||
|
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
|
||||||
|
if (dx * dx + dy * dy > radius * radius) continue;
|
||||||
|
const x = cx + dx, y = cy + dy;
|
||||||
|
if (inside(x, y) && mask[indexOf(x, y)]) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeIsolatedComponentToMain(comp, mainMask, mainCentroid, landIds, landIdValue) {
|
||||||
|
if (!comp?.cells?.length || landIdValue < 0) return [];
|
||||||
|
const dist = new Float64Array(MAP_W * MAP_H);
|
||||||
|
dist.fill(INF);
|
||||||
|
const prev = new Int32Array(MAP_W * MAP_H);
|
||||||
|
prev.fill(-1);
|
||||||
|
const heap = new MinHeap();
|
||||||
|
let seeded = 0;
|
||||||
|
const stride = Math.max(1, Math.floor(comp.cells.length / 96));
|
||||||
|
for (let k = 0; k < comp.cells.length; k += stride) {
|
||||||
|
const i = comp.cells[k];
|
||||||
|
if (sea[i] || landIds[i] !== landIdValue) continue;
|
||||||
|
dist[i] = 0;
|
||||||
|
prev[i] = i;
|
||||||
|
const [x, y] = xyOf(i);
|
||||||
|
heap.push({ i, f: Math.hypot(x - mainCentroid.x, y - mainCentroid.y) * 0.22 });
|
||||||
|
seeded++;
|
||||||
|
}
|
||||||
|
if (!seeded) return [];
|
||||||
|
let goal = -1;
|
||||||
|
let expanded = 0;
|
||||||
|
const maxExpanded = 22000;
|
||||||
|
while (heap.length && expanded < maxExpanded) {
|
||||||
|
const current = heap.pop();
|
||||||
|
if (!current) break;
|
||||||
|
const cur = current.i;
|
||||||
|
expanded++;
|
||||||
|
if (mainMask[cur] && dist[cur] > 2) { goal = cur; break; }
|
||||||
|
const [x, y] = xyOf(cur);
|
||||||
|
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
if (!dx && !dy) continue;
|
||||||
|
const nx = x + dx, ny = y + dy;
|
||||||
|
if (!inside(nx, ny)) continue;
|
||||||
|
const ni = indexOf(nx, ny);
|
||||||
|
if (sea[ni] || landIds[ni] !== landIdValue) continue;
|
||||||
|
const step = Math.hypot(dx, dy);
|
||||||
|
const terrainCost = 1.0 + (slope?.[ni] || 0) * 1.28 + (ridgeField?.[ni] || 0) * 0.66 + Math.max(0, (elevation?.[ni] || 0) - 0.66) * 1.75 - (valleyField?.[ni] || 0) * 0.42 - (plain?.[ni] || 0) * 0.18 - (coastalLowland?.[ni] || 0) * 0.08;
|
||||||
|
const nd = dist[cur] + step * Math.max(0.38, terrainCost);
|
||||||
|
if (nd >= dist[ni]) continue;
|
||||||
|
dist[ni] = nd;
|
||||||
|
prev[ni] = cur;
|
||||||
|
const h = Math.hypot(nx - mainCentroid.x, ny - mainCentroid.y) * 0.22;
|
||||||
|
heap.push({ i: ni, f: nd + h });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (goal < 0) return [];
|
||||||
|
const path = [];
|
||||||
|
let cur = goal;
|
||||||
|
for (let guard = 0; guard < 240 && cur >= 0; guard++) {
|
||||||
|
const [x, y] = xyOf(cur);
|
||||||
|
path.push([x, y]);
|
||||||
|
if (prev[cur] === cur) break;
|
||||||
|
cur = prev[cur];
|
||||||
|
}
|
||||||
|
path.reverse();
|
||||||
|
if (path.length < 4 || path.length > 150) return [];
|
||||||
|
return routeQualityAcceptable(path, { sea, elevation, slope, potential: populationDensity, highElevationThreshold: 0.80, steepThreshold: 0.55 }, {
|
||||||
|
minLength: 4,
|
||||||
|
maxLength: 150,
|
||||||
|
maxCompactness: 5.4,
|
||||||
|
maxHighElevationShare: 0.42,
|
||||||
|
maxSteepShare: 0.66,
|
||||||
|
}) ? path : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function attemptConnectSameLandmassAdminRoadComponents(comps) {
|
||||||
|
const result = { attempted: 0, added: 0, skippedIsland: 0, failed: 0 };
|
||||||
|
if (!comps || comps.length <= 1) return result;
|
||||||
|
const landIds = buildLandComponentIds();
|
||||||
|
const mainLand = majorityLandId(comps[0].cells, landIds);
|
||||||
|
const mainMask = new Uint8Array(MAP_W * MAP_H);
|
||||||
|
let sx = 0, sy = 0, sn = 0;
|
||||||
|
for (const ci of comps[0].cells) {
|
||||||
|
const [cx, cy] = xyOf(ci);
|
||||||
|
sx += cx; sy += cy; sn++;
|
||||||
|
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) {
|
||||||
|
if (dx * dx + dy * dy > 5) continue;
|
||||||
|
const nx = cx + dx, ny = cy + dy;
|
||||||
|
if (inside(nx, ny)) mainMask[indexOf(nx, ny)] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const centroid = { x: sx / Math.max(1, sn), y: sy / Math.max(1, sn) };
|
||||||
|
for (const comp of comps.slice(1, 24)) {
|
||||||
|
if (!componentNearAdminCenter(comp)) continue;
|
||||||
|
const land = majorityLandId(comp.cells, landIds);
|
||||||
|
if (land !== mainLand) { result.skippedIsland++; continue; }
|
||||||
|
result.attempted++;
|
||||||
|
const path = routeIsolatedComponentToMain(comp, mainMask, centroid, landIds, land);
|
||||||
|
if (path.length >= 4) {
|
||||||
|
minorRoads.push(path);
|
||||||
|
result.added++;
|
||||||
|
} else {
|
||||||
|
result.failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
let comps = components();
|
let comps = components();
|
||||||
const before = comps.length;
|
const before = comps.length;
|
||||||
|
const mountainConnect = attemptConnectSameLandmassAdminRoadComponents(comps);
|
||||||
|
if (mountainConnect.added > 0) comps = components();
|
||||||
const pruned = { minor: 0, national: 0, external: 0, expressway: 0, externalExpressway: 0 };
|
const pruned = { minor: 0, national: 0, external: 0, expressway: 0, externalExpressway: 0 };
|
||||||
for (let pass = 0; pass < 4 && comps.length > 1; pass++) {
|
for (let pass = 0; pass < 4 && comps.length > 1; pass++) {
|
||||||
const mainMask = new Uint8Array(MAP_W * MAP_H);
|
const mainMask = new Uint8Array(MAP_W * MAP_H);
|
||||||
|
|
@ -776,7 +1016,7 @@ export function finishMapOutput({
|
||||||
for (const [key, paths] of groups) {
|
for (const [key, paths] of groups) {
|
||||||
const kept = [];
|
const kept = [];
|
||||||
for (const path of paths || []) {
|
for (const path of paths || []) {
|
||||||
if (touchesMain(path) || pathNearAdminCenter(path)) kept.push(path);
|
if (touchesMain(path) || pathNearAdminCenter(path) || ((key === "expressway" || key === "externalExpressway") && pathNearRequiredExpresswayCity(path))) kept.push(path);
|
||||||
else pruned[key]++;
|
else pruned[key]++;
|
||||||
}
|
}
|
||||||
paths.length = 0;
|
paths.length = 0;
|
||||||
|
|
@ -784,7 +1024,7 @@ export function finishMapOutput({
|
||||||
}
|
}
|
||||||
comps = components();
|
comps = components();
|
||||||
}
|
}
|
||||||
debugLayers.finalOutputRoadConnectivity = { beforeComponents: before, afterComponents: comps.length, pruned };
|
debugLayers.finalOutputRoadConnectivity = { beforeComponents: before, afterComponents: comps.length, pruned, mountainAdminConnections: mountainConnect };
|
||||||
}
|
}
|
||||||
pruneIsolatedFinalRoadComponents();
|
pruneIsolatedFinalRoadComponents();
|
||||||
|
|
||||||
|
|
@ -815,6 +1055,178 @@ export function finishMapOutput({
|
||||||
}
|
}
|
||||||
ensureAdminCenterCellsAfterOutputPrune();
|
ensureAdminCenterCellsAfterOutputPrune();
|
||||||
|
|
||||||
|
function connectNearbyRoadEndpoints() {
|
||||||
|
const debugLayers = transportDebug ? (transportDebug.layers || (transportDebug.layers = {})) : {};
|
||||||
|
const ordinaryGroups = [
|
||||||
|
{ key: "minor", paths: minorRoads || [] },
|
||||||
|
{ key: "national", paths: nationalRoads || [] },
|
||||||
|
{ key: "external", paths: externalRoads || [] },
|
||||||
|
{ key: "ring", paths: ringRoads || [] },
|
||||||
|
];
|
||||||
|
const occ = new Uint8Array(MAP_W * MAP_H);
|
||||||
|
function rasterize(path, fn) {
|
||||||
|
for (let k = 1; k < (path?.length || 0); k++) {
|
||||||
|
const [x0, y0] = path[k - 1];
|
||||||
|
const [x1, y1] = path[k];
|
||||||
|
const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0)));
|
||||||
|
for (let s = 0; s <= steps; s++) {
|
||||||
|
const t = s / steps;
|
||||||
|
const x = Math.round(x0 + (x1 - x0) * t);
|
||||||
|
const y = Math.round(y0 + (y1 - y0) * t);
|
||||||
|
if (inside(x, y) && !sea[indexOf(x, y)]) fn(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const group of ordinaryGroups) for (const path of group.paths || []) rasterize(path, (x, y) => { occ[indexOf(x, y)] = 1; });
|
||||||
|
const comp = new Int32Array(MAP_W * MAP_H);
|
||||||
|
comp.fill(-1);
|
||||||
|
let compId = 0;
|
||||||
|
const queue = [];
|
||||||
|
for (let i = 0; i < occ.length; i++) {
|
||||||
|
if (!occ[i] || comp[i] >= 0) continue;
|
||||||
|
comp[i] = compId;
|
||||||
|
queue.length = 0;
|
||||||
|
queue.push(i);
|
||||||
|
for (let q = 0; q < queue.length; q++) {
|
||||||
|
const cur = queue[q];
|
||||||
|
const [x, y] = xyOf(cur);
|
||||||
|
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
if (!dx && !dy) continue;
|
||||||
|
const nx = x + dx, ny = y + dy;
|
||||||
|
if (!inside(nx, ny)) continue;
|
||||||
|
const ni = indexOf(nx, ny);
|
||||||
|
if (!occ[ni] || comp[ni] >= 0) continue;
|
||||||
|
comp[ni] = compId;
|
||||||
|
queue.push(ni);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compId++;
|
||||||
|
}
|
||||||
|
function endpointComponent(x, y) {
|
||||||
|
if (!inside(x, y) || sea[indexOf(x, y)]) return -1;
|
||||||
|
const here = comp[indexOf(x, y)];
|
||||||
|
if (here >= 0) return here;
|
||||||
|
for (let r = 1; r <= 2; r++) {
|
||||||
|
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
|
||||||
|
const nx = x + dx, ny = y + dy;
|
||||||
|
if (!inside(nx, ny) || sea[indexOf(nx, ny)]) continue;
|
||||||
|
const id = comp[indexOf(nx, ny)];
|
||||||
|
if (id >= 0) return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
function directConnector(a, b) {
|
||||||
|
const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y)));
|
||||||
|
const path = [];
|
||||||
|
for (let s = 0; s <= steps; s++) {
|
||||||
|
const t = s / steps;
|
||||||
|
const x = Math.round(a.x + (b.x - a.x) * t);
|
||||||
|
const y = Math.round(a.y + (b.y - a.y) * t);
|
||||||
|
if (!inside(x, y) || sea[indexOf(x, y)]) return [];
|
||||||
|
if (!path.length || path[path.length - 1][0] !== x || path[path.length - 1][1] !== y) path.push([x, y]);
|
||||||
|
}
|
||||||
|
return path.length >= 2 ? path : [];
|
||||||
|
}
|
||||||
|
const endpoints = [];
|
||||||
|
for (const group of ordinaryGroups) {
|
||||||
|
for (let pathIdx = 0; pathIdx < (group.paths?.length || 0); pathIdx++) {
|
||||||
|
const path = group.paths[pathIdx];
|
||||||
|
if (!path || path.length < 2) continue;
|
||||||
|
for (const end of [0, 1]) {
|
||||||
|
const raw = end === 0 ? path[0] : path[path.length - 1];
|
||||||
|
const x = Math.round(raw[0]), y = Math.round(raw[1]);
|
||||||
|
if (!inside(x, y) || sea[indexOf(x, y)]) continue;
|
||||||
|
const ci = indexOf(x, y);
|
||||||
|
const ruralBias = Math.max(0, 0.42 - (populationDensity?.[ci] || 0));
|
||||||
|
endpoints.push({ group: group.key, pathIdx, end, x, y, comp: endpointComponent(x, y), ruralBias });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pairs = [];
|
||||||
|
for (let i = 0; i < endpoints.length; i++) {
|
||||||
|
const a = endpoints[i];
|
||||||
|
if (a.comp < 0) continue;
|
||||||
|
for (let j = i + 1; j < endpoints.length; j++) {
|
||||||
|
const b = endpoints[j];
|
||||||
|
if (b.comp < 0 || a.comp === b.comp) continue;
|
||||||
|
if (a.group === b.group && a.pathIdx === b.pathIdx) continue;
|
||||||
|
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
const limit = (a.ruralBias + b.ruralBias) > 0.38 ? 6.5 : 4.4;
|
||||||
|
if (d < 1.1 || d > limit) continue;
|
||||||
|
const path = directConnector(a, b);
|
||||||
|
if (path.length < 2 || path.length > 9) continue;
|
||||||
|
pairs.push({ a, b, d, path, score: d - (a.ruralBias + b.ruralBias) * 1.25 + (a.group === "minor" && b.group === "minor" ? 0.25 : 0) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pairs.sort((a, b) => a.score - b.score || a.d - b.d);
|
||||||
|
const used = new Set();
|
||||||
|
let added = 0;
|
||||||
|
for (const pair of pairs) {
|
||||||
|
if (added >= 180) break;
|
||||||
|
const ak = `${pair.a.group}:${pair.a.pathIdx}:${pair.a.end}`;
|
||||||
|
const bk = `${pair.b.group}:${pair.b.pathIdx}:${pair.b.end}`;
|
||||||
|
if (used.has(ak) || used.has(bk)) continue;
|
||||||
|
minorRoads.push(pair.path);
|
||||||
|
used.add(ak); used.add(bk);
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
debugLayers.nearbyRoadEndpointConnectorsAdded = added;
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectNearbyRoadEndpoints();
|
||||||
|
|
||||||
|
function renameInterchangesFromMunicipalities() {
|
||||||
|
if (!interchanges?.length || !adminCenters?.length || !adminId) return 0;
|
||||||
|
const centerByAdmin = new Map();
|
||||||
|
for (const center of adminCenters || []) {
|
||||||
|
if (!center || !inside(center.x, center.y)) continue;
|
||||||
|
const id = adminId[indexOf(center.x, center.y)];
|
||||||
|
if (id >= 0 && !centerByAdmin.has(id)) centerByAdmin.set(id, center);
|
||||||
|
}
|
||||||
|
const allCenters = [...centerByAdmin.values()].filter((c) => c?.name);
|
||||||
|
const used = new Set();
|
||||||
|
const directionNames = ["北", "東", "南", "西", "中央", "上", "下", "新"];
|
||||||
|
let renamed = 0;
|
||||||
|
function cleanBase(name) {
|
||||||
|
return String(name || "").replace(/[ICインターチェンジ\s]+$/u, "").replace(/[市町村区]$/u, "");
|
||||||
|
}
|
||||||
|
for (const [idx, ic] of interchanges.entries()) {
|
||||||
|
if (!ic || !inside(ic.x, ic.y)) continue;
|
||||||
|
const cell = indexOf(ic.x, ic.y);
|
||||||
|
const admin = adminId[cell];
|
||||||
|
const primary = centerByAdmin.get(admin) || allCenters.slice().sort((a, b) => Math.hypot(a.x - ic.x, a.y - ic.y) - Math.hypot(b.x - ic.x, b.y - ic.y))[0];
|
||||||
|
const nearbyCenters = allCenters
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => Math.hypot(a.x - ic.x, a.y - ic.y) - Math.hypot(b.x - ic.x, b.y - ic.y));
|
||||||
|
const candidates = [];
|
||||||
|
if (primary?.name) candidates.push(`${cleanBase(primary.municipalityName || primary.name)}IC`);
|
||||||
|
for (const center of nearbyCenters.slice(0, 12)) {
|
||||||
|
const base = cleanBase(center.municipalityName || center.name);
|
||||||
|
if (base) candidates.push(`${base}IC`);
|
||||||
|
}
|
||||||
|
if (primary?.name) {
|
||||||
|
const base = cleanBase(primary.municipalityName || primary.name);
|
||||||
|
for (const dir of directionNames) candidates.push(`${base}${dir}IC`);
|
||||||
|
}
|
||||||
|
candidates.push(`自治${idx + 1}IC`);
|
||||||
|
let name = candidates.find((candidate) => candidate && !used.has(candidate));
|
||||||
|
if (!name) name = `自治${idx + 1}IC`;
|
||||||
|
ic.name = name;
|
||||||
|
ic.labelName = name;
|
||||||
|
ic.municipalityNameBased = true;
|
||||||
|
used.add(name);
|
||||||
|
renamed++;
|
||||||
|
}
|
||||||
|
if (transportDebug) {
|
||||||
|
transportDebug.layers ||= {};
|
||||||
|
transportDebug.layers.municipalityBasedInterchangeNames = renamed;
|
||||||
|
}
|
||||||
|
return renamed;
|
||||||
|
}
|
||||||
|
renameInterchangesFromMunicipalities();
|
||||||
|
|
||||||
nameDebug.maxDerivedPerBase = 0;
|
nameDebug.maxDerivedPerBase = 0;
|
||||||
const prefectureRegions = buildPrefectureRegions(prefectureRegionId, sea, nameFields, seed, usedNames, nameDebug, { modernCities, adminCenters });
|
const prefectureRegions = buildPrefectureRegions(prefectureRegionId, sea, nameFields, seed, usedNames, nameDebug, { modernCities, adminCenters });
|
||||||
const regionalPrefectureBorders = adminRegionalPrefectureBorders || [];
|
const regionalPrefectureBorders = adminRegionalPrefectureBorders || [];
|
||||||
|
|
@ -845,6 +1257,9 @@ export function finishMapOutput({
|
||||||
return applyOutputOptions({
|
return applyOutputOptions({
|
||||||
width: MAP_W,
|
width: MAP_W,
|
||||||
height: MAP_H,
|
height: MAP_H,
|
||||||
|
originX: Number.isFinite(terrain?.originX) ? terrain.originX : (Number.isFinite(options?.originX) ? options.originX : 0),
|
||||||
|
originY: Number.isFinite(terrain?.originY) ? terrain.originY : (Number.isFinite(options?.originY) ? options.originY : 0),
|
||||||
|
generationContext: options?.generationContext || terrain?.generationContext || null,
|
||||||
cellSize: CELL_SIZE,
|
cellSize: CELL_SIZE,
|
||||||
terrainTemplate,
|
terrainTemplate,
|
||||||
seaLevel,
|
seaLevel,
|
||||||
|
|
@ -852,7 +1267,7 @@ export function finishMapOutput({
|
||||||
humanRegionMask,
|
humanRegionMask,
|
||||||
prefectureBorder: regionalPrefectureBorders.length ? regionalPrefectureBorders : prefectureBorder,
|
prefectureBorder: regionalPrefectureBorders.length ? regionalPrefectureBorders : prefectureBorder,
|
||||||
prefectureRegionId,
|
prefectureRegionId,
|
||||||
municipalityToPrefectureId,
|
municipalityToPrefectureId: coherentMunicipalityToPrefectureId,
|
||||||
prefectureRegions,
|
prefectureRegions,
|
||||||
regionalDebug,
|
regionalDebug,
|
||||||
terrainDebug,
|
terrainDebug,
|
||||||
|
|
|
||||||
2272
mapPatch.js
Normal file
2272
mapPatch.js
Normal file
File diff suppressed because it is too large
Load diff
129
mapPipeline.js
129
mapPipeline.js
|
|
@ -8,6 +8,93 @@ import { finalizeAdminAwareTransport } from "./mapPostAdminTransport.js";
|
||||||
|
|
||||||
export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
|
export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
|
||||||
|
|
||||||
|
function normalizeRectLike(rect) {
|
||||||
|
if (!rect) return null;
|
||||||
|
const x0 = Math.floor(Math.min(Number(rect.x0), Number(rect.x1)));
|
||||||
|
const y0 = Math.floor(Math.min(Number(rect.y0), Number(rect.y1)));
|
||||||
|
const x1 = Math.ceil(Math.max(Number(rect.x0), Number(rect.x1)));
|
||||||
|
const y1 = Math.ceil(Math.max(Number(rect.y0), Number(rect.y1)));
|
||||||
|
if (![x0, y0, x1, y1].every(Number.isFinite)) return null;
|
||||||
|
return { x0, y0, x1, y1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGenerationContext(options = {}) {
|
||||||
|
const originX = Math.floor(Number.isFinite(options.originX) ? options.originX : 0);
|
||||||
|
const originY = Math.floor(Number.isFinite(options.originY) ? options.originY : 0);
|
||||||
|
const width = Math.max(1, Math.floor(Number.isFinite(options.width) ? options.width : MAP_W));
|
||||||
|
const height = Math.max(1, Math.floor(Number.isFinite(options.height) ? options.height : MAP_H));
|
||||||
|
const variant = Math.max(0, Math.floor(Number.isFinite(options.variant) ? options.variant : 0)) >>> 0;
|
||||||
|
const contextRect = normalizeRectLike(options.contextRect);
|
||||||
|
return {
|
||||||
|
worldNative: options.worldNative === true,
|
||||||
|
legacyTerrain: options.legacyTerrain !== false,
|
||||||
|
originX,
|
||||||
|
originY,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
variant,
|
||||||
|
contextRect,
|
||||||
|
hasBoundaryWorld: !!options.boundaryWorld,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mixUint(h, value) {
|
||||||
|
h = Math.imul((h ^ (value >>> 0)) >>> 0, 2246822519) >>> 0;
|
||||||
|
h ^= h >>> 13;
|
||||||
|
return Math.imul(h, 3266489917) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mixString(h, value) {
|
||||||
|
const text = String(value ?? "");
|
||||||
|
for (let i = 0; i < text.length; i++) h = mixUint(h, text.charCodeAt(i));
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextualSeed(seed, context, options = {}) {
|
||||||
|
let h = seed >>> 0;
|
||||||
|
if (!context.worldNative && !context.variant && !context.originX && !context.originY) return h;
|
||||||
|
h = mixString(h, options.terrainType || options.generationType || "auto");
|
||||||
|
h = mixUint(h, context.variant);
|
||||||
|
h = mixUint(h, context.originX | 0);
|
||||||
|
h = mixUint(h, context.originY | 0);
|
||||||
|
h = mixUint(h, context.width);
|
||||||
|
h = mixUint(h, context.height);
|
||||||
|
if (context.contextRect) {
|
||||||
|
h = mixUint(h, context.contextRect.x0 | 0);
|
||||||
|
h = mixUint(h, context.contextRect.y0 | 0);
|
||||||
|
h = mixUint(h, context.contextRect.x1 | 0);
|
||||||
|
h = mixUint(h, context.contextRect.y1 | 0);
|
||||||
|
}
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRuntimeOptions(options, baseSeed) {
|
||||||
|
const generationContext = normalizeGenerationContext(options);
|
||||||
|
const effectiveSeed = contextualSeed(baseSeed, generationContext, options);
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
generationContext,
|
||||||
|
baseSeed,
|
||||||
|
effectiveSeed,
|
||||||
|
originX: generationContext.originX,
|
||||||
|
originY: generationContext.originY,
|
||||||
|
width: generationContext.width,
|
||||||
|
height: generationContext.height,
|
||||||
|
variant: generationContext.variant,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateInitialTerrain(seed, options = {}) {
|
||||||
|
// Production generation is intentionally pinned to the legacy high-detail
|
||||||
|
// terrain/natural-compartment pipeline. Rect-native terrain helpers may remain
|
||||||
|
// in the codebase for experiments, but they are not reachable from generateMap.
|
||||||
|
return generateTerrainAndRivers(seed, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function terrainStageLabel() {
|
||||||
|
return "Terrain, rivers, and natural compartments";
|
||||||
|
}
|
||||||
|
|
||||||
function nowMs() {
|
function nowMs() {
|
||||||
return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
||||||
}
|
}
|
||||||
|
|
@ -45,13 +132,15 @@ async function timedStageAsync(timings, options, key, label, fn) {
|
||||||
|
|
||||||
|
|
||||||
export function generateMap(seedInput = 114514, options = {}) {
|
export function generateMap(seedInput = 114514, options = {}) {
|
||||||
const seed = Number(seedInput) >>> 0;
|
const baseSeed = Number(seedInput) >>> 0;
|
||||||
|
options = makeRuntimeOptions(options, baseSeed);
|
||||||
|
const seed = options.effectiveSeed;
|
||||||
if (typeof options.onProgress !== "function") options = { ...options, onProgress: () => {} };
|
if (typeof options.onProgress !== "function") options = { ...options, onProgress: () => {} };
|
||||||
|
|
||||||
const generationTimings = [];
|
const generationTimings = [];
|
||||||
const stage = (key, label, fn) => timedStage(generationTimings, options, key, label, fn);
|
const stage = (key, label, fn) => timedStage(generationTimings, options, key, label, fn);
|
||||||
|
|
||||||
const terrain = stage("terrain", "Terrain, rivers, and natural compartments", () => generateTerrainAndRivers(seed));
|
const terrain = stage("terrain", terrainStageLabel(options), () => generateInitialTerrain(seed, options));
|
||||||
const {
|
const {
|
||||||
elevation,
|
elevation,
|
||||||
slope,
|
slope,
|
||||||
|
|
@ -71,10 +160,10 @@ export function generateMap(seedInput = 114514, options = {}) {
|
||||||
naturalCompartments,
|
naturalCompartments,
|
||||||
} = terrain;
|
} = terrain;
|
||||||
|
|
||||||
const geographyBasis = stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain));
|
const geographyBasis = stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain, options));
|
||||||
const terrainWithGeography = { ...terrain, geography: geographyBasis };
|
const terrainWithGeography = { ...terrain, geography: geographyBasis, generationContext: options.generationContext };
|
||||||
|
|
||||||
const features = stage("settlements", "Settlements, towns, ports, and land-use demand", () => generateMapFeatures(seed, terrainWithGeography));
|
const features = stage("settlements", "Settlements, towns, ports, and land-use demand", () => generateMapFeatures(seed, terrainWithGeography, options));
|
||||||
const {
|
const {
|
||||||
settlementScore,
|
settlementScore,
|
||||||
villages,
|
villages,
|
||||||
|
|
@ -94,10 +183,10 @@ export function generateMap(seedInput = 114514, options = {}) {
|
||||||
villageInfluence,
|
villageInfluence,
|
||||||
} = features;
|
} = features;
|
||||||
|
|
||||||
const geography = stage("geographyFinal", "Unified accessibility and centrality fields", () => finalizeGeographicBasis(seed, terrain, features, geographyBasis));
|
const geography = stage("geographyFinal", "Unified accessibility and centrality fields", () => finalizeGeographicBasis(seed, terrain, features, geographyBasis, options));
|
||||||
|
|
||||||
const admin = stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({
|
const admin = stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({
|
||||||
seed, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
|
seed, generationContext: options.generationContext, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
|
||||||
geography, habitability: geography.habitability, accessibility: geography.accessibility, centrality: geography.centrality, geographicBarrier: geography.geographicBarrier, geographicBarrierCost: geography.geographicBarrierCost, adminBoundaryPreference: geography.adminBoundaryPreference, boundaryAvoidance: geography.boundaryAvoidance,
|
geography, habitability: geography.habitability, accessibility: geography.accessibility, centrality: geography.centrality, geographicBarrier: geography.geographicBarrier, geographicBarrierCost: geography.geographicBarrierCost, adminBoundaryPreference: geography.adminBoundaryPreference, boundaryAvoidance: geography.boundaryAvoidance,
|
||||||
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
|
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
|
||||||
adminProgress: (event) => options?.onProgress?.({
|
adminProgress: (event) => options?.onProgress?.({
|
||||||
|
|
@ -112,7 +201,7 @@ export function generateMap(seedInput = 114514, options = {}) {
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
stage("transport", "Administrative-aware final transport network", () => finalizeAdminAwareTransport({ seed, terrain, features, admin, geography }));
|
stage("transport", "Administrative-aware final transport network", () => finalizeAdminAwareTransport({ seed, terrain, features, admin, geography, generationContext: options.generationContext }));
|
||||||
|
|
||||||
const output = stage("output", "Names, population totals, and final packaging", () => finishMapOutput({
|
const output = stage("output", "Names, population totals, and final packaging", () => finishMapOutput({
|
||||||
seed,
|
seed,
|
||||||
|
|
@ -124,17 +213,22 @@ export function generateMap(seedInput = 114514, options = {}) {
|
||||||
}));
|
}));
|
||||||
output.generationTimings = generationTimings;
|
output.generationTimings = generationTimings;
|
||||||
output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0);
|
output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0);
|
||||||
|
output.baseSeed = options.baseSeed;
|
||||||
|
output.effectiveSeed = seed;
|
||||||
|
output.generationContext = { ...options.generationContext };
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMapAsync(seedInput = 114514, options = {}) {
|
export async function generateMapAsync(seedInput = 114514, options = {}) {
|
||||||
const seed = Number(seedInput) >>> 0;
|
const baseSeed = Number(seedInput) >>> 0;
|
||||||
|
options = makeRuntimeOptions(options, baseSeed);
|
||||||
|
const seed = options.effectiveSeed;
|
||||||
if (typeof options.onProgress !== "function") options = { ...options, onProgress: () => {} };
|
if (typeof options.onProgress !== "function") options = { ...options, onProgress: () => {} };
|
||||||
|
|
||||||
const generationTimings = [];
|
const generationTimings = [];
|
||||||
const stage = (key, label, fn) => timedStageAsync(generationTimings, options, key, label, fn);
|
const stage = (key, label, fn) => timedStageAsync(generationTimings, options, key, label, fn);
|
||||||
|
|
||||||
const terrain = await stage("terrain", "Terrain, rivers, and natural compartments", () => generateTerrainAndRivers(seed));
|
const terrain = await stage("terrain", terrainStageLabel(options), () => generateInitialTerrain(seed, options));
|
||||||
const {
|
const {
|
||||||
elevation,
|
elevation,
|
||||||
slope,
|
slope,
|
||||||
|
|
@ -154,10 +248,10 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
|
||||||
naturalCompartments,
|
naturalCompartments,
|
||||||
} = terrain;
|
} = terrain;
|
||||||
|
|
||||||
const geographyBasis = await stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain));
|
const geographyBasis = await stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain, options));
|
||||||
const terrainWithGeography = { ...terrain, geography: geographyBasis };
|
const terrainWithGeography = { ...terrain, geography: geographyBasis, generationContext: options.generationContext };
|
||||||
|
|
||||||
const features = await stage("settlements", "Settlements, towns, ports, and land-use demand", () => generateMapFeatures(seed, terrainWithGeography));
|
const features = await stage("settlements", "Settlements, towns, ports, and land-use demand", () => generateMapFeatures(seed, terrainWithGeography, options));
|
||||||
const {
|
const {
|
||||||
settlementScore,
|
settlementScore,
|
||||||
villages,
|
villages,
|
||||||
|
|
@ -177,10 +271,10 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
|
||||||
villageInfluence,
|
villageInfluence,
|
||||||
} = features;
|
} = features;
|
||||||
|
|
||||||
const geography = await stage("geographyFinal", "Unified accessibility and centrality fields", () => finalizeGeographicBasis(seed, terrain, features, geographyBasis));
|
const geography = await stage("geographyFinal", "Unified accessibility and centrality fields", () => finalizeGeographicBasis(seed, terrain, features, geographyBasis, options));
|
||||||
|
|
||||||
const admin = await stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({
|
const admin = await stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({
|
||||||
seed, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
|
seed, generationContext: options.generationContext, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
|
||||||
geography, habitability: geography.habitability, accessibility: geography.accessibility, centrality: geography.centrality, geographicBarrier: geography.geographicBarrier, geographicBarrierCost: geography.geographicBarrierCost, adminBoundaryPreference: geography.adminBoundaryPreference, boundaryAvoidance: geography.boundaryAvoidance,
|
geography, habitability: geography.habitability, accessibility: geography.accessibility, centrality: geography.centrality, geographicBarrier: geography.geographicBarrier, geographicBarrierCost: geography.geographicBarrierCost, adminBoundaryPreference: geography.adminBoundaryPreference, boundaryAvoidance: geography.boundaryAvoidance,
|
||||||
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
|
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
|
||||||
adminProgress: (event) => options?.onProgress?.({
|
adminProgress: (event) => options?.onProgress?.({
|
||||||
|
|
@ -195,7 +289,7 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await stage("transport", "Administrative-aware final transport network", () => finalizeAdminAwareTransport({ seed, terrain, features, admin, geography }));
|
await stage("transport", "Administrative-aware final transport network", () => finalizeAdminAwareTransport({ seed, terrain, features, admin, geography, generationContext: options.generationContext }));
|
||||||
|
|
||||||
const output = await stage("output", "Names, population totals, and final packaging", () => finishMapOutput({
|
const output = await stage("output", "Names, population totals, and final packaging", () => finishMapOutput({
|
||||||
seed,
|
seed,
|
||||||
|
|
@ -207,5 +301,8 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
|
||||||
}));
|
}));
|
||||||
output.generationTimings = generationTimings;
|
output.generationTimings = generationTimings;
|
||||||
output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0);
|
output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0);
|
||||||
|
output.baseSeed = options.baseSeed;
|
||||||
|
output.effectiveSeed = seed;
|
||||||
|
output.generationContext = { ...options.generationContext };
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { MAP_W, MAP_H, SIZE, indexOf, inside } from "./mapUtils.js";
|
import { INF, MAP_W, MAP_H, SIZE, MinHeap, indexOf, inside, xyOf } from "./mapUtils.js";
|
||||||
import { pathLengthCells } from "./mapTransport.js";
|
import { pathLengthCells } from "./mapTransport.js";
|
||||||
|
|
||||||
function cellKey(x, y) { return `${Math.round(x)},${Math.round(y)}`; }
|
function cellKey(x, y) { return `${Math.round(x)},${Math.round(y)}`; }
|
||||||
|
|
@ -19,15 +19,38 @@ function pathTerrainRuns(path, terrain = null) {
|
||||||
const ridgeField = terrain?.ridgeField;
|
const ridgeField = terrain?.ridgeField;
|
||||||
const naturalBarrierScore = terrain?.naturalBarrierScore;
|
const naturalBarrierScore = terrain?.naturalBarrierScore;
|
||||||
let seaRun = 0, maxSeaRun = 0, tunnelRun = 0, maxTunnelRun = 0;
|
let seaRun = 0, maxSeaRun = 0, tunnelRun = 0, maxTunnelRun = 0;
|
||||||
for (const [x, y] of path || []) {
|
let sampled = 0;
|
||||||
if (!inside(x, y)) continue;
|
const visit = (x, y) => {
|
||||||
|
if (!inside(x, y)) {
|
||||||
|
seaRun++;
|
||||||
|
maxSeaRun = Math.max(maxSeaRun, seaRun);
|
||||||
|
tunnelRun = 0;
|
||||||
|
sampled++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const i = indexOf(x, y);
|
const i = indexOf(x, y);
|
||||||
const isSea = Boolean(sea?.[i]);
|
const isSea = Boolean(sea?.[i]);
|
||||||
const isTunnel = !isSea && (((elevation?.[i] || 0) >= 0.74 && (ridgeField?.[i] || 0) >= 0.46) || (naturalBarrierScore?.[i] || 0) >= 0.82);
|
// Use the same sensitive tunnel proxy as the main transport validator.
|
||||||
|
// Sampling every raster cell along each segment prevents smoothed or direct
|
||||||
|
// paths from hiding over-limit tunnel runs between sparse vertices.
|
||||||
|
const isTunnel = !isSea && (((elevation?.[i] || 0) >= 0.72 && (ridgeField?.[i] || 0) >= 0.34) || (naturalBarrierScore?.[i] || 0) >= 0.72);
|
||||||
if (isSea) { seaRun++; maxSeaRun = Math.max(maxSeaRun, seaRun); } else seaRun = 0;
|
if (isSea) { seaRun++; maxSeaRun = Math.max(maxSeaRun, seaRun); } else seaRun = 0;
|
||||||
if (isTunnel) { tunnelRun++; maxTunnelRun = Math.max(maxTunnelRun, tunnelRun); } else tunnelRun = 0;
|
if (isTunnel) { tunnelRun++; maxTunnelRun = Math.max(maxTunnelRun, tunnelRun); } else tunnelRun = 0;
|
||||||
|
sampled++;
|
||||||
|
};
|
||||||
|
for (let k = 1; k < (path?.length || 0); k++) {
|
||||||
|
const a = path[k - 1];
|
||||||
|
const b = path[k];
|
||||||
|
if (!a || !b) continue;
|
||||||
|
const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])));
|
||||||
|
for (let s = 0; s <= steps; s++) {
|
||||||
|
if (k > 1 && s === 0) continue;
|
||||||
|
const t = s / steps;
|
||||||
|
visit(Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { maxSeaRun, maxTunnelRun };
|
if ((path?.length || 0) === 1) visit(path[0][0], path[0][1]);
|
||||||
|
return { maxSeaRun, maxTunnelRun, sampled };
|
||||||
}
|
}
|
||||||
|
|
||||||
function directPath(a, b, options = {}) {
|
function directPath(a, b, options = {}) {
|
||||||
|
|
@ -50,6 +73,70 @@ function directPath(a, b, options = {}) {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function routeTerrainPath(a, b, terrain = null, options = {}) {
|
||||||
|
if (!a || !b || !inside(a.x, a.y) || !inside(b.x, b.y)) return [];
|
||||||
|
const sea = terrain?.sea;
|
||||||
|
const elevation = terrain?.elevation;
|
||||||
|
const slope = terrain?.slope;
|
||||||
|
const ridgeField = terrain?.ridgeField;
|
||||||
|
const start = indexOf(Math.round(a.x), Math.round(a.y));
|
||||||
|
const goal = indexOf(Math.round(b.x), Math.round(b.y));
|
||||||
|
if (sea?.[start] || sea?.[goal]) return [];
|
||||||
|
const straight = Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
const maxLength = options.maxLength ?? straight * 2.8 + 60;
|
||||||
|
const maxExpanded = Math.min(SIZE, options.maxExpanded ?? Math.max(9000, Math.floor(straight * straight * 5.5)));
|
||||||
|
const dist = new Float64Array(SIZE);
|
||||||
|
dist.fill(INF);
|
||||||
|
const prev = new Int32Array(SIZE);
|
||||||
|
prev.fill(-1);
|
||||||
|
const closed = new Uint8Array(SIZE);
|
||||||
|
const heap = new MinHeap();
|
||||||
|
dist[start] = 0;
|
||||||
|
prev[start] = start;
|
||||||
|
heap.push({ i: start, f: straight * 0.42 });
|
||||||
|
let hit = -1;
|
||||||
|
let expanded = 0;
|
||||||
|
while (heap.length && expanded++ < maxExpanded) {
|
||||||
|
const current = heap.pop();
|
||||||
|
if (!current || closed[current.i]) continue;
|
||||||
|
const cur = current.i;
|
||||||
|
closed[cur] = 1;
|
||||||
|
const [x, y] = xyOf(cur);
|
||||||
|
if (Math.hypot(x - b.x, y - b.y) <= (options.snapRadius ?? 2.0)) { hit = cur; break; }
|
||||||
|
if (Math.hypot(x - a.x, y - a.y) > maxLength) continue;
|
||||||
|
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
if (!dx && !dy) continue;
|
||||||
|
const nx = x + dx, ny = y + dy;
|
||||||
|
if (!inside(nx, ny)) continue;
|
||||||
|
const ni = indexOf(nx, ny);
|
||||||
|
if (closed[ni] || sea?.[ni]) continue;
|
||||||
|
const step = Math.hypot(dx, dy);
|
||||||
|
const terrainCost = 1.0 + (slope?.[ni] || 0) * 1.55 + (ridgeField?.[ni] || 0) * 0.82 + Math.max(0, (elevation?.[ni] || 0) - 0.66) * 2.05;
|
||||||
|
const nd = dist[cur] + step * Math.max(0.42, terrainCost);
|
||||||
|
if (nd >= dist[ni]) continue;
|
||||||
|
dist[ni] = nd;
|
||||||
|
prev[ni] = cur;
|
||||||
|
const h = Math.hypot(nx - b.x, ny - b.y) * 0.42;
|
||||||
|
heap.push({ i: ni, f: nd + h });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hit < 0) return [];
|
||||||
|
const path = [];
|
||||||
|
let cur = hit;
|
||||||
|
for (let guard = 0; guard < Math.max(80, maxLength * 3) && cur >= 0; guard++) {
|
||||||
|
const [x, y] = xyOf(cur);
|
||||||
|
path.push([x, y]);
|
||||||
|
if (prev[cur] === cur) break;
|
||||||
|
cur = prev[cur];
|
||||||
|
}
|
||||||
|
path.reverse();
|
||||||
|
if (path.length < 2 || pathLengthCells(path) > maxLength) return [];
|
||||||
|
const runs = pathTerrainRuns(path, terrain);
|
||||||
|
if (Number.isFinite(options.maxSeaRun) && runs.maxSeaRun > options.maxSeaRun) return [];
|
||||||
|
if (Number.isFinite(options.maxTunnelRun) && runs.maxTunnelRun > options.maxTunnelRun) return [];
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
function nearestPointOnPaths(paths, p, maxDistance = Infinity) {
|
function nearestPointOnPaths(paths, p, maxDistance = Infinity) {
|
||||||
let best = null;
|
let best = null;
|
||||||
for (const path of paths || []) {
|
for (const path of paths || []) {
|
||||||
|
|
@ -155,7 +242,7 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge
|
||||||
...(features.villages || []).filter((p) => (p.population || 0) >= 5000),
|
...(features.villages || []).filter((p) => (p.population || 0) >= 5000),
|
||||||
...(features.ports || []).filter((p) => (p.population || 0) >= 5000 || p.portClass === "major" || p.portClass === "regional" || p.portClass === "fishing"),
|
...(features.ports || []).filter((p) => (p.population || 0) >= 5000 || p.portClass === "major" || p.portClass === "regional" || p.portClass === "fishing"),
|
||||||
];
|
];
|
||||||
const debug = { order: "settlements -> administration -> transport", adminCentersChecked: 0, adminLocalRoadsAdded: 0, nationalTownSpursAdded: 0, nationalTownChainsAdded: 0, nationalTownChainTownsCovered: 0, expresswayEndpointInterchangesAdded: 0, expresswaysSmoothed: 0 };
|
const debug = { order: "settlements -> administration -> transport", adminCentersChecked: 0, adminLocalRoadsAdded: 0, nationalTownSpursAdded: 0, nationalTownChainsAdded: 0, nationalTownChainTownsCovered: 0, expresswayEndpointInterchangesAdded: 0, expresswaysSmoothed: 0, expresswayMajorCityLinksAdded: 0, railCityChainsAdded: 0, railCityChainCitiesCovered: 0 };
|
||||||
|
|
||||||
// Local roads after admin: every municipal office cell should lie on a road.
|
// Local roads after admin: every municipal office cell should lie on a road.
|
||||||
const settlementTargets = [...(features.modernCities || []), ...(features.markets || []), ...(features.villages || []), ...(features.ports || [])];
|
const settlementTargets = [...(features.modernCities || []), ...(features.markets || []), ...(features.villages || []), ...(features.ports || [])];
|
||||||
|
|
@ -194,6 +281,44 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge
|
||||||
function townWeight(p) {
|
function townWeight(p) {
|
||||||
return (p.population || 0) + (p.isPrefecturalCapital ? 800000 : 0) + (p.isRegionalCapital ? 350000 : 0) + (p.portClass === "major" ? 220000 : p.portClass === "regional" ? 120000 : p.portClass === "fishing" ? 45000 : 0);
|
return (p.population || 0) + (p.isPrefecturalCapital ? 800000 : 0) + (p.isRegionalCapital ? 350000 : 0) + (p.portClass === "major" ? 220000 : p.portClass === "regional" ? 120000 : p.portClass === "fishing" ? 45000 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function relayGeometryAcceptable(points, options = {}) {
|
||||||
|
const pts = (points || []).filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y));
|
||||||
|
if (pts.length < 3) return true;
|
||||||
|
const first = pts[0];
|
||||||
|
const last = pts[pts.length - 1];
|
||||||
|
const vx = last.x - first.x;
|
||||||
|
const vy = last.y - first.y;
|
||||||
|
const direct = Math.hypot(vx, vy);
|
||||||
|
if (direct < 0.001) return false;
|
||||||
|
let via = 0;
|
||||||
|
for (let i = 1; i < pts.length; i++) via += Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
|
||||||
|
const maxDetour = options.maxDetour ?? 1.68;
|
||||||
|
if (via > direct * maxDetour + (options.detourSlack ?? 16)) return false;
|
||||||
|
const maxOffset = Math.max(options.minOffset ?? 14, Math.min(options.maxOffset ?? 30, direct * (options.offsetRatio ?? 0.32)));
|
||||||
|
for (let i = 1; i < pts.length - 1; i++) {
|
||||||
|
const p = pts[i];
|
||||||
|
const wx = p.x - first.x;
|
||||||
|
const wy = p.y - first.y;
|
||||||
|
const t = (wx * vx + wy * vy) / Math.max(0.0001, direct * direct);
|
||||||
|
const projX = first.x + vx * t;
|
||||||
|
const projY = first.y + vy * t;
|
||||||
|
const offset = Math.hypot(p.x - projX, p.y - projY);
|
||||||
|
if (t < (options.minProjection ?? -0.10) || t > (options.maxProjection ?? 1.10)) return false;
|
||||||
|
if (offset > maxOffset) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathGeometryAcceptable(path, options = {}) {
|
||||||
|
if (!path || path.length < 3) return true;
|
||||||
|
const step = Math.max(1, Math.floor(path.length / 10));
|
||||||
|
const pts = [];
|
||||||
|
for (let k = 0; k < path.length; k += step) pts.push({ x: path[k][0], y: path[k][1] });
|
||||||
|
const last = path[path.length - 1];
|
||||||
|
pts.push({ x: last[0], y: last[1] });
|
||||||
|
return relayGeometryAcceptable(pts, options);
|
||||||
|
}
|
||||||
function nearestTrunkOrHub(p, maxDistance = 85) {
|
function nearestTrunkOrHub(p, maxDistance = 85) {
|
||||||
const trunk = nearestPointOnPaths([...nationalRoads, ...externalRoads], p, maxDistance);
|
const trunk = nearestPointOnPaths([...nationalRoads, ...externalRoads], p, maxDistance);
|
||||||
if (trunk) return trunk;
|
if (trunk) return trunk;
|
||||||
|
|
@ -225,30 +350,41 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge
|
||||||
let townsCovered = 0;
|
let townsCovered = 0;
|
||||||
while (uncovered.length) {
|
while (uncovered.length) {
|
||||||
const start = uncovered.shift();
|
const start = uncovered.shift();
|
||||||
const chain = buildTownChain(start, uncovered, 7);
|
let chain = buildTownChain(start, uncovered, 7);
|
||||||
uncovered = uncovered.filter((town) => !chain.includes(town));
|
uncovered = uncovered.filter((town) => !chain.includes(town));
|
||||||
const parts = [];
|
const parts = [];
|
||||||
const before = nearestTrunkOrHub(chain[0], 80);
|
let before = nearestTrunkOrHub(chain[0], 80);
|
||||||
|
let after = chain.length >= 2 ? nearestTrunkOrHub(chain[chain.length - 1], 80) : null;
|
||||||
|
const relayPoints = [before || chain[0], ...chain, after || chain[chain.length - 1]];
|
||||||
|
if (!relayGeometryAcceptable(relayPoints, { maxDetour: 1.62, minOffset: 12, maxOffset: 26, offsetRatio: 0.30 })) {
|
||||||
|
// The town-chain pass is a coverage fallback, not a mandate to drag a
|
||||||
|
// road through a remote off-axis waypoint. Collapse to a single spur
|
||||||
|
// when the waypoint chain would create a hooked or S-shaped route.
|
||||||
|
chain = [chain[0]];
|
||||||
|
before = nearestTrunkOrHub(chain[0], 80);
|
||||||
|
after = null;
|
||||||
|
}
|
||||||
if (before) {
|
if (before) {
|
||||||
const p = directPath(before, chain[0], { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 0 });
|
const p = directPath(before, chain[0], { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 10 });
|
||||||
if (p.length) parts.push(p);
|
if (p.length) parts.push(p);
|
||||||
}
|
}
|
||||||
for (let i = 1; i < chain.length; i++) {
|
for (let i = 1; i < chain.length; i++) {
|
||||||
const d = Math.hypot(chain[i - 1].x - chain[i].x, chain[i - 1].y - chain[i].y);
|
const d = Math.hypot(chain[i - 1].x - chain[i].x, chain[i - 1].y - chain[i].y);
|
||||||
const p = directPath(chain[i - 1], chain[i], { maxLength: Math.max(28, d * 1.35 + 8), terrain, maxSeaRun: 10, maxTunnelRun: 0 });
|
const segmentPoints = [chain[i - 1], chain[i]];
|
||||||
|
if (!relayGeometryAcceptable(segmentPoints, { maxDetour: 1.25, minOffset: 10, maxOffset: 18 })) continue;
|
||||||
|
const p = directPath(chain[i - 1], chain[i], { maxLength: Math.max(28, d * 1.35 + 8), terrain, maxSeaRun: 10, maxTunnelRun: 10 });
|
||||||
if (p.length) parts.push(p);
|
if (p.length) parts.push(p);
|
||||||
}
|
}
|
||||||
const after = chain.length >= 2 ? nearestTrunkOrHub(chain[chain.length - 1], 80) : null;
|
|
||||||
if (after) {
|
if (after) {
|
||||||
const p = directPath(chain[chain.length - 1], after, { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 0 });
|
const p = directPath(chain[chain.length - 1], after, { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 10 });
|
||||||
if (p.length) parts.push(p);
|
if (p.length) parts.push(p);
|
||||||
}
|
}
|
||||||
let path = concatPaths(parts);
|
let path = concatPaths(parts);
|
||||||
if (path.length < 2) {
|
if (path.length < 2) {
|
||||||
const target = nearestTrunkOrHub(chain[0], 90);
|
const target = nearestTrunkOrHub(chain[0], 90);
|
||||||
path = target ? directPath(chain[0], target, { maxLength: 100, terrain, maxSeaRun: 10, maxTunnelRun: 0 }) : [];
|
path = target ? directPath(chain[0], target, { maxLength: 100, terrain, maxSeaRun: 10, maxTunnelRun: 10 }) : [];
|
||||||
}
|
}
|
||||||
if (path.length >= 2 && chain.some((town) => pathTouchesCell(path, town.x, town.y, 0.65))) {
|
if (path.length >= 2 && pathGeometryAcceptable(path, { maxDetour: 1.78, minOffset: 14, maxOffset: 32, offsetRatio: 0.34 }) && chain.some((town) => pathTouchesCell(path, town.x, town.y, 0.65))) {
|
||||||
nationalRoads.push(path);
|
nationalRoads.push(path);
|
||||||
chainsAdded++;
|
chainsAdded++;
|
||||||
townsCovered += chain.filter((town) => pathTouchesCell(path, town.x, town.y, 0.65)).length;
|
townsCovered += chain.filter((town) => pathTouchesCell(path, town.x, town.y, 0.65)).length;
|
||||||
|
|
@ -261,27 +397,384 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge
|
||||||
debug.nationalTownChainTownsCovered = chainDebug.townsCovered;
|
debug.nationalTownChainTownsCovered = chainDebug.townsCovered;
|
||||||
debug.nationalTownSpursAdded = chainDebug.chainsAdded;
|
debug.nationalTownSpursAdded = chainDebug.chainsAdded;
|
||||||
|
|
||||||
|
function majorCityKey(city) { return city?.name || `${Math.round(city.x)},${Math.round(city.y)}`; }
|
||||||
|
function expresswayCityComponents(cities, radius = 8.0) {
|
||||||
|
const parent = new Map();
|
||||||
|
function find(k) {
|
||||||
|
const p = parent.get(k);
|
||||||
|
if (p === k) return k;
|
||||||
|
const r = find(p);
|
||||||
|
parent.set(k, r);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
function unite(a, b) {
|
||||||
|
const ra = find(a), rb = find(b);
|
||||||
|
if (ra !== rb) parent.set(ra, rb);
|
||||||
|
}
|
||||||
|
for (const city of cities) parent.set(majorCityKey(city), majorCityKey(city));
|
||||||
|
for (const path of [...expressways, ...externalExpressways]) {
|
||||||
|
const near = cities.filter((city) => pathTouchesCell(path, city.x, city.y, radius));
|
||||||
|
if (near.length >= 2) {
|
||||||
|
const first = majorCityKey(near[0]);
|
||||||
|
for (const city of near.slice(1)) unite(first, majorCityKey(city));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Map(cities.map((city) => [majorCityKey(city), find(majorCityKey(city))]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function suburbanExpresswayAnchorForCity(city, target = null) {
|
||||||
|
if (!city || !inside(city.x, city.y)) return null;
|
||||||
|
const sea = terrain?.sea;
|
||||||
|
const elevation = terrain?.elevation;
|
||||||
|
const slope = terrain?.slope;
|
||||||
|
const ridgeField = terrain?.ridgeField;
|
||||||
|
const inner = Math.max(8, Math.round((city.coreRadius || 4) + 6));
|
||||||
|
const outer = Math.max(inner + 7, Math.round((city.urbanRadius || 12) * 1.9));
|
||||||
|
let best = null;
|
||||||
|
for (let dy = -outer; dy <= outer; dy++) {
|
||||||
|
for (let dx = -outer; dx <= outer; dx++) {
|
||||||
|
const x = city.x + dx, y = city.y + dy;
|
||||||
|
if (!inside(x, y)) continue;
|
||||||
|
const d = Math.hypot(dx, dy);
|
||||||
|
if (d < inner || d > outer) continue;
|
||||||
|
const i = indexOf(x, y);
|
||||||
|
if (sea?.[i]) continue;
|
||||||
|
const radial = Math.abs(d - (inner + outer) * 0.52);
|
||||||
|
const targetBias = target ? Math.hypot(x - target.x, y - target.y) * 0.038 : 0;
|
||||||
|
const score = -radial * 0.26 - targetBias - (slope?.[i] || 0) * 0.70 - (ridgeField?.[i] || 0) * 0.55 - Math.max(0, (elevation?.[i] || 0) - 0.70) * 0.75;
|
||||||
|
if (!best || score > best.score) best = { x, y, score };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function suburbanExpresswayStubForCity(city, preferredAnchor = null) {
|
||||||
|
if (!city || !inside(city.x, city.y)) return [];
|
||||||
|
const angles = [];
|
||||||
|
if (preferredAnchor) angles.push(Math.atan2(preferredAnchor.y - city.y, preferredAnchor.x - city.x));
|
||||||
|
for (let k = 0; k < 8; k++) angles.push((Math.PI * 2 * k) / 8 + (k % 2 ? 0.18 : 0));
|
||||||
|
const seenAngles = new Set();
|
||||||
|
for (const angle of angles) {
|
||||||
|
const bucket = Math.round(angle * 100) / 100;
|
||||||
|
if (seenAngles.has(bucket)) continue;
|
||||||
|
seenAngles.add(bucket);
|
||||||
|
const hint = { x: Math.round(city.x + Math.cos(angle) * 120), y: Math.round(city.y + Math.sin(angle) * 120) };
|
||||||
|
const anchor = suburbanExpresswayAnchorForCity(city, hint);
|
||||||
|
if (!anchor) continue;
|
||||||
|
const minD = Math.max(20, (city.urbanRadius || 12) * 1.35);
|
||||||
|
const maxD = Math.max(minD + 10, (city.urbanRadius || 12) * 2.65);
|
||||||
|
let bestEnd = null;
|
||||||
|
for (let d = minD; d <= maxD; d += 2) {
|
||||||
|
const x = Math.round(city.x + Math.cos(angle) * d);
|
||||||
|
const y = Math.round(city.y + Math.sin(angle) * d);
|
||||||
|
if (!inside(x, y)) continue;
|
||||||
|
const i = indexOf(x, y);
|
||||||
|
if (terrain?.sea?.[i]) continue;
|
||||||
|
bestEnd = { x, y };
|
||||||
|
}
|
||||||
|
if (!bestEnd || Math.hypot(bestEnd.x - anchor.x, bestEnd.y - anchor.y) < 8) continue;
|
||||||
|
const d = Math.hypot(bestEnd.x - anchor.x, bestEnd.y - anchor.y);
|
||||||
|
let path = directPath(anchor, bestEnd, { maxLength: d * 1.8 + 12, terrain, maxSeaRun: 0, maxTunnelRun: 10 });
|
||||||
|
if (!path.length) path = routeTerrainPath(anchor, bestEnd, terrain, { maxLength: d * 2.6 + 20, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 1.6 });
|
||||||
|
if (path.length >= 4) return path;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function expresswayServesCityFringe(city) {
|
||||||
|
const inner = Math.max(7.0, (city.coreRadius || 4) + 4.5);
|
||||||
|
const outer = Math.max(inner + 7, (city.urbanRadius || 12) * 2.0);
|
||||||
|
for (const path of [...(features.expressways || []), ...(features.externalExpressways || [])]) {
|
||||||
|
let inBand = false;
|
||||||
|
let exits = false;
|
||||||
|
for (const [x, y] of path || []) {
|
||||||
|
const d = Math.hypot(x - city.x, y - city.y);
|
||||||
|
if (d >= inner && d <= outer) inBand = true;
|
||||||
|
if (d >= Math.max(24, (city.urbanRadius || 12) * 1.55)) exits = true;
|
||||||
|
if (inBand && exits) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureMajorCityExpresswayLinks(minPopulation = 100000) {
|
||||||
|
const cities = (features.modernCities || [])
|
||||||
|
.filter((city) => (city.population || 0) >= minPopulation && inside(city.x, city.y))
|
||||||
|
.sort((a, b) => (b.population || 0) - (a.population || 0));
|
||||||
|
const result = { minPopulation, checked: cities.length, covered: 0, added: 0, noTarget: 0, noPath: 0 };
|
||||||
|
features.expressways ||= [];
|
||||||
|
if (!cities.length) return result;
|
||||||
|
for (const city of cities) {
|
||||||
|
if (expresswayServesCityFringe(city)) { result.covered++; continue; }
|
||||||
|
const existing = [...(features.expressways || []), ...(features.externalExpressways || [])];
|
||||||
|
let target = nearestPointOnPaths(existing, city, 145);
|
||||||
|
if (!target) {
|
||||||
|
const other = cities.find((c) => c !== city && expresswayServesCityFringe(c));
|
||||||
|
target = other ? suburbanExpresswayAnchorForCity(other, city) : null;
|
||||||
|
}
|
||||||
|
if (!target) { result.noTarget++; continue; }
|
||||||
|
const anchor = suburbanExpresswayAnchorForCity(city, target);
|
||||||
|
if (!anchor) { result.noTarget++; continue; }
|
||||||
|
const d = Math.hypot(anchor.x - target.x, anchor.y - target.y);
|
||||||
|
if (d < 4) { result.covered++; continue; }
|
||||||
|
let path = directPath(anchor, target, { maxLength: d * 1.35 + 18, terrain, maxSeaRun: 20, maxTunnelRun: 10 });
|
||||||
|
if (!path.length) path = directPath(anchor, target, { maxLength: d * 1.75 + 34, terrain, maxSeaRun: 20, maxTunnelRun: 10 });
|
||||||
|
if (!path.length) path = routeTerrainPath(anchor, target, terrain, { maxLength: d * 2.9 + 64, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 2.5 });
|
||||||
|
if (!path.length) {
|
||||||
|
const cityTargets = cities
|
||||||
|
.filter((other) => other !== city)
|
||||||
|
.map((other) => {
|
||||||
|
const otherAnchor = suburbanExpresswayAnchorForCity(other, anchor);
|
||||||
|
return otherAnchor ? { other, otherAnchor, d: Math.hypot(otherAnchor.x - anchor.x, otherAnchor.y - anchor.y) } : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((row) => row.d >= 16 && row.d <= 185)
|
||||||
|
.sort((a, b) => a.d - b.d);
|
||||||
|
for (const row of cityTargets.slice(0, 6)) {
|
||||||
|
let candidate = directPath(anchor, row.otherAnchor, { maxLength: row.d * 1.6 + 26, terrain, maxSeaRun: 20, maxTunnelRun: 10 });
|
||||||
|
if (!candidate.length) candidate = routeTerrainPath(anchor, row.otherAnchor, terrain, { maxLength: row.d * 2.9 + 70, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 2.5 });
|
||||||
|
if (candidate.length >= 4) { path = candidate; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!path.length) path = suburbanExpresswayStubForCity(city, anchor);
|
||||||
|
if (!path.length || pathLengthCells(path) < 4) { result.noPath++; continue; }
|
||||||
|
features.expressways.push(smoothPath(path, 1));
|
||||||
|
result.added++;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cityRailChain(minPopulation = 50000) {
|
||||||
|
const cities = (features.modernCities || [])
|
||||||
|
.filter((city) => (city.population || 0) >= minPopulation && inside(city.x, city.y))
|
||||||
|
.sort((a, b) => a.x - b.x || a.y - b.y);
|
||||||
|
const result = { minPopulation, checked: cities.length, chainsAdded: 0, citiesCovered: 0 };
|
||||||
|
if (cities.length < 2) return result;
|
||||||
|
const existingRail = [...(features.railways || []), ...(features.branchRailways || []), ...(features.externalRailways || [])];
|
||||||
|
const uncovered = cities.filter((city) => !anyPathTouches(existingRail, city, 1.2));
|
||||||
|
if (!uncovered.length) return result;
|
||||||
|
const remaining = uncovered.slice();
|
||||||
|
let chain = [remaining.shift()];
|
||||||
|
while (remaining.length) {
|
||||||
|
const cur = chain[chain.length - 1];
|
||||||
|
let bestIndex = 0;
|
||||||
|
let bestD = Infinity;
|
||||||
|
for (let i = 0; i < remaining.length; i++) {
|
||||||
|
const d = Math.hypot(cur.x - remaining[i].x, cur.y - remaining[i].y);
|
||||||
|
if (d < bestD) { bestD = d; bestIndex = i; }
|
||||||
|
}
|
||||||
|
chain.push(remaining.splice(bestIndex, 1)[0]);
|
||||||
|
}
|
||||||
|
const parts = [];
|
||||||
|
for (let i = 1; i < chain.length; i++) {
|
||||||
|
const a = chain[i - 1], b = chain[i];
|
||||||
|
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
let p = directPath(a, b, { maxLength: d * 1.55 + 20, terrain, maxSeaRun: 10, maxTunnelRun: 10 });
|
||||||
|
if (!p.length) p = directPath(a, b, { maxLength: d * 1.85 + 40, terrain, maxSeaRun: 10, maxTunnelRun: 10 });
|
||||||
|
if (p.length) parts.push(p);
|
||||||
|
}
|
||||||
|
const path = concatPaths(parts);
|
||||||
|
if (path.length >= 2) {
|
||||||
|
features.railways = features.railways || [];
|
||||||
|
features.railways.push(smoothPath(path, 1));
|
||||||
|
result.chainsAdded = 1;
|
||||||
|
result.citiesCovered = chain.filter((city) => pathTouchesCell(path, city.x, city.y, 1.2)).length;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const railDebug = cityRailChain(50000);
|
||||||
|
debug.railCityChainsAdded = railDebug.chainsAdded;
|
||||||
|
debug.railCityChainCitiesCovered = railDebug.citiesCovered;
|
||||||
|
|
||||||
|
const expressDebug = ensureMajorCityExpresswayLinks(100000);
|
||||||
|
debug.expresswayMajorCityLinksAdded = expressDebug.added;
|
||||||
|
debug.expresswayMajorCityLinksCovered = expressDebug.covered;
|
||||||
|
debug.expresswayMajorCityLinksNoTarget = expressDebug.noTarget;
|
||||||
|
debug.expresswayMajorCityLinksNoPath = expressDebug.noPath;
|
||||||
|
|
||||||
// Expressway finalization after administration: smooth and ensure both endpoints are ICs.
|
// Expressway finalization after administration: smooth and ensure both endpoints are ICs.
|
||||||
for (let i = 0; i < expressways.length; i++) {
|
for (let i = 0; i < expressways.length; i++) {
|
||||||
const smoothed = smoothPath(expressways[i], 2);
|
const smoothed = smoothPath(expressways[i], 2);
|
||||||
if (smoothed.length >= 2) {
|
if (smoothed.length >= 2) {
|
||||||
expressways[i] = smoothed;
|
const runs = pathTerrainRuns(smoothed, terrain);
|
||||||
debug.expresswaysSmoothed++;
|
if (runs.maxTunnelRun <= 10 && runs.maxSeaRun <= 20) {
|
||||||
|
expressways[i] = smoothed;
|
||||||
|
debug.expresswaysSmoothed++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const path of [...expressways, ...externalExpressways]) {
|
const expresswayBeforeTerrainPrune = expressways.length;
|
||||||
if (!path || path.length < 2) continue;
|
for (let i = expressways.length - 1; i >= 0; i--) {
|
||||||
const a = path[0];
|
const runs = pathTerrainRuns(expressways[i], terrain);
|
||||||
const b = path[path.length - 1];
|
if (runs.maxTunnelRun > 10 || runs.maxSeaRun > 20) expressways.splice(i, 1);
|
||||||
if (addInterchange(interchanges, a[0], a[1])) debug.expresswayEndpointInterchangesAdded++;
|
|
||||||
if (addInterchange(interchanges, b[0], b[1])) debug.expresswayEndpointInterchangesAdded++;
|
|
||||||
}
|
}
|
||||||
|
debug.expresswaysPrunedForBridgeTunnelLimits = expresswayBeforeTerrainPrune - expressways.length;
|
||||||
|
|
||||||
|
function pointInsideCityNodeBuffer(x, y) {
|
||||||
|
for (const city of features.modernCities || []) {
|
||||||
|
if (!city || (city.population || 0) < 25000) continue;
|
||||||
|
const r = Math.max(4.2, (city.coreRadius || 3) + 1.6);
|
||||||
|
if (Math.hypot(x - city.x, y - city.y) <= r) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function splitExpresswayAwayFromCityNodes(path) {
|
||||||
|
const chunks = [];
|
||||||
|
let cur = [];
|
||||||
|
for (const [x, y] of path || []) {
|
||||||
|
if (pointInsideCityNodeBuffer(x, y)) {
|
||||||
|
if (cur.length >= 2) chunks.push(cur);
|
||||||
|
cur = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!cur.length || cur[cur.length - 1][0] !== x || cur[cur.length - 1][1] !== y) cur.push([x, y]);
|
||||||
|
}
|
||||||
|
if (cur.length >= 2) chunks.push(cur);
|
||||||
|
return chunks.filter((chunk) => pathLengthCells(chunk) >= 12);
|
||||||
|
}
|
||||||
|
const expresswayBeforeCityNodePrune = expressways.length;
|
||||||
|
const separatedExpressways = [];
|
||||||
|
for (const path of expressways) separatedExpressways.push(...splitExpresswayAwayFromCityNodes(path));
|
||||||
|
expressways.length = 0;
|
||||||
|
expressways.push(...dedupePaths(separatedExpressways, 2));
|
||||||
|
debug.expresswaysPrunedForCityNodeSeparation = expresswayBeforeCityNodePrune - expressways.length;
|
||||||
|
|
||||||
|
function connectNearbyExpresswayTermini() {
|
||||||
|
const result = { candidates: 0, added: 0, failed: 0 };
|
||||||
|
const expressGroups = [
|
||||||
|
{ key: "expressway", paths: expressways },
|
||||||
|
{ key: "externalExpressway", paths: externalExpressways },
|
||||||
|
];
|
||||||
|
const endpoints = [];
|
||||||
|
for (const group of expressGroups) {
|
||||||
|
for (let pathIdx = 0; pathIdx < (group.paths?.length || 0); pathIdx++) {
|
||||||
|
const path = group.paths[pathIdx];
|
||||||
|
if (!path || path.length < 2) continue;
|
||||||
|
for (const end of [0, 1]) {
|
||||||
|
const raw = end === 0 ? path[0] : path[path.length - 1];
|
||||||
|
const x = Math.round(raw[0]), y = Math.round(raw[1]);
|
||||||
|
if (!inside(x, y) || terrain?.sea?.[indexOf(x, y)] || pointInsideCityNodeBuffer(x, y)) continue;
|
||||||
|
endpoints.push({ group: group.key, pathIdx, end, x, y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pairs = [];
|
||||||
|
for (let i = 0; i < endpoints.length; i++) {
|
||||||
|
const a = endpoints[i];
|
||||||
|
for (let j = i + 1; j < endpoints.length; j++) {
|
||||||
|
const b = endpoints[j];
|
||||||
|
if (a.group === b.group && a.pathIdx === b.pathIdx) continue;
|
||||||
|
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
if (d < 3.0 || d > 24.0) continue;
|
||||||
|
pairs.push({ a, b, d, kind: "terminus-terminus" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also snap a dead-end to the side of a nearby expressway if no terminal is
|
||||||
|
// close enough. This removes visible half-built expressway stubs without
|
||||||
|
// requiring every segment to be merged into a single polyline.
|
||||||
|
for (const a of endpoints) {
|
||||||
|
let best = null;
|
||||||
|
for (const group of expressGroups) {
|
||||||
|
for (let pathIdx = 0; pathIdx < (group.paths?.length || 0); pathIdx++) {
|
||||||
|
if (a.group === group.key && a.pathIdx === pathIdx) continue;
|
||||||
|
const path = group.paths[pathIdx];
|
||||||
|
for (let k = 1; k < (path?.length || 0) - 1; k += 2) {
|
||||||
|
const [x, y] = path[k];
|
||||||
|
const d = Math.hypot(a.x - x, a.y - y);
|
||||||
|
if (d < 3.0 || d > 14.0) continue;
|
||||||
|
if (!best || d < best.d) best = { a, b: { group: group.key, pathIdx, end: -1, x, y }, d, kind: "terminus-side" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best) pairs.push(best);
|
||||||
|
}
|
||||||
|
pairs.sort((a, b) => a.d - b.d || (a.kind === "terminus-terminus" ? -1 : 1));
|
||||||
|
const used = new Set();
|
||||||
|
for (const pair of pairs) {
|
||||||
|
if (result.added >= 10) break;
|
||||||
|
const ak = `${pair.a.group}:${pair.a.pathIdx}:${pair.a.end}`;
|
||||||
|
const bk = `${pair.b.group}:${pair.b.pathIdx}:${pair.b.end}`;
|
||||||
|
if (used.has(ak) || (pair.b.end >= 0 && used.has(bk))) continue;
|
||||||
|
result.candidates++;
|
||||||
|
let path = directPath(pair.a, pair.b, { maxLength: pair.d * 1.65 + 18, terrain, maxSeaRun: 20, maxTunnelRun: 10 });
|
||||||
|
if (!path.length) path = routeTerrainPath(pair.a, pair.b, terrain, { maxLength: pair.d * 2.6 + 44, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 1.8 });
|
||||||
|
if (!path.length || pathLengthCells(path) < 3) { result.failed++; continue; }
|
||||||
|
const runs = pathTerrainRuns(path, terrain);
|
||||||
|
if (runs.maxTunnelRun > 10 || runs.maxSeaRun > 20) { result.failed++; continue; }
|
||||||
|
expressways.push(smoothPath(path, 1));
|
||||||
|
used.add(ak);
|
||||||
|
if (pair.b.end >= 0) used.add(bk);
|
||||||
|
result.added++;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expresswayTerminusConnectDebug = connectNearbyExpresswayTermini();
|
||||||
|
debug.expresswayTerminusConnectionsAdded = expresswayTerminusConnectDebug.added;
|
||||||
|
debug.expresswayTerminusConnectionCandidates = expresswayTerminusConnectDebug.candidates;
|
||||||
|
debug.expresswayTerminusConnectionFailures = expresswayTerminusConnectDebug.failed;
|
||||||
|
|
||||||
|
function pointOnExpressway(p, radius = 1.5) {
|
||||||
|
return (expressways || []).some((path) => pathTouchesCell(path, p.x, p.y, radius));
|
||||||
|
}
|
||||||
|
const icBeforePrune = interchanges.length;
|
||||||
|
const pairedInterchanges = [];
|
||||||
|
const pairedAccessRoads = [];
|
||||||
|
for (let i = 0; i < interchanges.length; i++) {
|
||||||
|
const ic = interchanges[i];
|
||||||
|
const access = (features.icAccessRoads || [])[i];
|
||||||
|
if (ic && pointOnExpressway(ic, 1.8) && access && access.length >= 2) {
|
||||||
|
pairedInterchanges.push(ic);
|
||||||
|
pairedAccessRoads.push(access);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interchanges.length = 0;
|
||||||
|
interchanges.push(...pairedInterchanges);
|
||||||
|
features.icAccessRoads = pairedAccessRoads;
|
||||||
|
debug.interchangesPrunedWithoutExpresswayOrAccess = icBeforePrune - interchanges.length;
|
||||||
|
|
||||||
|
function ensureTerminalInterchangesWithAccess() {
|
||||||
|
const result = { endpointsChecked: 0, added: 0, accessAdded: 0, withoutAccess: 0 };
|
||||||
|
features.icAccessRoads ||= [];
|
||||||
|
const ordinaryRoads = [...(features.nationalRoads || []), ...(features.externalRoads || []), ...(features.minorRoads || [])];
|
||||||
|
for (const path of [...(features.expressways || []), ...(features.externalExpressways || [])]) {
|
||||||
|
if (!path || path.length < 2) continue;
|
||||||
|
for (const raw of [path[0], path[path.length - 1]]) {
|
||||||
|
const p = { x: Math.round(raw[0]), y: Math.round(raw[1]) };
|
||||||
|
result.endpointsChecked++;
|
||||||
|
if (!inside(p.x, p.y) || terrain?.sea?.[indexOf(p.x, p.y)]) continue;
|
||||||
|
if ((interchanges || []).some((ic) => Math.hypot(ic.x - p.x, ic.y - p.y) <= 2.8)) continue;
|
||||||
|
const hit = nearestPointOnPaths(ordinaryRoads, p, 58);
|
||||||
|
let access = [];
|
||||||
|
if (hit) {
|
||||||
|
const d = Math.hypot(p.x - hit.x, p.y - hit.y);
|
||||||
|
access = directPath(p, hit, { maxLength: d * 1.75 + 18, terrain, maxSeaRun: 0, maxTunnelRun: 8 });
|
||||||
|
if (access.length >= 2) {
|
||||||
|
features.icAccessRoads.push(access);
|
||||||
|
features.minorRoads ||= [];
|
||||||
|
features.minorRoads.push(access);
|
||||||
|
result.accessAdded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addInterchange(interchanges, p.x, p.y, access.length >= 2 ? "post-admin-terminal-ic" : "post-admin-terminal-ic-no-access");
|
||||||
|
result.added++;
|
||||||
|
if (access.length < 2) result.withoutAccess++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const terminalIcDebug = ensureTerminalInterchangesWithAccess();
|
||||||
|
debug.expresswayTerminalInterchangesAdded = terminalIcDebug.added;
|
||||||
|
debug.expresswayTerminalInterchangeAccessAdded = terminalIcDebug.accessAdded;
|
||||||
|
debug.expresswayTerminalInterchangesWithoutAccess = terminalIcDebug.withoutAccess;
|
||||||
|
|
||||||
features.minorRoads = dedupePaths(minorRoads, 2);
|
features.minorRoads = dedupePaths(minorRoads, 2);
|
||||||
features.nationalRoads = dedupePaths(nationalRoads, 1);
|
features.nationalRoads = dedupePaths(nationalRoads, 1);
|
||||||
features.externalRoads = dedupePaths(externalRoads, 1);
|
features.externalRoads = dedupePaths(externalRoads, 1);
|
||||||
features.expressways = dedupePaths(expressways, 2);
|
features.expressways = dedupePaths(expressways, 2);
|
||||||
features.externalExpressways = dedupePaths(externalExpressways, 2);
|
features.externalExpressways = dedupePaths(externalExpressways, 2);
|
||||||
|
features.railways = dedupePaths(features.railways || [], 2);
|
||||||
features.interchanges = interchanges;
|
features.interchanges = interchanges;
|
||||||
|
|
||||||
// Final invariant: after all dedupe passes, every municipal office cell has at least a local road cell on it.
|
// Final invariant: after all dedupe passes, every municipal office cell has at least a local road cell on it.
|
||||||
|
|
|
||||||
|
|
@ -748,6 +748,98 @@ export function splitOversizedPrefecturesByMunicipalityCount(nodes, owner, maxCo
|
||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function relaxHighBoundaryShareMunicipalities(nodes, owner, options = {}) {
|
||||||
|
const threshold = options.threshold ?? 0.50;
|
||||||
|
const maxPasses = options.maxPasses ?? 5;
|
||||||
|
const minSharedToTarget = options.minSharedToTarget ?? 2;
|
||||||
|
let changed = 0;
|
||||||
|
for (let pass = 0; pass < maxPasses; pass++) {
|
||||||
|
let passChanged = 0;
|
||||||
|
const counts = prefectureMunicipalityCounts(owner);
|
||||||
|
const candidates = [];
|
||||||
|
for (const [id, pref] of owner) {
|
||||||
|
if (pref === undefined || pref < 0) continue;
|
||||||
|
const node = nodes.get(id);
|
||||||
|
if (!node || !node.adjacent?.size) continue;
|
||||||
|
if ((node.cityPopulation || 0) >= 180000 || (node.majorCityCount || 0) > 0) continue;
|
||||||
|
let totalBoundary = 0;
|
||||||
|
let sameBoundary = 0;
|
||||||
|
const byPref = new Map();
|
||||||
|
for (const [nextId, edge] of node.adjacent) {
|
||||||
|
const nPref = owner.get(nextId);
|
||||||
|
if (nPref === undefined || nPref < 0) continue;
|
||||||
|
const w = Math.max(1, edge.count || 1);
|
||||||
|
totalBoundary += w;
|
||||||
|
if (nPref === pref) sameBoundary += w;
|
||||||
|
else {
|
||||||
|
const row = byPref.get(nPref) || { pref: nPref, shared: 0, score: 0, minCrossing: INF };
|
||||||
|
row.shared += w;
|
||||||
|
row.score += w * 2.8 - (edge.crossingCost ?? (1 + (edge.barrier || 0) * 8.0)) * 0.55;
|
||||||
|
row.minCrossing = Math.min(row.minCrossing, edge.crossingCost ?? 1);
|
||||||
|
byPref.set(nPref, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (totalBoundary <= 0) continue;
|
||||||
|
const borderBoundary = totalBoundary - sameBoundary;
|
||||||
|
const borderShare = borderBoundary / totalBoundary;
|
||||||
|
if (borderShare < threshold || !byPref.size) continue;
|
||||||
|
if ((counts.get(pref) || 0) <= Math.max(5, options.minSourceCount ?? 8)) continue;
|
||||||
|
if (!wouldRemainConnectedAfterRemoval(nodes, owner, id, pref)) continue;
|
||||||
|
const best = [...byPref.values()]
|
||||||
|
.filter((row) => row.shared >= minSharedToTarget)
|
||||||
|
.sort((a, b) => b.score - a.score || b.shared - a.shared || a.pref - b.pref)[0];
|
||||||
|
if (!best) continue;
|
||||||
|
const compactnessGain = best.shared - sameBoundary * 0.72 + borderShare * 6.0;
|
||||||
|
if (compactnessGain < 1.2 && best.score < 1.0) continue;
|
||||||
|
candidates.push({ id, from: pref, to: best.pref, borderShare, score: compactnessGain + best.score * 0.08 });
|
||||||
|
}
|
||||||
|
candidates.sort((a, b) => b.borderShare - a.borderShare || b.score - a.score || a.id - b.id);
|
||||||
|
const touched = new Set();
|
||||||
|
for (const cand of candidates) {
|
||||||
|
if (touched.has(cand.id) || owner.get(cand.id) !== cand.from) continue;
|
||||||
|
if (!wouldRemainConnectedAfterRemoval(nodes, owner, cand.id, cand.from)) continue;
|
||||||
|
owner.set(cand.id, cand.to);
|
||||||
|
touched.add(cand.id);
|
||||||
|
passChanged++;
|
||||||
|
}
|
||||||
|
if (!passChanged) break;
|
||||||
|
changed += passChanged;
|
||||||
|
repairPrefectureMunicipalityConnectivity(nodes, owner);
|
||||||
|
repairPrefectureMunicipalityEnclaves(nodes, owner, 6);
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lockPrefectureCapitalNeighborMunicipalities(owner, nodes, seeds = [], maxNeighbors = 6) {
|
||||||
|
let changed = 0;
|
||||||
|
const seedIds = new Set((seeds || []).map((node) => node?.id).filter((id) => id !== undefined));
|
||||||
|
for (const seedNode of seeds || []) {
|
||||||
|
if (!seedNode || !nodes.has(seedNode.id)) continue;
|
||||||
|
const prefId = owner.get(seedNode.id);
|
||||||
|
if (prefId === undefined || prefId < 0) continue;
|
||||||
|
const neighbors = [...(nodes.get(seedNode.id)?.adjacent || [])]
|
||||||
|
.map(([id, edge]) => ({ node: nodes.get(id), id, edge }))
|
||||||
|
.filter((row) => row.node && owner.get(row.id) !== prefId && !seedIds.has(row.id))
|
||||||
|
.sort((a, b) => (a.edge.crossingCost ?? 1) - (b.edge.crossingCost ?? 1) || Math.hypot(a.node.x - seedNode.x, a.node.y - seedNode.y) - Math.hypot(b.node.x - seedNode.x, b.node.y - seedNode.y));
|
||||||
|
let taken = 0;
|
||||||
|
for (const row of neighbors) {
|
||||||
|
if (taken >= maxNeighbors) break;
|
||||||
|
const donorPref = owner.get(row.id);
|
||||||
|
if (donorPref === undefined || donorPref < 0 || donorPref === prefId) continue;
|
||||||
|
if (!wouldRemainConnectedAfterRemoval(nodes, owner, row.id, donorPref)) continue;
|
||||||
|
owner.set(row.id, prefId);
|
||||||
|
changed++;
|
||||||
|
taken++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
repairPrefectureMunicipalityConnectivity(nodes, owner);
|
||||||
|
repairPrefectureMunicipalityEnclaves(nodes, owner, 6);
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
function averageRegionalBorderField(adminId, municipalityToPrefectureId, prefectureMask, sea, field) {
|
function averageRegionalBorderField(adminId, municipalityToPrefectureId, prefectureMask, sea, field) {
|
||||||
if (!field) return 0;
|
if (!field) return 0;
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
|
|
@ -803,6 +895,14 @@ export function generatePrefecturesFromMunicipalities(context, adminResult) {
|
||||||
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
||||||
changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 10);
|
changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 10);
|
||||||
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
||||||
|
const changedForCapitalNeighborLock = lockPrefectureCapitalNeighborMunicipalities(owner, graph.nodes, seeds, 7);
|
||||||
|
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
||||||
|
changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 12);
|
||||||
|
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
||||||
|
const changedForBoundaryShareRelaxation = relaxHighBoundaryShareMunicipalities(graph.nodes, owner, { threshold: 0.50, maxPasses: 6 });
|
||||||
|
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
||||||
|
changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 12);
|
||||||
|
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
||||||
const maxAdminId = Math.max(-1, ...[...graph.nodes.keys()]);
|
const maxAdminId = Math.max(-1, ...[...graph.nodes.keys()]);
|
||||||
const municipalityToPrefectureId = new Int16Array(maxAdminId + 1);
|
const municipalityToPrefectureId = new Int16Array(maxAdminId + 1);
|
||||||
municipalityToPrefectureId.fill(-1);
|
municipalityToPrefectureId.fill(-1);
|
||||||
|
|
@ -846,6 +946,8 @@ export function generatePrefecturesFromMunicipalities(context, adminResult) {
|
||||||
prefectureMunicipalityCountRebalanceChangedMunicipalities: (changedForMunicipalityCountRebalance || 0) + (changedForPostMergeMunicipalityCountRebalance || 0),
|
prefectureMunicipalityCountRebalanceChangedMunicipalities: (changedForMunicipalityCountRebalance || 0) + (changedForPostMergeMunicipalityCountRebalance || 0),
|
||||||
prefectureTinyMunicipalityCountMergeChangedMunicipalities: changedForTinyPrefectureCountMerge || 0,
|
prefectureTinyMunicipalityCountMergeChangedMunicipalities: changedForTinyPrefectureCountMerge || 0,
|
||||||
prefectureOversizedCountSplitChangedMunicipalities: changedForOversizedPrefectureSplit || 0,
|
prefectureOversizedCountSplitChangedMunicipalities: changedForOversizedPrefectureSplit || 0,
|
||||||
|
prefectureCapitalNeighborLockChangedMunicipalities: changedForCapitalNeighborLock || 0,
|
||||||
|
prefectureBoundaryShareRelaxationChangedMunicipalities: changedForBoundaryShareRelaxation || 0,
|
||||||
finalRegionalMaxMunicipalityCount: Math.max(...prefectureMunicipalityCounts(owner).values()),
|
finalRegionalMaxMunicipalityCount: Math.max(...prefectureMunicipalityCounts(owner).values()),
|
||||||
finalRegionalMunicipalityCountCap: 88,
|
finalRegionalMunicipalityCountCap: 88,
|
||||||
finalRegionalMinMunicipalityCount: Math.min(...prefectureMunicipalityCounts(owner).values()),
|
finalRegionalMinMunicipalityCount: Math.min(...prefectureMunicipalityCounts(owner).values()),
|
||||||
|
|
|
||||||
754
mapTerrain.js
754
mapTerrain.js
|
|
@ -5,6 +5,7 @@ import {
|
||||||
neighbors8,
|
neighbors8,
|
||||||
} from "./mapGeneratorHelpers.js";
|
} from "./mapGeneratorHelpers.js";
|
||||||
import { buildNaturalCompartments } from "./adminRegions.js";
|
import { buildNaturalCompartments } from "./adminRegions.js";
|
||||||
|
import { createRectContext, createRectTerrainFields, rectIndexOf, rectInside, rectNeighbors8, rectQuantile } from "./rectContext.js";
|
||||||
|
|
||||||
const ASPECT = MAP_W / MAP_H;
|
const ASPECT = MAP_W / MAP_H;
|
||||||
const SQRT2 = Math.SQRT2;
|
const SQRT2 = Math.SQRT2;
|
||||||
|
|
@ -168,13 +169,13 @@ const TERRAIN_TYPES = [
|
||||||
mountainOffsetRange: [0.47, 0.53],
|
mountainOffsetRange: [0.47, 0.53],
|
||||||
baseHeightRange: [0.56, 0.82],
|
baseHeightRange: [0.56, 0.82],
|
||||||
primaryLengthRange: [0.76, 0.96],
|
primaryLengthRange: [0.76, 0.96],
|
||||||
primaryWidthRange: [0.13, 0.22],
|
primaryWidthRange: [0.18, 0.30],
|
||||||
systemCountRange: [12, 16],
|
systemCountRange: [14, 18],
|
||||||
beltCountRange: [3, 4],
|
beltCountRange: [4, 5],
|
||||||
angleSpread: 0.14,
|
angleSpread: 0.18,
|
||||||
crossSpread: 0.38,
|
crossSpread: 0.58,
|
||||||
lengthScale: 1.22,
|
lengthScale: 1.22,
|
||||||
widthScale: 0.92,
|
widthScale: 1.16,
|
||||||
heightScale: 0.86,
|
heightScale: 0.86,
|
||||||
coastStrength: 0.90,
|
coastStrength: 0.90,
|
||||||
plainBiasRange: [0.16, 0.34],
|
plainBiasRange: [0.16, 0.34],
|
||||||
|
|
@ -206,6 +207,31 @@ const TERRAIN_TYPES = [
|
||||||
riverRichnessRange: [0.74, 1.14],
|
riverRichnessRange: [0.74, 1.14],
|
||||||
bigRiverChanceRange: [0.30, 0.60],
|
bigRiverChanceRange: [0.30, 0.60],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "oceanic_archipelago",
|
||||||
|
label: "海洋型・多島海",
|
||||||
|
weight: 0.16,
|
||||||
|
coastStyle: "oceanic_archipelago",
|
||||||
|
mountainMode: "mixed",
|
||||||
|
massifnessRange: [0.04, 0.28],
|
||||||
|
seaRatioRange: [0.72, 0.90],
|
||||||
|
twoSidedChance: 1.0,
|
||||||
|
mountainOffsetRange: [0.25, 0.55],
|
||||||
|
baseHeightRange: [0.30, 0.62],
|
||||||
|
primaryLengthRange: [0.20, 0.52],
|
||||||
|
primaryWidthRange: [0.08, 0.24],
|
||||||
|
systemCountRange: [7, 14],
|
||||||
|
beltCountRange: [2, 4],
|
||||||
|
angleSpread: 0.70,
|
||||||
|
crossSpread: 0.90,
|
||||||
|
lengthScale: 0.78,
|
||||||
|
widthScale: 0.82,
|
||||||
|
heightScale: 0.58,
|
||||||
|
coastStrength: 1.55,
|
||||||
|
plainBiasRange: [0.12, 0.34],
|
||||||
|
riverRichnessRange: [0.18, 0.52],
|
||||||
|
bigRiverChanceRange: [0.02, 0.12],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "setouchi_inland_sea",
|
id: "setouchi_inland_sea",
|
||||||
label: "瀬戸内型・内海多島",
|
label: "瀬戸内型・内海多島",
|
||||||
|
|
@ -283,7 +309,12 @@ const TERRAIN_TYPES = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function pickTerrainType(seed) {
|
function pickTerrainType(seed, requestedType = "auto") {
|
||||||
|
if (requestedType && requestedType !== "auto") {
|
||||||
|
const normalizedType = requestedType === "touhoku_spine" ? "tohoku_spine" : requestedType;
|
||||||
|
const selected = TERRAIN_TYPES.find((type) => type.id === normalizedType);
|
||||||
|
if (selected) return selected;
|
||||||
|
}
|
||||||
// Terrain type selection is intentionally uniform. Individual terrain
|
// Terrain type selection is intentionally uniform. Individual terrain
|
||||||
// templates still contain their own parameter ranges, but there is no
|
// templates still contain their own parameter ranges, but there is no
|
||||||
// terrain-type appearance weighting.
|
// terrain-type appearance weighting.
|
||||||
|
|
@ -299,8 +330,8 @@ function rangeInt(seed, salt, [lo, hi]) {
|
||||||
return Math.round(lo + rand(seed, salt) * (hi - lo));
|
return Math.round(lo + rand(seed, salt) * (hi - lo));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTerrainTemplate(seed) {
|
export function buildTerrainTemplate(seed, options = {}) {
|
||||||
const terrainType = pickTerrainType(seed);
|
const terrainType = pickTerrainType(seed, options.terrainType || options.generationType || "auto");
|
||||||
const mountainMode = terrainType.mountainMode === "mixed"
|
const mountainMode = terrainType.mountainMode === "mixed"
|
||||||
? (rand(seed, 12) < 0.42 ? "range" : rand(seed, 13) < 0.72 ? "mixed" : "massif")
|
? (rand(seed, 12) < 0.42 ? "range" : rand(seed, 13) < 0.72 ? "mixed" : "massif")
|
||||||
: terrainType.mountainMode;
|
: terrainType.mountainMode;
|
||||||
|
|
@ -579,7 +610,15 @@ function computeCoastLower(px, py, template, seed) {
|
||||||
const islandNoise = (fbm(px * 420 + 17, py * 420 - 11, seed + 302) - 0.5) * 0.032;
|
const islandNoise = (fbm(px * 420 + 17, py * 420 - 11, seed + 302) - 0.5) * 0.032;
|
||||||
let pressure = 0;
|
let pressure = 0;
|
||||||
|
|
||||||
if (template.coastStyle === "inland_sea") {
|
if (template.coastStyle === "oceanic_archipelago") {
|
||||||
|
// 海洋型: 外洋を強く取り、島列・湾・水道が点在する低標高圧を作る。
|
||||||
|
const radial = distNorm(px, py, 0.5, 0.5);
|
||||||
|
const outerOcean = smoothstep((radial - 0.30 + wave * 1.15 + bay * 0.85) / 0.18) * 0.96;
|
||||||
|
const diagonalChannel = smoothstep((0.13 - Math.abs(cross + wave * 0.82 + islandNoise * 0.70)) / 0.14) * 0.70;
|
||||||
|
const openSide = smoothstep((-axis + 0.12 + wave + bay) / 0.23) * 0.82;
|
||||||
|
const islandGaps = clamp((valueNoise(px * 780 + 31, py * 780 - 19, seed + 303, 20) - 0.42) * 1.25) * 0.22;
|
||||||
|
pressure = clamp(Math.max(outerOcean, diagonalChannel, openSide) + islandGaps);
|
||||||
|
} else if (template.coastStyle === "inland_sea") {
|
||||||
// 瀬戸内型は旧来の大きな内海+両岸海岸線に戻す。
|
// 瀬戸内型は旧来の大きな内海+両岸海岸線に戻す。
|
||||||
// 海面比率はテンプレート側で高めに保ち、微細な島ノイズではなく
|
// 海面比率はテンプレート側で高めに保ち、微細な島ノイズではなく
|
||||||
// 連続した水道形状で海を増やす。
|
// 連続した水道形状で海を増やす。
|
||||||
|
|
@ -1132,7 +1171,656 @@ function enforceLandGradient(elevation, sea, seaLevel) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateTerrainAndRivers(seed) {
|
function rectTerrainProfile(template) {
|
||||||
|
const id = String(template?.terrainType || "auto");
|
||||||
|
if (id.includes("oceanic")) return {
|
||||||
|
base: 0.36, relief: 0.19, ridge: 0.30, ridgeWidth: 22, ridgeSpacing: 76, coast: 0.34, archipelago: 0.28,
|
||||||
|
seaQuantile: Math.max(0.68, template.seaRatio ?? 0.76), plain: 0.20, moisture: 0.60, capStart: 0.64, capMax: 0.86,
|
||||||
|
};
|
||||||
|
if (id.includes("setouchi") || id.includes("archipelago")) return {
|
||||||
|
base: 0.43, relief: 0.18, ridge: 0.34, ridgeWidth: 30, ridgeSpacing: 94, coast: 0.25, archipelago: 0.20,
|
||||||
|
seaQuantile: Math.max(0.30, template.seaRatio ?? 0.36), plain: 0.34, moisture: 0.58, capStart: 0.72, capMax: 0.94,
|
||||||
|
};
|
||||||
|
if (id.includes("chubu") || id.includes("mountain")) return {
|
||||||
|
base: 0.52, relief: 0.26, ridge: 0.62, ridgeWidth: 36, ridgeSpacing: 108, coast: 0.12, archipelago: 0.03,
|
||||||
|
seaQuantile: Math.min(0.22, template.seaRatio ?? 0.18), plain: 0.15, moisture: 0.48, capStart: 0.90, capMax: 1.10,
|
||||||
|
};
|
||||||
|
if (id.includes("kanto") || id.includes("alluvial")) return {
|
||||||
|
base: 0.48, relief: 0.12, ridge: 0.18, ridgeWidth: 42, ridgeSpacing: 130, coast: 0.16, archipelago: 0.04,
|
||||||
|
seaQuantile: template.seaRatio ?? 0.13, plain: 0.66, moisture: 0.56, capStart: 0.84, capMax: 1.00,
|
||||||
|
};
|
||||||
|
if (id.includes("tohoku") || id.includes("spine")) return {
|
||||||
|
base: 0.49, relief: 0.19, ridge: 0.46, ridgeWidth: 24, ridgeSpacing: 88, coast: 0.18, archipelago: 0.03,
|
||||||
|
seaQuantile: template.seaRatio ?? 0.22, plain: 0.26, moisture: 0.52, capStart: 0.78, capMax: 0.98,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
base: 0.45, relief: 0.18, ridge: 0.34, ridgeWidth: 32, ridgeSpacing: 100, coast: 0.18, archipelago: 0.06,
|
||||||
|
seaQuantile: template.seaRatio ?? 0.20, plain: 0.30, moisture: 0.52, capStart: 0.86, capMax: 1.04,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rectSeed(seed, variant, salt) {
|
||||||
|
let h = (seed >>> 0) ^ Math.imul((variant || 0) >>> 0, 0x9e3779b9) ^ (salt >>> 0);
|
||||||
|
h ^= h >>> 16;
|
||||||
|
h = Math.imul(h, 0x7feb352d) >>> 0;
|
||||||
|
h ^= h >>> 15;
|
||||||
|
h = Math.imul(h, 0x846ca68b) >>> 0;
|
||||||
|
return (h ^ (h >>> 16)) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function periodicRidgeField(wx, wy, template, profile, seed) {
|
||||||
|
const angle = template.mountainAngle || 0;
|
||||||
|
const c = Math.cos(angle);
|
||||||
|
const s = Math.sin(angle);
|
||||||
|
const u = wx * c + wy * s;
|
||||||
|
const v = -wx * s + wy * c;
|
||||||
|
const spacing = Math.max(18, profile.ridgeSpacing);
|
||||||
|
const shifted = v / spacing + valueNoise(wx, wy, seed ^ 0x654f6d23, 115) * 0.70;
|
||||||
|
const nearest = Math.abs((shifted - Math.round(shifted)) * spacing);
|
||||||
|
const ridgeCore = Math.exp(-Math.pow(nearest / Math.max(4, profile.ridgeWidth), 2.0));
|
||||||
|
const along = valueNoise(u, v, seed ^ 0x27d4eb2f, 86);
|
||||||
|
const cut = valueNoise(u, v, seed ^ 0x165667b1, 31);
|
||||||
|
return clamp(ridgeCore * (0.62 + along * 0.62) * (0.74 + cut * 0.40));
|
||||||
|
}
|
||||||
|
|
||||||
|
function worldMarinePressure(wx, wy, template, profile, seed) {
|
||||||
|
const angle = template.coastAngle || 0;
|
||||||
|
const c = Math.cos(angle);
|
||||||
|
const s = Math.sin(angle);
|
||||||
|
const axis = wx * c + wy * s;
|
||||||
|
const cross = -wx * s + wy * c;
|
||||||
|
const period = template.coastStyle === "oceanic_archipelago" ? 160 : template.coastStyle === "inland_sea" ? 220 : 300;
|
||||||
|
const broad = Math.sin((axis + valueNoise(wx, wy, seed ^ 0xc2b2ae35, 190) * 90) / period * Math.PI * 2);
|
||||||
|
const channel = Math.exp(-Math.pow((cross + (valueNoise(wx, wy, seed ^ 0x85ebca6b, 130) - 0.5) * 80) / (profile.ridgeSpacing * 0.85), 2.0));
|
||||||
|
const radial = valueNoise(wx, wy, seed ^ 0x9e3779b9, 260);
|
||||||
|
let pressure = clamp((broad * 0.5 + 0.5) * profile.coast + channel * profile.coast * 0.62 + radial * profile.coast * 0.52);
|
||||||
|
if (template.coastStyle === "oceanic_archipelago") {
|
||||||
|
const gap = clamp((fbm(wx * 0.75 + 33, wy * 0.75 - 17, seed ^ 0x3c6ef372) - 0.42) * 2.2);
|
||||||
|
pressure = clamp(pressure + gap * profile.archipelago);
|
||||||
|
}
|
||||||
|
if (template.coastStyle === "open_bay") pressure = clamp(pressure + channel * 0.12);
|
||||||
|
return pressure;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyRectWater(ctx, fields, seaLevel) {
|
||||||
|
const { elevation, sea, ocean, lake } = fields;
|
||||||
|
sea.fill(0); ocean.fill(0); lake.fill(0);
|
||||||
|
const water = new Uint8Array(ctx.size);
|
||||||
|
for (let i = 0; i < ctx.size; i++) water[i] = elevation[i] <= seaLevel ? 1 : 0;
|
||||||
|
const seen = new Uint8Array(ctx.size);
|
||||||
|
let oceanCells = 0;
|
||||||
|
for (let i = 0; i < ctx.size; i++) {
|
||||||
|
if (!water[i] || seen[i]) continue;
|
||||||
|
const queue = [i];
|
||||||
|
const cells = [];
|
||||||
|
let touchesEdge = false;
|
||||||
|
seen[i] = 1;
|
||||||
|
for (let q = 0; q < queue.length; q++) {
|
||||||
|
const cur = queue[q];
|
||||||
|
cells.push(cur);
|
||||||
|
const x = cur % ctx.width;
|
||||||
|
const y = Math.floor(cur / ctx.width);
|
||||||
|
if (x === 0 || y === 0 || x === ctx.width - 1 || y === ctx.height - 1) touchesEdge = true;
|
||||||
|
for (const [nx, ny] of rectNeighbors8(ctx, x, y)) {
|
||||||
|
const ni = rectIndexOf(ctx, nx, ny);
|
||||||
|
if (!water[ni] || seen[ni]) continue;
|
||||||
|
seen[ni] = 1;
|
||||||
|
queue.push(ni);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const isOcean = touchesEdge || cells.length > Math.max(96, ctx.size * 0.018);
|
||||||
|
if (isOcean || cells.length >= 20) {
|
||||||
|
for (const ci of cells) {
|
||||||
|
sea[ci] = 1;
|
||||||
|
if (isOcean) ocean[ci] = 1;
|
||||||
|
else lake[ci] = 1;
|
||||||
|
}
|
||||||
|
if (isOcean) oceanCells += cells.length;
|
||||||
|
} else {
|
||||||
|
for (const ci of cells) elevation[ci] = seaLevel + 0.012;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return oceanCells;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recomputeRectSlope(ctx, fields) {
|
||||||
|
const { elevation, sea, slope } = fields;
|
||||||
|
slope.fill(0);
|
||||||
|
for (let y = 1; y < ctx.height - 1; y++) {
|
||||||
|
for (let x = 1; x < ctx.width - 1; x++) {
|
||||||
|
const i = rectIndexOf(ctx, x, y);
|
||||||
|
if (sea[i]) continue;
|
||||||
|
const gx = elevation[rectIndexOf(ctx, x + 1, y)] - elevation[rectIndexOf(ctx, x - 1, y)];
|
||||||
|
const gy = elevation[rectIndexOf(ctx, x, y + 1)] - elevation[rectIndexOf(ctx, x, y - 1)];
|
||||||
|
slope[i] = clamp(Math.hypot(gx, gy) * 8.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function priorityFloodRect(ctx, fields) {
|
||||||
|
const { elevation, sea, flowTo } = fields;
|
||||||
|
const filled = new Float32Array(elevation);
|
||||||
|
const visited = new Uint8Array(ctx.size);
|
||||||
|
const heap = new MinHeap();
|
||||||
|
let seeds = 0;
|
||||||
|
for (let i = 0; i < ctx.size; i++) {
|
||||||
|
const x = i % ctx.width;
|
||||||
|
const y = Math.floor(i / ctx.width);
|
||||||
|
if (sea[i] || x === 0 || y === 0 || x === ctx.width - 1 || y === ctx.height - 1) {
|
||||||
|
visited[i] = 1;
|
||||||
|
heap.push({ i, f: filled[i] });
|
||||||
|
seeds++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!seeds) return filled;
|
||||||
|
while (heap.length) {
|
||||||
|
const cur = heap.pop();
|
||||||
|
if (!cur || cur.f > filled[cur.i] + 1e-5) continue;
|
||||||
|
const x = cur.i % ctx.width;
|
||||||
|
const y = Math.floor(cur.i / ctx.width);
|
||||||
|
for (const [nx, ny] of rectNeighbors8(ctx, x, y)) {
|
||||||
|
const ni = rectIndexOf(ctx, nx, ny);
|
||||||
|
if (visited[ni]) continue;
|
||||||
|
visited[ni] = 1;
|
||||||
|
if (filled[ni] < filled[cur.i] + 0.00002) filled[ni] = filled[cur.i] + 0.00002;
|
||||||
|
heap.push({ i: ni, f: filled[ni] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flowTo.fill(-1);
|
||||||
|
for (let y = 0; y < ctx.height; y++) {
|
||||||
|
for (let x = 0; x < ctx.width; x++) {
|
||||||
|
const i = rectIndexOf(ctx, x, y);
|
||||||
|
if (sea[i]) continue;
|
||||||
|
let best = -1;
|
||||||
|
let bestScore = filled[i];
|
||||||
|
for (const [nx, ny] of rectNeighbors8(ctx, x, y)) {
|
||||||
|
const ni = rectIndexOf(ctx, nx, ny);
|
||||||
|
const stepPenalty = (nx !== x && ny !== y) ? 0.000015 : 0;
|
||||||
|
const score = filled[ni] + stepPenalty + hash2(ctx.originX + nx, ctx.originY + ny, 9000) * 0.000002;
|
||||||
|
if (score < bestScore - 0.000001 || sea[ni]) {
|
||||||
|
bestScore = score;
|
||||||
|
best = ni;
|
||||||
|
if (sea[ni]) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flowTo[i] = best;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeRectFlowAccumulation(ctx, fields, filled) {
|
||||||
|
const { sea, flowTo, flowAccum } = fields;
|
||||||
|
const area = new Float32Array(ctx.size);
|
||||||
|
const order = [];
|
||||||
|
for (let i = 0; i < ctx.size; i++) {
|
||||||
|
if (sea[i]) continue;
|
||||||
|
area[i] = 1;
|
||||||
|
order.push(i);
|
||||||
|
}
|
||||||
|
order.sort((a, b) => filled[b] - filled[a]);
|
||||||
|
for (const i of order) {
|
||||||
|
const to = flowTo[i];
|
||||||
|
if (to >= 0 && !sea[to]) area[to] += area[i];
|
||||||
|
}
|
||||||
|
let maxArea = 1;
|
||||||
|
for (let i = 0; i < ctx.size; i++) if (!sea[i]) maxArea = Math.max(maxArea, area[i]);
|
||||||
|
for (let i = 0; i < ctx.size; i++) flowAccum[i] = sea[i] ? 0 : clamp(Math.pow(area[i] / maxArea, 0.42));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rectStableId(seed, wx, wy, salt) {
|
||||||
|
const x = Math.floor(wx) | 0;
|
||||||
|
const y = Math.floor(wy) | 0;
|
||||||
|
let h = (seed >>> 0) ^ Math.imul(x, 0x9e3779b1) ^ Math.imul(y, 0x85ebca77) ^ (salt >>> 0);
|
||||||
|
h ^= h >>> 16;
|
||||||
|
h = Math.imul(h, 0x7feb352d) >>> 0;
|
||||||
|
h ^= h >>> 15;
|
||||||
|
h = Math.imul(h, 0x846ca68b) >>> 0;
|
||||||
|
return (h ^ (h >>> 16)) & 0x7fffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
function traceRectSink(ctx, start, fields, maxSteps = 4096) {
|
||||||
|
const { sea, flowTo } = fields;
|
||||||
|
let i = start;
|
||||||
|
let last = i;
|
||||||
|
const seen = new Set();
|
||||||
|
for (let step = 0; step < maxSteps; step++) {
|
||||||
|
if (i < 0 || i >= ctx.size || seen.has(i)) break;
|
||||||
|
seen.add(i);
|
||||||
|
last = i;
|
||||||
|
if (sea[i]) break;
|
||||||
|
const next = flowTo[i];
|
||||||
|
if (next < 0 || next === i) break;
|
||||||
|
i = next;
|
||||||
|
}
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRectWatershedId(ctx, fields, seed) {
|
||||||
|
const { sea, flowAccum, watershedId } = fields;
|
||||||
|
if (!watershedId) return { watershedCount: 0 };
|
||||||
|
watershedId.fill(-1);
|
||||||
|
const sinkToId = new Map();
|
||||||
|
let watershedCount = 0;
|
||||||
|
for (let i = 0; i < ctx.size; i++) {
|
||||||
|
if (sea[i]) continue;
|
||||||
|
const sink = traceRectSink(ctx, i, fields);
|
||||||
|
const sx = sink % ctx.width;
|
||||||
|
const sy = Math.floor(sink / ctx.width);
|
||||||
|
const wx = ctx.originX + sx;
|
||||||
|
const wy = ctx.originY + sy;
|
||||||
|
const coarseX = Math.round(wx / 12);
|
||||||
|
const coarseY = Math.round(wy / 12);
|
||||||
|
const key = `${coarseX},${coarseY}`;
|
||||||
|
let id = sinkToId.get(key);
|
||||||
|
if (!Number.isFinite(id)) {
|
||||||
|
id = 50000000 + rectStableId(seed, coarseX, coarseY, 0x51ed270b) % 40000000;
|
||||||
|
sinkToId.set(key, id);
|
||||||
|
watershedCount++;
|
||||||
|
}
|
||||||
|
watershedId[i] = id;
|
||||||
|
}
|
||||||
|
// Merge tiny or noisy drainage islands into their strongest neighbor.
|
||||||
|
for (let pass = 0; pass < 2; pass++) {
|
||||||
|
const changes = [];
|
||||||
|
for (let y = 1; y < ctx.height - 1; y++) {
|
||||||
|
for (let x = 1; x < ctx.width - 1; x++) {
|
||||||
|
const i = rectIndexOf(ctx, x, y);
|
||||||
|
if (sea[i] || watershedId[i] < 0) continue;
|
||||||
|
const counts = new Map();
|
||||||
|
for (const [nx, ny] of rectNeighbors8(ctx, x, y)) {
|
||||||
|
const ni = rectIndexOf(ctx, nx, ny);
|
||||||
|
const id = watershedId[ni];
|
||||||
|
if (id < 0) continue;
|
||||||
|
counts.set(id, (counts.get(id) || 0) + 1 + (flowAccum[ni] || 0));
|
||||||
|
}
|
||||||
|
let best = watershedId[i];
|
||||||
|
let bestScore = counts.get(best) || 0;
|
||||||
|
for (const [id, score] of counts) if (score > bestScore) { best = id; bestScore = score; }
|
||||||
|
if (best !== watershedId[i] && bestScore >= 5.5) changes.push([i, best]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [i, id] of changes) watershedId[i] = id;
|
||||||
|
if (!changes.length) break;
|
||||||
|
}
|
||||||
|
return { watershedCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRectNaturalRegions(ctx, fields, seed, template) {
|
||||||
|
const { sea, ridgeField, valleyField, basinField, flowAccum, naturalBarrierScore, watershedId, naturalCompartmentId, regionId } = fields;
|
||||||
|
if (!naturalCompartmentId || !regionId) return { naturalCompartmentCount: 0, regionCount: 0 };
|
||||||
|
naturalCompartmentId.fill(-1);
|
||||||
|
regionId.fill(-1);
|
||||||
|
const type = String(template?.terrainType || "auto");
|
||||||
|
const spacing = type.includes("oceanic") ? 30 : type.includes("kanto") ? 44 : type.includes("chubu") ? 34 : 38;
|
||||||
|
const coarseSpacing = spacing * 2.55;
|
||||||
|
const seeds = [];
|
||||||
|
const gx0 = Math.floor((ctx.originX - spacing) / spacing) - 1;
|
||||||
|
const gx1 = Math.ceil((ctx.originX + ctx.width + spacing) / spacing) + 1;
|
||||||
|
const gy0 = Math.floor((ctx.originY - spacing) / spacing) - 1;
|
||||||
|
const gy1 = Math.ceil((ctx.originY + ctx.height + spacing) / spacing) + 1;
|
||||||
|
for (let gy = gy0; gy <= gy1; gy++) {
|
||||||
|
for (let gx = gx0; gx <= gx1; gx++) {
|
||||||
|
const jitterX = (hash2(gx, gy, seed ^ 0x6a09e667) - 0.5) * spacing * 0.74;
|
||||||
|
const jitterY = (hash2(gx, gy, seed ^ 0xbb67ae85) - 0.5) * spacing * 0.74;
|
||||||
|
const wx = gx * spacing + spacing * 0.5 + jitterX;
|
||||||
|
const wy = gy * spacing + spacing * 0.5 + jitterY;
|
||||||
|
const lx = Math.round(wx - ctx.originX);
|
||||||
|
const ly = Math.round(wy - ctx.originY);
|
||||||
|
let viability = 0.8;
|
||||||
|
if (rectInside(ctx, lx, ly)) {
|
||||||
|
const i = rectIndexOf(ctx, lx, ly);
|
||||||
|
viability += (basinField[i] || 0) * 0.25 + (valleyField[i] || 0) * 0.16 - (ridgeField[i] || 0) * 0.14;
|
||||||
|
if (sea[i]) viability -= 1.2;
|
||||||
|
}
|
||||||
|
if (viability < 0.18 && hash2(gx, gy, seed ^ 0x3c6ef372) < 0.82) continue;
|
||||||
|
seeds.push({
|
||||||
|
wx,
|
||||||
|
wy,
|
||||||
|
id: 40000000 + rectStableId(seed, gx, gy, 0xb5c0fbcf) % 42000000,
|
||||||
|
coarseId: 30000000 + rectStableId(seed, Math.floor((gx * spacing) / coarseSpacing), Math.floor((gy * spacing) / coarseSpacing), 0xc2b2ae35) % 42000000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!seeds.length) return { naturalCompartmentCount: 0, regionCount: 0 };
|
||||||
|
for (let y = 0; y < ctx.height; y++) {
|
||||||
|
for (let x = 0; x < ctx.width; x++) {
|
||||||
|
const i = rectIndexOf(ctx, x, y);
|
||||||
|
if (sea[i]) continue;
|
||||||
|
const wx = ctx.originX + x;
|
||||||
|
const wy = ctx.originY + y;
|
||||||
|
let best = seeds[0];
|
||||||
|
let bestScore = Infinity;
|
||||||
|
const barrier = (naturalBarrierScore[i] || 0) + (ridgeField[i] || 0) * 0.55 + (flowAccum[i] || 0) * 0.18;
|
||||||
|
const basinBonus = (basinField[i] || 0) * 0.18 + (valleyField[i] || 0) * 0.10;
|
||||||
|
for (const s of seeds) {
|
||||||
|
const dx = (wx - s.wx) * 1.05;
|
||||||
|
const dy = wy - s.wy;
|
||||||
|
const d = Math.hypot(dx, dy);
|
||||||
|
const tileNoise = (valueNoise(wx + s.wx * 0.13, wy + s.wy * 0.13, seed ^ 0xa54ff53a, 52) - 0.5) * spacing * 0.34;
|
||||||
|
const watershedPenalty = watershedId?.[i] >= 0 ? ((watershedId[i] ^ s.id) & 7) * 0.16 : 0;
|
||||||
|
const score = d + barrier * spacing * 0.42 - basinBonus * spacing * 0.32 + tileNoise + watershedPenalty;
|
||||||
|
if (score < bestScore) { bestScore = score; best = s; }
|
||||||
|
}
|
||||||
|
naturalCompartmentId[i] = best.id;
|
||||||
|
regionId[i] = best.coarseId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let pass = 0; pass < 2; pass++) {
|
||||||
|
const changes = [];
|
||||||
|
for (let y = 1; y < ctx.height - 1; y++) {
|
||||||
|
for (let x = 1; x < ctx.width - 1; x++) {
|
||||||
|
const i = rectIndexOf(ctx, x, y);
|
||||||
|
if (sea[i]) continue;
|
||||||
|
if ((ridgeField[i] || 0) > 0.78) continue;
|
||||||
|
const counts = new Map();
|
||||||
|
for (const [nx, ny] of rectNeighbors8(ctx, x, y)) {
|
||||||
|
const ni = rectIndexOf(ctx, nx, ny);
|
||||||
|
const id = naturalCompartmentId[ni];
|
||||||
|
if (id < 0) continue;
|
||||||
|
counts.set(id, (counts.get(id) || 0) + 1 + (basinField[ni] || 0) * 0.3);
|
||||||
|
}
|
||||||
|
let best = naturalCompartmentId[i];
|
||||||
|
let bestScore = counts.get(best) || 0;
|
||||||
|
for (const [id, score] of counts) if (score > bestScore) { best = id; bestScore = score; }
|
||||||
|
if (best !== naturalCompartmentId[i] && bestScore >= 5.8) changes.push([i, best]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [i, id] of changes) naturalCompartmentId[i] = id;
|
||||||
|
if (!changes.length) break;
|
||||||
|
}
|
||||||
|
const nset = new Set();
|
||||||
|
const rset = new Set();
|
||||||
|
for (let i = 0; i < ctx.size; i++) {
|
||||||
|
if (naturalCompartmentId[i] >= 0) nset.add(naturalCompartmentId[i]);
|
||||||
|
if (regionId[i] >= 0) rset.add(regionId[i]);
|
||||||
|
}
|
||||||
|
return { naturalCompartmentCount: nset.size, regionCount: rset.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
function traceRectFlowPath(start, ctx, fields, maxSteps = 1200) {
|
||||||
|
const { sea, flowTo } = fields;
|
||||||
|
let i = start;
|
||||||
|
const path = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (let step = 0; step < maxSteps; step++) {
|
||||||
|
if (i < 0 || i >= ctx.size || seen.has(i)) break;
|
||||||
|
seen.add(i);
|
||||||
|
const x = i % ctx.width;
|
||||||
|
const y = Math.floor(i / ctx.width);
|
||||||
|
path.push([ctx.originX + x, ctx.originY + y]);
|
||||||
|
if (sea[i]) break;
|
||||||
|
const next = flowTo[i];
|
||||||
|
if (next < 0 || next === i) break;
|
||||||
|
i = next;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreRectRiverPath(path, ctx, fields) {
|
||||||
|
let score = 0;
|
||||||
|
for (const [wx, wy] of path) {
|
||||||
|
const x = wx - ctx.originX;
|
||||||
|
const y = wy - ctx.originY;
|
||||||
|
if (!rectInside(ctx, x, y)) continue;
|
||||||
|
const i = rectIndexOf(ctx, x, y);
|
||||||
|
score += (fields.flowAccum[i] || 0) + (fields.river[i] || 0) * 0.7;
|
||||||
|
}
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRectRiverPaths(ctx, fields, seed, template) {
|
||||||
|
const { sea, flowAccum, river, erosionField } = fields;
|
||||||
|
const candidates = [];
|
||||||
|
const threshold = template?.terrainType === "oceanic_archipelago" ? 0.52 : template?.terrainType === "kanto_alluvial" ? 0.46 : 0.50;
|
||||||
|
for (let i = 0; i < ctx.size; i++) {
|
||||||
|
if (sea[i] || flowAccum[i] < threshold) continue;
|
||||||
|
const x = i % ctx.width;
|
||||||
|
const y = Math.floor(i / ctx.width);
|
||||||
|
let upstream = 0;
|
||||||
|
for (const [nx, ny] of rectNeighbors8(ctx, x, y)) {
|
||||||
|
const ni = rectIndexOf(ctx, nx, ny);
|
||||||
|
if (fields.flowTo[ni] === i) upstream++;
|
||||||
|
}
|
||||||
|
const sourceBias = hash2(ctx.originX + x, ctx.originY + y, seed ^ 0x1f123bb5);
|
||||||
|
if (upstream <= 1 || sourceBias > 0.78) candidates.push({ i, score: flowAccum[i] + sourceBias * 0.12 });
|
||||||
|
}
|
||||||
|
candidates.sort((a, b) => b.score - a.score);
|
||||||
|
const accepted = [];
|
||||||
|
const occupied = new Set();
|
||||||
|
const desired = Math.min(72, Math.max(8, Math.floor(ctx.size / 900)));
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (accepted.length >= desired) break;
|
||||||
|
const path = traceRectFlowPath(c.i, ctx, fields);
|
||||||
|
if (path.length < 8) continue;
|
||||||
|
const keyHits = path.reduce((n, [wx, wy], k) => k % 3 === 0 && occupied.has(`${wx},${wy}`) ? n + 1 : n, 0);
|
||||||
|
if (keyHits > Math.max(5, path.length * 0.18)) continue;
|
||||||
|
const score = scoreRectRiverPath(path, ctx, fields);
|
||||||
|
if (score < 4.2) continue;
|
||||||
|
accepted.push({ path, score });
|
||||||
|
for (const [wx, wy] of path) occupied.add(`${wx},${wy}`);
|
||||||
|
}
|
||||||
|
accepted.sort((a, b) => b.score - a.score);
|
||||||
|
const mainRivers = accepted.slice(0, Math.max(1, Math.min(10, Math.round(accepted.length * 0.25)))).map((r) => r.path);
|
||||||
|
const tributaryRivers = accepted.slice(mainRivers.length, mainRivers.length + 28).map((r) => r.path);
|
||||||
|
const smallStreams = accepted.slice(mainRivers.length + 28, mainRivers.length + 56).map((r) => r.path);
|
||||||
|
for (const group of [mainRivers, tributaryRivers, smallStreams]) {
|
||||||
|
const boost = group === mainRivers ? 0.72 : group === tributaryRivers ? 0.48 : 0.28;
|
||||||
|
for (const path of group) {
|
||||||
|
for (const [wx, wy] of path) {
|
||||||
|
const x = wx - ctx.originX;
|
||||||
|
const y = wy - ctx.originY;
|
||||||
|
if (!rectInside(ctx, x, y)) continue;
|
||||||
|
const i = rectIndexOf(ctx, x, y);
|
||||||
|
if (sea[i]) continue;
|
||||||
|
river[i] = clamp(Math.max(river[i], boost + (flowAccum[i] || 0) * 0.42));
|
||||||
|
if (erosionField) erosionField[i] = clamp((erosionField[i] || 0) + river[i] * 0.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { riverPaths: accepted.map((r) => r.path), mainRivers, tributaryRivers, smallStreams };
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveRectTerrainFields(ctx, fields, seaLevel) {
|
||||||
|
const {
|
||||||
|
elevation, sea, river, flowAccum, floodplain, plain, agriculture, ridgeField, valleyField, basinField,
|
||||||
|
coastalLowland, erosionField, depositionField, depositionalLowland, alluvialFanField, deltaField,
|
||||||
|
naturalBarrierScore, portSuitability, crossingSuitability, passSuitability, slope, moisture,
|
||||||
|
} = fields;
|
||||||
|
for (let i = 0; i < ctx.size; i++) {
|
||||||
|
if (sea[i]) {
|
||||||
|
river[i] = 0; plain[i] = 0; agriculture[i] = 0; naturalBarrierScore[i] = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const low = clamp((0.48 - elevation[i]) * 2.2);
|
||||||
|
const flat = clamp(1 - slope[i] * 2.3);
|
||||||
|
const coast = clamp((elevation[i] - seaLevel) * 18);
|
||||||
|
river[i] = flowAccum[i] > 0.58 ? clamp((flowAccum[i] - 0.52) * 2.1 + (0.22 - slope[i]) * 0.40) : 0;
|
||||||
|
floodplain[i] = clamp(river[i] * 0.72 + low * flat * 0.24);
|
||||||
|
plain[i] = clamp(flat * (low * 0.78 + basinField[i] * 0.38 + floodplain[i] * 0.35));
|
||||||
|
agriculture[i] = clamp(plain[i] * 0.72 + moisture[i] * 0.22 - slope[i] * 0.22);
|
||||||
|
coastalLowland[i] = clamp((1 - coast) * flat * 0.90);
|
||||||
|
erosionField[i] = clamp(slope[i] * 0.55 + river[i] * 0.34 + ridgeField[i] * 0.22);
|
||||||
|
depositionField[i] = clamp(floodplain[i] * 0.58 + coastalLowland[i] * 0.34 + plain[i] * 0.18);
|
||||||
|
depositionalLowland[i] = clamp(depositionField[i] * flat);
|
||||||
|
alluvialFanField[i] = clamp(river[i] * slope[i] * 1.8);
|
||||||
|
deltaField[i] = clamp(river[i] * coastalLowland[i] * 1.2);
|
||||||
|
naturalBarrierScore[i] = clamp(ridgeField[i] * 0.72 + slope[i] * 0.42 + river[i] * 0.24);
|
||||||
|
crossingSuitability[i] = clamp(flat * (1 - river[i] * 0.65) + plain[i] * 0.24);
|
||||||
|
passSuitability[i] = clamp((1 - ridgeField[i]) * 0.55 + valleyField[i] * 0.40 - slope[i] * 0.15);
|
||||||
|
portSuitability[i] = clamp(coastalLowland[i] * 0.65 + plain[i] * 0.22 - slope[i] * 0.26);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateTerrainRect(options = {}) {
|
||||||
|
const seed = Number.isFinite(options.seed) ? options.seed >>> 0 : 0;
|
||||||
|
const variant = Number.isFinite(options.variant) ? Math.max(0, Math.floor(options.variant)) >>> 0 : 0;
|
||||||
|
const ctx = options.rectContext || createRectContext(options);
|
||||||
|
const rectSeedValue = rectSeed(seed, variant, 0x5489a1f3);
|
||||||
|
const terrainTemplate = buildTerrainTemplate(rectSeedValue, options);
|
||||||
|
const profile = rectTerrainProfile(terrainTemplate);
|
||||||
|
const fields = createRectTerrainFields(ctx);
|
||||||
|
const {
|
||||||
|
elevation, moisture, ridgeField, valleyField, basinField, coastalLowland, arcSpineField, branchRidgeField,
|
||||||
|
visibleRavineField, surfaceTextureField,
|
||||||
|
} = fields;
|
||||||
|
|
||||||
|
for (let y = 0; y < ctx.height; y++) {
|
||||||
|
for (let x = 0; x < ctx.width; x++) {
|
||||||
|
const i = rectIndexOf(ctx, x, y);
|
||||||
|
const wx = ctx.originX + x;
|
||||||
|
const wy = ctx.originY + y;
|
||||||
|
const broad = (fbm(wx * 0.58, wy * 0.58, rectSeedValue ^ 0x9e3779b9) - 0.5) * profile.relief;
|
||||||
|
const regional = (valueNoise(wx, wy, rectSeedValue ^ 0x85ebca6b, 58) - 0.5) * profile.relief * 0.72;
|
||||||
|
const detail = (valueNoise(wx, wy, rectSeedValue ^ 0xc2b2ae35, 19) - 0.5) * profile.relief * 0.22;
|
||||||
|
const ridge = periodicRidgeField(wx, wy, terrainTemplate, profile, rectSeedValue ^ 0x27d4eb2f);
|
||||||
|
const marine = worldMarinePressure(wx, wy, terrainTemplate, profile, rectSeedValue ^ 0x165667b1);
|
||||||
|
const basin = clamp((valueNoise(wx, wy, rectSeedValue ^ 0xd3a2646c, 120) - 0.36) * 1.65) * profile.plain;
|
||||||
|
const valley = clamp((1 - ridge) * (valueNoise(wx, wy, rectSeedValue ^ 0xfd7046c5, 42) - 0.42) * 1.7);
|
||||||
|
const archipelago = terrainTemplate.terrainType === "oceanic_archipelago" || terrainTemplate.coastStyle === "inland_sea"
|
||||||
|
? clamp((fbm(wx * 0.72 + 49, wy * 0.72 - 31, rectSeedValue ^ 0x94d049bb) - 0.44) * 2.2) * profile.archipelago
|
||||||
|
: 0;
|
||||||
|
let e = profile.base + broad + regional + detail + ridge * profile.ridge + archipelago - marine + basin * 0.10;
|
||||||
|
if (terrainTemplate.terrainType === "kanto_alluvial") e -= basin * 0.075;
|
||||||
|
if (terrainTemplate.terrainType === "oceanic_archipelago") e -= marine * 0.16;
|
||||||
|
e = softCapElevation(e, profile.capStart, profile.capMax);
|
||||||
|
elevation[i] = clamp(e, 0.025, profile.capMax);
|
||||||
|
ridgeField[i] = clamp(ridge * (0.62 + profile.ridge));
|
||||||
|
branchRidgeField[i] = clamp(ridge * 0.82 + detail * 0.60);
|
||||||
|
arcSpineField[i] = clamp(ridge * 0.90);
|
||||||
|
valleyField[i] = clamp(valley + (1 - ridge) * marine * 0.20);
|
||||||
|
basinField[i] = clamp(basin + valley * 0.35);
|
||||||
|
coastalLowland[i] = clamp(marine * 0.82 + basin * 0.25);
|
||||||
|
moisture[i] = clamp(profile.moisture + marine * 0.22 + basin * 0.15 - elevation[i] * 0.22 + (fbm(wx * 0.85, wy * 0.85, rectSeedValue ^ 0xa0761d65) - 0.5) * 0.13);
|
||||||
|
visibleRavineField[i] = clamp(Math.abs(detail) * ridge * 1.9 + valley * 0.25);
|
||||||
|
surfaceTextureField[i] = clamp(Math.abs(broad) * 0.55 + Math.abs(detail) * 1.3 + ridge * 0.22);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seaLevel = clamp(Number.isFinite(options.seaLevel) ? options.seaLevel : rectQuantile(elevation, profile.seaQuantile), 0.13, 0.50);
|
||||||
|
const oceanCells = classifyRectWater(ctx, fields, seaLevel);
|
||||||
|
recomputeRectSlope(ctx, fields);
|
||||||
|
const filled = priorityFloodRect(ctx, fields);
|
||||||
|
computeRectFlowAccumulation(ctx, fields, filled);
|
||||||
|
const watershedDebug = buildRectWatershedId(ctx, fields, rectSeedValue ^ 0x51ed270b);
|
||||||
|
deriveRectTerrainFields(ctx, fields, seaLevel);
|
||||||
|
const riverNetwork = buildRectRiverPaths(ctx, fields, rectSeedValue ^ 0x1f123bb5, terrainTemplate);
|
||||||
|
const naturalDebug = buildRectNaturalRegions(ctx, fields, rectSeedValue ^ 0xb5c0fbcf, terrainTemplate);
|
||||||
|
|
||||||
|
let landCount = 0;
|
||||||
|
let mountainCount = 0;
|
||||||
|
let plainCount = 0;
|
||||||
|
for (let i = 0; i < ctx.size; i++) {
|
||||||
|
if (fields.sea[i]) continue;
|
||||||
|
landCount++;
|
||||||
|
if (fields.elevation[i] > 0.56 || fields.ridgeField[i] > 0.52) mountainCount++;
|
||||||
|
if (fields.plain[i] > 0.36) plainCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rectContext: ctx,
|
||||||
|
originX: ctx.originX,
|
||||||
|
originY: ctx.originY,
|
||||||
|
width: ctx.width,
|
||||||
|
height: ctx.height,
|
||||||
|
size: ctx.size,
|
||||||
|
terrainTemplate,
|
||||||
|
seaLevel,
|
||||||
|
...fields,
|
||||||
|
terrainDebug: {
|
||||||
|
terrainType: terrainTemplate.terrainType,
|
||||||
|
terrainTypeLabel: terrainTemplate.terrainTypeLabel,
|
||||||
|
coastStyle: terrainTemplate.coastStyle,
|
||||||
|
rectNative: true,
|
||||||
|
originX: ctx.originX,
|
||||||
|
originY: ctx.originY,
|
||||||
|
width: ctx.width,
|
||||||
|
height: ctx.height,
|
||||||
|
variant,
|
||||||
|
seaRatio: fields.sea.reduce((sum, value) => sum + value, 0) / Math.max(1, ctx.size),
|
||||||
|
landCount,
|
||||||
|
oceanCells,
|
||||||
|
mountainRatio: mountainCount / Math.max(1, landCount),
|
||||||
|
plainRatio: plainCount / Math.max(1, landCount),
|
||||||
|
watershedCount: watershedDebug.watershedCount,
|
||||||
|
naturalCompartmentCount: naturalDebug.naturalCompartmentCount,
|
||||||
|
regionCount: naturalDebug.regionCount,
|
||||||
|
mainRiverCount: riverNetwork.mainRivers.length,
|
||||||
|
tributaryRiverCount: riverNetwork.tributaryRivers.length,
|
||||||
|
smallStreamCount: riverNetwork.smallStreams.length,
|
||||||
|
},
|
||||||
|
...riverNetwork,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finalizeRectTerrainForFixedMap(seed, terrain, options = {}) {
|
||||||
|
if (!terrain || terrain.width !== MAP_W || terrain.height !== MAP_H || terrain.size !== SIZE) {
|
||||||
|
throw new Error(`finalizeRectTerrainForFixedMap requires ${MAP_W}x${MAP_H} terrain, got ${terrain?.width}x${terrain?.height}`);
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
elevation, slope, sea, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum,
|
||||||
|
plain, agriculture, watershedId, landMask: existingLandMask, prefectureMask: existingPrefectureMask,
|
||||||
|
} = terrain;
|
||||||
|
const prefectureMask = existingPrefectureMask || makePrefectureMask(seed, sea, elevation, slope, river);
|
||||||
|
const landMask = existingLandMask || new Uint8Array(SIZE);
|
||||||
|
if (!existingLandMask) {
|
||||||
|
for (let i = 0; i < SIZE; i++) landMask[i] = sea[i] ? 0 : 1;
|
||||||
|
}
|
||||||
|
const zeroDensity = new Float32Array(SIZE);
|
||||||
|
const zeroLanduse = new Int8Array(SIZE);
|
||||||
|
const landCount = landMask.reduce((sum, value, i) => sum + (value && !sea[i] ? 1 : 0), 0);
|
||||||
|
const natural = buildNaturalCompartments(
|
||||||
|
landMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum,
|
||||||
|
null, plain, agriculture, zeroDensity, zeroLanduse,
|
||||||
|
{
|
||||||
|
seed: (seed + 17003) >>> 0,
|
||||||
|
watershedId,
|
||||||
|
targetCompartmentCount: clamp(Math.round(landCount / 45), 70, 360),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const prefectureBorder = extractMaskBorder(prefectureMask, sea);
|
||||||
|
const terrainDebug = {
|
||||||
|
...(terrain.terrainDebug || {}),
|
||||||
|
rectNativeInitialTerrain: true,
|
||||||
|
rectInitialOriginX: terrain.originX || 0,
|
||||||
|
rectInitialOriginY: terrain.originY || 0,
|
||||||
|
sharedNaturalCompartmentLayer: true,
|
||||||
|
naturalCompartmentCount: natural.compartments?.filter?.((unit) => unit && unit.area > 0).length || 0,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...terrain,
|
||||||
|
prefectureMask,
|
||||||
|
landMask,
|
||||||
|
prefectureBorder,
|
||||||
|
naturalBarrierScore: natural.naturalBarrierScore || terrain.naturalBarrierScore,
|
||||||
|
naturalCompartmentId: natural.compartmentId,
|
||||||
|
naturalCompartments: natural.compartments,
|
||||||
|
terrainDebug,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateInitialTerrainRect(seed, options = {}) {
|
||||||
|
const variant = Number.isFinite(options.initialVariant) ? Math.max(0, Math.floor(options.initialVariant)) : 0;
|
||||||
|
const terrain = generateTerrainRect({
|
||||||
|
...options,
|
||||||
|
seed,
|
||||||
|
variant,
|
||||||
|
originX: 0,
|
||||||
|
originY: 0,
|
||||||
|
width: MAP_W,
|
||||||
|
height: MAP_H,
|
||||||
|
name: "initial-full-map",
|
||||||
|
});
|
||||||
|
return finalizeRectTerrainForFixedMap(seed, terrain, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function generateTerrainAndRivers(seed, options = {}) {
|
||||||
|
const generationContext = options.generationContext || {};
|
||||||
|
const originX = Math.floor(Number.isFinite(options.originX) ? options.originX : (Number.isFinite(generationContext.originX) ? generationContext.originX : 0));
|
||||||
|
const originY = Math.floor(Number.isFinite(options.originY) ? options.originY : (Number.isFinite(generationContext.originY) ? generationContext.originY : 0));
|
||||||
|
const variant = Math.max(0, Math.floor(Number.isFinite(options.variant) ? options.variant : (Number.isFinite(generationContext.variant) ? generationContext.variant : 0))) >>> 0;
|
||||||
|
const worldNative = options.worldNative === true || generationContext.worldNative === true;
|
||||||
const fields = createMapFields();
|
const fields = createMapFields();
|
||||||
fields.visibleRavineField = new Float32Array(SIZE);
|
fields.visibleRavineField = new Float32Array(SIZE);
|
||||||
fields.surfaceTextureField = new Float32Array(SIZE);
|
fields.surfaceTextureField = new Float32Array(SIZE);
|
||||||
|
|
@ -1145,16 +1833,18 @@ export function generateTerrainAndRivers(seed) {
|
||||||
passSuitability,
|
passSuitability,
|
||||||
} = fields;
|
} = fields;
|
||||||
|
|
||||||
const terrainTemplate = buildTerrainTemplate(seed);
|
const terrainTemplate = buildTerrainTemplate(seed, options);
|
||||||
const systems = buildMountainSystems(terrainTemplate, seed);
|
const systems = buildMountainSystems(terrainTemplate, seed);
|
||||||
const allRidges = systems.flatMap((system, id) => buildScratchRidges(system, seed, id));
|
const allRidges = systems.flatMap((system, id) => buildScratchRidges(system, seed, id));
|
||||||
|
|
||||||
for (let y = 0; y < MAP_H; y++) {
|
for (let y = 0; y < MAP_H; y++) {
|
||||||
for (let x = 0; x < MAP_W; x++) {
|
for (let x = 0; x < MAP_W; x++) {
|
||||||
const i = indexOf(x, y);
|
const i = indexOf(x, y);
|
||||||
|
const wx = originX + x;
|
||||||
|
const wy = originY + y;
|
||||||
const { px, py } = normalizeCoord(x, y);
|
const { px, py } = normalizeCoord(x, y);
|
||||||
const terrainLarge = (fbm(x * 0.65, y * 0.65, seed + 1) - 0.5) * 0.23;
|
const terrainLarge = (fbm(wx * 0.65, wy * 0.65, seed + 1) - 0.5) * 0.23;
|
||||||
const terrainRegional = (valueNoise(x * 0.8, y * 0.8, seed + 2, 42) - 0.5) * 0.16;
|
const terrainRegional = (valueNoise(wx * 0.8, wy * 0.8, seed + 2, 42) - 0.5) * 0.16;
|
||||||
const { pressure: coastPressure } = computeCoastLower(px, py, terrainTemplate, seed);
|
const { pressure: coastPressure } = computeCoastLower(px, py, terrainTemplate, seed);
|
||||||
let e = 0.42 + terrainLarge + terrainRegional - coastPressure * (terrainTemplate.coastStrength ?? 0.96) * (0.22 + terrainTemplate.deposition * 0.040);
|
let e = 0.42 + terrainLarge + terrainRegional - coastPressure * (terrainTemplate.coastStrength ?? 0.96) * (0.22 + terrainTemplate.deposition * 0.040);
|
||||||
let mountainMaskMax = 0;
|
let mountainMaskMax = 0;
|
||||||
|
|
@ -1179,7 +1869,7 @@ export function generateTerrainAndRivers(seed) {
|
||||||
const dx = (px - 0.5) * ASPECT;
|
const dx = (px - 0.5) * ASPECT;
|
||||||
const dy = py - 0.5;
|
const dy = py - 0.5;
|
||||||
const { u, v } = rotate(dx, dy, terrainTemplate.mountainAngle);
|
const { u, v } = rotate(dx, dy, terrainTemplate.mountainAngle);
|
||||||
const warp = (valueNoise(x * 0.50, y * 0.50, seed + 504, 28) - 0.5) * 18;
|
const warp = (valueNoise(wx * 0.50, wy * 0.50, seed + 504, 28) - 0.5) * 18;
|
||||||
macro = (fbm(u * 520 + warp, v * 1850 - warp * 0.35, seed + 500) - 0.5) * 2;
|
macro = (fbm(u * 520 + warp, v * 1850 - warp * 0.35, seed + 500) - 0.5) * 2;
|
||||||
scratch = (fbm(u * 920 + warp * 0.8, v * 2300 + warp * 0.25, seed + 502) - 0.5) * 2;
|
scratch = (fbm(u * 920 + warp * 0.8, v * 2300 + warp * 0.25, seed + 502) - 0.5) * 2;
|
||||||
const passBreak = clamp((valueNoise(u * 760 + 17, v * 1800 - 11, seed + 505, 18) - 0.62) * 2.6);
|
const passBreak = clamp((valueNoise(u * 760 + 17, v * 1800 - 11, seed + 505, 18) - 0.62) * 2.6);
|
||||||
|
|
@ -1187,10 +1877,10 @@ export function generateTerrainAndRivers(seed) {
|
||||||
e += lateralBranch * mountainMaskMax * 0.022;
|
e += lateralBranch * mountainMaskMax * 0.022;
|
||||||
e -= passBreak * mountainMaskMax * 0.052;
|
e -= passBreak * mountainMaskMax * 0.052;
|
||||||
} else {
|
} else {
|
||||||
macro = (fbm(x * terrainTemplate.macroNoiseScale * 48, y * terrainTemplate.macroNoiseScale * 48, seed + 500) - 0.5) * 2;
|
macro = (fbm(wx * terrainTemplate.macroNoiseScale * 48, wy * terrainTemplate.macroNoiseScale * 48, seed + 500) - 0.5) * 2;
|
||||||
scratch = (fbm(x * 2.2, y * 2.2, seed + 502) - 0.5) * 2;
|
scratch = (fbm(wx * 2.2, wy * 2.2, seed + 502) - 0.5) * 2;
|
||||||
}
|
}
|
||||||
const global = (valueNoise(x * 0.23, y * 0.23, seed + 501, 38) - 0.5) * 2;
|
const global = (valueNoise(wx * 0.23, wy * 0.23, seed + 501, 38) - 0.5) * 2;
|
||||||
e += macro * terrainTemplate.macroNoiseStrength * (0.38 + mountainMaskMax * 0.80);
|
e += macro * terrainTemplate.macroNoiseStrength * (0.38 + mountainMaskMax * 0.80);
|
||||||
e += global * 0.020;
|
e += global * 0.020;
|
||||||
e += scratch * terrainTemplate.scratchNoiseStrength * mountainMaskMax;
|
e += scratch * terrainTemplate.scratchNoiseStrength * mountainMaskMax;
|
||||||
|
|
@ -1203,7 +1893,7 @@ export function generateTerrainAndRivers(seed) {
|
||||||
if (terrainTemplate.terrainType === "setouchi_inland_sea") {
|
if (terrainTemplate.terrainType === "setouchi_inland_sea") {
|
||||||
// Setouchi maps should have many low hills and island backbones rather
|
// Setouchi maps should have many low hills and island backbones rather
|
||||||
// than a few high alpine ridges. Add broad low relief, then cap peaks.
|
// than a few high alpine ridges. Add broad low relief, then cap peaks.
|
||||||
const lowHillNoise = clamp((fbm(x * 0.95 + 17, y * 0.95 - 23, seed + 571) - 0.38) * 2.9);
|
const lowHillNoise = clamp((fbm(wx * 0.95 + 17, wy * 0.95 - 23, seed + 571) - 0.38) * 2.9);
|
||||||
const lowHillMask = clamp(0.34 + mountainMaskMax * 0.72 + lowHillNoise * 0.50 - coastPressure * 0.12);
|
const lowHillMask = clamp(0.34 + mountainMaskMax * 0.72 + lowHillNoise * 0.50 - coastPressure * 0.12);
|
||||||
e += lowHillMask * 0.145;
|
e += lowHillMask * 0.145;
|
||||||
// Do not add the previous fine speckle uplift here: it created too many
|
// Do not add the previous fine speckle uplift here: it created too many
|
||||||
|
|
@ -1212,13 +1902,22 @@ export function generateTerrainAndRivers(seed) {
|
||||||
const high = Math.max(0, e - 0.62);
|
const high = Math.max(0, e - 0.62);
|
||||||
e -= high * 0.42;
|
e -= high * 0.42;
|
||||||
}
|
}
|
||||||
const softCapStart = terrainTemplate.terrainType === "tohoku_spine" ? 0.78 : terrainTemplate.terrainType === "setouchi_inland_sea" ? 0.72 : terrainTemplate.terrainType === "chubu_mountain" ? 0.88 : 0.91;
|
if (terrainTemplate.terrainType === "oceanic_archipelago") {
|
||||||
const softCapMax = terrainTemplate.terrainType === "tohoku_spine" ? 0.96 : terrainTemplate.terrainType === "setouchi_inland_sea" ? 0.92 : 1.08;
|
// 海洋型は大きな山塊ではなく、島列を読むための低い起伏を点在させる。
|
||||||
|
const islandCore = clamp((fbm(wx * 0.82 + 41, wy * 0.82 - 29, seed + 572) - 0.46) * 2.7);
|
||||||
|
const islandChain = clamp(mountainMaskMax * 0.92 + islandCore * 0.54 - coastPressure * 0.24);
|
||||||
|
e += islandChain * 0.135;
|
||||||
|
e -= clamp((coastPressure - 0.38) * 1.55) * 0.040;
|
||||||
|
const high = Math.max(0, e - 0.56);
|
||||||
|
e -= high * 0.52;
|
||||||
|
}
|
||||||
|
const softCapStart = terrainTemplate.terrainType === "tohoku_spine" ? 0.78 : terrainTemplate.terrainType === "setouchi_inland_sea" ? 0.72 : terrainTemplate.terrainType === "oceanic_archipelago" ? 0.62 : terrainTemplate.terrainType === "chubu_mountain" ? 0.88 : 0.91;
|
||||||
|
const softCapMax = terrainTemplate.terrainType === "tohoku_spine" ? 0.96 : terrainTemplate.terrainType === "setouchi_inland_sea" ? 0.92 : terrainTemplate.terrainType === "oceanic_archipelago" ? 0.84 : 1.08;
|
||||||
elevation[i] = clamp(softCapElevation(e, softCapStart, softCapMax), 0.025, softCapMax);
|
elevation[i] = clamp(softCapElevation(e, softCapStart, softCapMax), 0.025, softCapMax);
|
||||||
visibleRavineField[i] = clamp(Math.abs(scratch) * mountainMaskMax * 0.30 + Math.max(0, -scratch) * mountainMaskMax * 0.40);
|
visibleRavineField[i] = clamp(Math.abs(scratch) * mountainMaskMax * 0.30 + Math.max(0, -scratch) * mountainMaskMax * 0.40);
|
||||||
surfaceTextureField[i] = clamp(Math.abs(macro) * 0.12 + Math.abs(scratch) * mountainMaskMax * 0.46);
|
surfaceTextureField[i] = clamp(Math.abs(macro) * 0.12 + Math.abs(scratch) * mountainMaskMax * 0.46);
|
||||||
valleyField[i] = clamp(Math.max(0, -scratch) * mountainMaskMax * 0.18);
|
valleyField[i] = clamp(Math.max(0, -scratch) * mountainMaskMax * 0.18);
|
||||||
moisture[i] = clamp(0.45 + coastPressure * 0.28 - elevation[i] * 0.20 + (fbm(x * 1.1, y * 1.1, seed + 503) - 0.5) * 0.16);
|
moisture[i] = clamp(0.45 + coastPressure * 0.28 - elevation[i] * 0.20 + (fbm(wx * 1.1, wy * 1.1, seed + 503) - 0.5) * 0.16);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1280,9 +1979,20 @@ export function generateTerrainAndRivers(seed) {
|
||||||
mountainRatio: mountainCount / Math.max(1, landCount),
|
mountainRatio: mountainCount / Math.max(1, landCount),
|
||||||
plainRatio: plainCount / Math.max(1, landCount),
|
plainRatio: plainCount / Math.max(1, landCount),
|
||||||
mountainSystemCount: systems.length,
|
mountainSystemCount: systems.length,
|
||||||
|
originX,
|
||||||
|
originY,
|
||||||
|
width: MAP_W,
|
||||||
|
height: MAP_H,
|
||||||
|
variant,
|
||||||
|
worldNative,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
originX,
|
||||||
|
originY,
|
||||||
|
width: MAP_W,
|
||||||
|
height: MAP_H,
|
||||||
|
generationContext: { ...generationContext, originX, originY, width: MAP_W, height: MAP_H, variant, worldNative },
|
||||||
terrainTemplate,
|
terrainTemplate,
|
||||||
seaLevel,
|
seaLevel,
|
||||||
elevation,
|
elevation,
|
||||||
|
|
|
||||||
1191
mapTransport.js
1191
mapTransport.js
File diff suppressed because it is too large
Load diff
145
mapTransportGraph.js
Normal file
145
mapTransportGraph.js
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import { INF, MAP_H, MAP_W, MinHeap, indexOf, inside } from "./mapUtils.js";
|
||||||
|
|
||||||
|
export function buildCoarseCostGraph({ sea, costField }, options = {}) {
|
||||||
|
const scale = options.scale ?? 4;
|
||||||
|
const cw = Math.ceil(MAP_W / scale);
|
||||||
|
const ch = Math.ceil(MAP_H / scale);
|
||||||
|
const size = cw * ch;
|
||||||
|
const cost = new Float32Array(size);
|
||||||
|
const passable = new Uint8Array(size);
|
||||||
|
cost.fill(INF);
|
||||||
|
|
||||||
|
for (let cy = 0; cy < ch; cy++) {
|
||||||
|
for (let cx = 0; cx < cw; cx++) {
|
||||||
|
let best = INF;
|
||||||
|
let sum = 0;
|
||||||
|
let n = 0;
|
||||||
|
for (let dy = 0; dy < scale; dy++) {
|
||||||
|
for (let dx = 0; dx < scale; dx++) {
|
||||||
|
const x = cx * scale + dx;
|
||||||
|
const y = cy * scale + dy;
|
||||||
|
if (!inside(x, y)) continue;
|
||||||
|
const i = indexOf(x, y);
|
||||||
|
if (sea?.[i] || costField?.[i] >= INF) continue;
|
||||||
|
const v = costField[i];
|
||||||
|
best = Math.min(best, v);
|
||||||
|
sum += v;
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ci = cy * cw + cx;
|
||||||
|
if (n > 0) {
|
||||||
|
passable[ci] = 1;
|
||||||
|
cost[ci] = best * 0.55 + (sum / n) * 0.45;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { scale, cw, ch, size, cost, passable };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function routeCoarsePath(start, goal, graph, options = {}) {
|
||||||
|
if (!start || !goal || !graph) return [];
|
||||||
|
const sx = Math.floor(start.x / graph.scale);
|
||||||
|
const sy = Math.floor(start.y / graph.scale);
|
||||||
|
const gx = Math.floor(goal.x / graph.scale);
|
||||||
|
const gy = Math.floor(goal.y / graph.scale);
|
||||||
|
if (sx < 0 || sy < 0 || sx >= graph.cw || sy >= graph.ch || gx < 0 || gy < 0 || gx >= graph.cw || gy >= graph.ch) return [];
|
||||||
|
const startCi = sy * graph.cw + sx;
|
||||||
|
const goalCi = gy * graph.cw + gx;
|
||||||
|
if (!graph.passable[startCi] || !graph.passable[goalCi]) return [];
|
||||||
|
|
||||||
|
const score = new Float32Array(graph.size);
|
||||||
|
const cameFrom = new Int32Array(graph.size);
|
||||||
|
const closed = new Uint8Array(graph.size);
|
||||||
|
score.fill(INF);
|
||||||
|
cameFrom.fill(-1);
|
||||||
|
score[startCi] = 0;
|
||||||
|
const heap = new MinHeap();
|
||||||
|
heap.push({ i: startCi, f: Math.hypot(sx - gx, sy - gy) });
|
||||||
|
const maxExpanded = options.maxExpanded ?? graph.size;
|
||||||
|
let expanded = 0;
|
||||||
|
let found = -1;
|
||||||
|
|
||||||
|
while (heap.length && expanded++ < maxExpanded) {
|
||||||
|
const cur = heap.pop();
|
||||||
|
if (!cur || closed[cur.i]) continue;
|
||||||
|
closed[cur.i] = 1;
|
||||||
|
if (cur.i === goalCi) {
|
||||||
|
found = cur.i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const cx = cur.i % graph.cw;
|
||||||
|
const cy = Math.floor(cur.i / graph.cw);
|
||||||
|
for (let dy = -1; dy <= 1; dy++) {
|
||||||
|
for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
if (!dx && !dy) continue;
|
||||||
|
const nx = cx + dx;
|
||||||
|
const ny = cy + dy;
|
||||||
|
if (nx < 0 || ny < 0 || nx >= graph.cw || ny >= graph.ch) continue;
|
||||||
|
const ni = ny * graph.cw + nx;
|
||||||
|
if (closed[ni] || !graph.passable[ni]) continue;
|
||||||
|
const nd = score[cur.i] + graph.cost[ni] * Math.hypot(dx, dy);
|
||||||
|
if (nd < score[ni]) {
|
||||||
|
score[ni] = nd;
|
||||||
|
cameFrom[ni] = cur.i;
|
||||||
|
heap.push({ i: ni, f: nd + Math.hypot(nx - gx, ny - gy) * (options.heuristicWeight ?? 0.85) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found < 0) return [];
|
||||||
|
|
||||||
|
const path = [];
|
||||||
|
for (let p = found; p >= 0; p = cameFrom[p]) {
|
||||||
|
const cx = p % graph.cw;
|
||||||
|
const cy = Math.floor(p / graph.cw);
|
||||||
|
path.push([
|
||||||
|
Math.min(MAP_W - 1, Math.round(cx * graph.scale + graph.scale * 0.5)),
|
||||||
|
Math.min(MAP_H - 1, Math.round(cy * graph.scale + graph.scale * 0.5)),
|
||||||
|
]);
|
||||||
|
if (p === startCi) break;
|
||||||
|
}
|
||||||
|
return path.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refineCoarsePath(path, costField, options = {}) {
|
||||||
|
if (!path || path.length < 2) return [];
|
||||||
|
const sea = options.sea || null;
|
||||||
|
const radius = options.snapRadius ?? 1;
|
||||||
|
const out = [];
|
||||||
|
let lastKey = "";
|
||||||
|
for (let k = 0; k < path.length - 1; k++) {
|
||||||
|
const a = path[k];
|
||||||
|
const b = path[k + 1];
|
||||||
|
const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])));
|
||||||
|
for (let s = k === 0 ? 0 : 1; s <= steps; s++) {
|
||||||
|
const t = s / steps;
|
||||||
|
const tx = Math.round(a[0] + (b[0] - a[0]) * t);
|
||||||
|
const ty = Math.round(a[1] + (b[1] - a[1]) * t);
|
||||||
|
let best = null;
|
||||||
|
let bestScore = INF;
|
||||||
|
for (let dy = -radius; dy <= radius; dy++) {
|
||||||
|
for (let dx = -radius; dx <= radius; dx++) {
|
||||||
|
const x = tx + dx;
|
||||||
|
const y = ty + dy;
|
||||||
|
if (!inside(x, y)) continue;
|
||||||
|
const i = indexOf(x, y);
|
||||||
|
if (sea?.[i] || costField?.[i] >= INF) continue;
|
||||||
|
const score = costField[i] + Math.hypot(dx, dy) * 0.22;
|
||||||
|
if (score < bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
best = [x, y];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!best) return [];
|
||||||
|
const key = `${best[0]},${best[1]}`;
|
||||||
|
if (key !== lastKey) {
|
||||||
|
out.push(best);
|
||||||
|
lastKey = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.length >= 2 ? out : [];
|
||||||
|
}
|
||||||
|
|
@ -40,7 +40,9 @@ export function buildUnifiedRailODNetwork(ctx) {
|
||||||
transportRouteAcceptable,
|
transportRouteAcceptable,
|
||||||
pruneParallelSameMode,
|
pruneParallelSameMode,
|
||||||
cachedInfluenceFromPaths,
|
cachedInfluenceFromPaths,
|
||||||
|
speedTolerance = 1,
|
||||||
} = ctx;
|
} = ctx;
|
||||||
|
const speedScale = clamp(speedTolerance, 0.75, 1);
|
||||||
|
|
||||||
const railways = [];
|
const railways = [];
|
||||||
const branchRailways = [];
|
const branchRailways = [];
|
||||||
|
|
@ -220,8 +222,8 @@ export function buildUnifiedRailODNetwork(ctx) {
|
||||||
relaxRadius: 1,
|
relaxRadius: 1,
|
||||||
relaxLineWeight: branch ? 0.44 : 0.50,
|
relaxLineWeight: branch ? 0.44 : 0.50,
|
||||||
snapRadius: branch ? 2.4 : 2.8,
|
snapRadius: branch ? 2.4 : 2.8,
|
||||||
searchPad: Math.ceil(Math.max(22, Math.min(68, pair.d * 0.48))),
|
searchPad: Math.ceil(Math.max(18, Math.min(56, pair.d * 0.40))),
|
||||||
maxPathLength: pair.d * (branch ? 2.28 : 2.48) + (branch ? 18 : 36),
|
maxPathLength: pair.d * (branch ? 2.12 : 2.30) + (branch ? 16 : 30),
|
||||||
maxSeaRun: 1,
|
maxSeaRun: 1,
|
||||||
maxSeaShare: 0.006,
|
maxSeaShare: 0.006,
|
||||||
});
|
});
|
||||||
|
|
@ -260,18 +262,18 @@ export function buildUnifiedRailODNetwork(ctx) {
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const anchorNodes = geographicUrbanAnchors
|
const anchorNodes = geographicUrbanAnchors
|
||||||
.filter((a) => (a.score || 0) > 0.76)
|
.filter((a) => (a.score || 0) > 0.76)
|
||||||
.slice(0, 8)
|
.slice(0, Math.max(5, Math.round(8 * speedScale)))
|
||||||
.map((a) => nearbyRailAnchor({ ...a, population: a.population || 52000 }, "geo-anchor-rail", { outer: 6 }))
|
.map((a) => nearbyRailAnchor({ ...a, population: a.population || 52000 }, "geo-anchor-rail", { outer: 6 }))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
let trunkNodes = dedupeNodes([...regionalCityNodes, ...secondaryCityNodes, ...portNodes, ...externalNodes, ...anchorNodes], 7.5)
|
let trunkNodes = dedupeNodes([...regionalCityNodes, ...secondaryCityNodes, ...portNodes, ...externalNodes, ...anchorNodes], 7.5)
|
||||||
.sort((a, b) => (b.population || 0) - (a.population || 0))
|
.sort((a, b) => (b.population || 0) - (a.population || 0))
|
||||||
.slice(0, 34);
|
.slice(0, Math.max(24, Math.round(34 * speedScale)));
|
||||||
if (trunkNodes.length < 2) {
|
if (trunkNodes.length < 2) {
|
||||||
trunkNodes = dedupeNodes([
|
trunkNodes = dedupeNodes([
|
||||||
...modernCities.map((c) => nearbyRailAnchor(c, "fallback-city-rail", { outer: 8 })),
|
...modernCities.map((c) => nearbyRailAnchor(c, "fallback-city-rail", { outer: 8 })),
|
||||||
...markets.filter((m) => (m.population || 0) >= 14000).map((m) => nearbyRailAnchor(m, "fallback-market-rail", { outer: 5 })),
|
...markets.filter((m) => (m.population || 0) >= 14000).map((m) => nearbyRailAnchor(m, "fallback-market-rail", { outer: 5 })),
|
||||||
], 7).slice(0, 18);
|
], 7).slice(0, Math.max(14, Math.round(18 * speedScale)));
|
||||||
}
|
}
|
||||||
debug.nodeCounts = {
|
debug.nodeCounts = {
|
||||||
regionalCityNodes: regionalCityNodes.length,
|
regionalCityNodes: regionalCityNodes.length,
|
||||||
|
|
@ -300,7 +302,7 @@ export function buildUnifiedRailODNetwork(ctx) {
|
||||||
|
|
||||||
const uf = makeUnionFind(trunkNodes, keyOf);
|
const uf = makeUnionFind(trunkNodes, keyOf);
|
||||||
const penalty = new Float32Array(SIZE);
|
const penalty = new Float32Array(SIZE);
|
||||||
const maxTrunk = Math.min(18, Math.max(4, trunkNodes.length - 1));
|
const maxTrunk = Math.min(Math.max(14, Math.round(18 * speedScale)), Math.max(4, trunkNodes.length - 1));
|
||||||
let connectedEdges = 0;
|
let connectedEdges = 0;
|
||||||
for (const pair of trunkPairs) {
|
for (const pair of trunkPairs) {
|
||||||
if (connectedEdges >= maxTrunk) break;
|
if (connectedEdges >= maxTrunk) break;
|
||||||
|
|
@ -322,7 +324,7 @@ export function buildUnifiedRailODNetwork(ctx) {
|
||||||
let loopAdded = 0;
|
let loopAdded = 0;
|
||||||
const trunkInfluenceBeforeLoops = cachedInfluenceFromPaths(railways, 5, "rail-od:trunk-before-loops");
|
const trunkInfluenceBeforeLoops = cachedInfluenceFromPaths(railways, 5, "rail-od:trunk-before-loops");
|
||||||
for (const pair of trunkPairs) {
|
for (const pair of trunkPairs) {
|
||||||
if (loopAdded >= Math.min(7, Math.max(2, Math.ceil(trunkNodes.length / 5)))) break;
|
if (loopAdded >= Math.min(Math.max(4, Math.round(7 * speedScale)), Math.max(2, Math.ceil(trunkNodes.length / 6)))) break;
|
||||||
const ai = indexOf(pair.a.x, pair.a.y);
|
const ai = indexOf(pair.a.x, pair.a.y);
|
||||||
const bi = indexOf(pair.b.x, pair.b.y);
|
const bi = indexOf(pair.b.x, pair.b.y);
|
||||||
if ((trunkInfluenceBeforeLoops[ai] || 0) > 0.62 && (trunkInfluenceBeforeLoops[bi] || 0) > 0.62 && pair.demand < 0.88) continue;
|
if ((trunkInfluenceBeforeLoops[ai] || 0) > 0.62 && (trunkInfluenceBeforeLoops[bi] || 0) > 0.62 && pair.demand < 0.88) continue;
|
||||||
|
|
@ -347,7 +349,7 @@ export function buildUnifiedRailODNetwork(ctx) {
|
||||||
...ports
|
...ports
|
||||||
.filter((p) => p.portClass === "regional" || (p.population || 0) >= 10000)
|
.filter((p) => p.portClass === "regional" || (p.population || 0) >= 10000)
|
||||||
.map((p) => nearbyRailAnchor(p, "branch-port-rail", { outer: 7 })),
|
.map((p) => nearbyRailAnchor(p, "branch-port-rail", { outer: 7 })),
|
||||||
], 6).sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, 36);
|
], 6).sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, Math.max(26, Math.round(36 * speedScale)));
|
||||||
|
|
||||||
const trunkTargets = [];
|
const trunkTargets = [];
|
||||||
for (const path of railways) {
|
for (const path of railways) {
|
||||||
|
|
@ -361,7 +363,7 @@ export function buildUnifiedRailODNetwork(ctx) {
|
||||||
|
|
||||||
let branchAdded = 0;
|
let branchAdded = 0;
|
||||||
for (const node of branchCandidates) {
|
for (const node of branchCandidates) {
|
||||||
if (branchAdded >= 14) break;
|
if (branchAdded >= Math.max(10, Math.round(14 * speedScale))) break;
|
||||||
const ni = indexOf(node.x, node.y);
|
const ni = indexOf(node.x, node.y);
|
||||||
if ((railInfluence[ni] || 0) > 0.34) continue;
|
if ((railInfluence[ni] || 0) > 0.34) continue;
|
||||||
const options = trunkTargets
|
const options = trunkTargets
|
||||||
|
|
@ -374,7 +376,7 @@ export function buildUnifiedRailODNetwork(ctx) {
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.sort((a, b) => a.score - b.score);
|
.sort((a, b) => a.score - b.score);
|
||||||
for (const pair of options.slice(0, 5)) {
|
for (const pair of options.slice(0, 3)) {
|
||||||
if (odDemand(pair.a, pair.b, "branch") < 0.34 && pair.d > 32) { reject("branchWeakDemand"); continue; }
|
if (odDemand(pair.a, pair.b, "branch") < 0.34 && pair.d > 32) { reject("branchWeakDemand"); continue; }
|
||||||
const path = routeRailPair(pair, penalty, true);
|
const path = routeRailPair(pair, penalty, true);
|
||||||
if (!path.length) continue;
|
if (!path.length) continue;
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,149 @@ export function pathAverageField(path, field) {
|
||||||
return n ? sum / n : 0;
|
return n ? sum / n : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function markPathInfluence(field, path, radius = 5, strength = 1, sea = null) {
|
||||||
|
for (const [px, py] of path || []) {
|
||||||
|
for (let dy = -radius; dy <= radius; dy++) {
|
||||||
|
for (let dx = -radius; dx <= radius; dx++) {
|
||||||
|
if (dx * dx + dy * dy > radius * radius) continue;
|
||||||
|
const x = px + dx;
|
||||||
|
const y = py + dy;
|
||||||
|
if (!inside(x, y)) continue;
|
||||||
|
const i = indexOf(x, y);
|
||||||
|
if (sea?.[i]) continue;
|
||||||
|
const d = Math.hypot(dx, dy);
|
||||||
|
const v = strength * Math.pow(1 - d / Math.max(1, radius), 1.35);
|
||||||
|
if (v > field[i]) field[i] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIncrementalPathInfluence(initialPaths = [], radius = 5, options = {}) {
|
||||||
|
const field = new Float32Array(SIZE);
|
||||||
|
const sea = options.sea || null;
|
||||||
|
for (const path of initialPaths || []) markPathInfluence(field, path, radius, 1, sea);
|
||||||
|
return {
|
||||||
|
field,
|
||||||
|
add(path, strength = 1, addRadius = radius) {
|
||||||
|
markPathInfluence(field, path, addRadius, strength, sea);
|
||||||
|
return field;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sampledNetworkCells(paths, step = 2, sea = null) {
|
||||||
|
const cells = [];
|
||||||
|
for (let pathId = 0; pathId < (paths || []).length; pathId++) {
|
||||||
|
const path = paths[pathId];
|
||||||
|
for (let k = 0; k < (path?.length || 0); k += step) {
|
||||||
|
const [x, y] = path[k];
|
||||||
|
if (inside(x, y) && !sea?.[indexOf(x, y)]) cells.push({ x, y, pathId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pathEndpoints(paths) {
|
||||||
|
const endpoints = [];
|
||||||
|
for (let pathId = 0; pathId < (paths || []).length; pathId++) {
|
||||||
|
const path = paths[pathId];
|
||||||
|
if (!path || path.length < 2) continue;
|
||||||
|
endpoints.push({ x: path[0][0], y: path[0][1], pathId });
|
||||||
|
const end = path[path.length - 1];
|
||||||
|
endpoints.push({ x: end[0], y: end[1], pathId });
|
||||||
|
}
|
||||||
|
return endpoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nearestNetworkPoint(source, targets, radius, options = {}) {
|
||||||
|
if (!source || !targets?.length) return null;
|
||||||
|
const excludeSamePath = options.excludeSamePath !== false;
|
||||||
|
const excludePath = options.excludePath;
|
||||||
|
let best = null;
|
||||||
|
let bestD = radius + 1;
|
||||||
|
for (const target of targets) {
|
||||||
|
if (excludePath != null && target.pathId === excludePath) continue;
|
||||||
|
if (excludeSamePath && source.pathId != null && target.pathId === source.pathId) continue;
|
||||||
|
const d = Math.hypot(source.x - target.x, source.y - target.y);
|
||||||
|
if (d > 0.01 && d < bestD) {
|
||||||
|
bestD = d;
|
||||||
|
best = target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best ? { target: best, d: bestD, ...best } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitPathToValidCells(path, isValid, minCells = 2) {
|
||||||
|
const chunks = [];
|
||||||
|
let current = [];
|
||||||
|
function pushPoint(x, y) {
|
||||||
|
if (!isValid(x, y)) {
|
||||||
|
if (current.length >= minCells) chunks.push(current);
|
||||||
|
current = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!current.length || current[current.length - 1][0] !== x || current[current.length - 1][1] !== y) current.push([x, y]);
|
||||||
|
}
|
||||||
|
for (let k = 0; k < (path?.length || 0); k++) {
|
||||||
|
const a = path[k];
|
||||||
|
const b = path[Math.min(k + 1, path.length - 1)];
|
||||||
|
const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])));
|
||||||
|
for (let s = 0; s <= steps; s++) {
|
||||||
|
if (k > 0 && s === 0) continue;
|
||||||
|
const t = s / steps;
|
||||||
|
pushPoint(Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current.length >= minCells) chunks.push(current);
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pathCumulativeLengths(path) {
|
||||||
|
const cum = [0];
|
||||||
|
for (let k = 1; k < (path?.length || 0); k++) {
|
||||||
|
cum.push(cum[cum.length - 1] + Math.hypot(path[k][0] - path[k - 1][0], path[k][1] - path[k - 1][1]));
|
||||||
|
}
|
||||||
|
return cum;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pointAtPathDistance(path, cum, dist) {
|
||||||
|
if (!path?.length) return null;
|
||||||
|
if (dist <= 0) return { x: path[0][0], y: path[0][1], s: 0 };
|
||||||
|
const total = cum[cum.length - 1] || 0;
|
||||||
|
if (dist >= total) {
|
||||||
|
const p = path[path.length - 1];
|
||||||
|
return { x: p[0], y: p[1], s: total };
|
||||||
|
}
|
||||||
|
let k = 1;
|
||||||
|
while (k < cum.length && cum[k] < dist) k++;
|
||||||
|
const a = path[k - 1];
|
||||||
|
const b = path[k];
|
||||||
|
const seg = Math.max(0.0001, cum[k] - cum[k - 1]);
|
||||||
|
const t = clamp((dist - cum[k - 1]) / seg);
|
||||||
|
return { x: Math.round(a[0] + (b[0] - a[0]) * t), y: Math.round(a[1] + (b[1] - a[1]) * t), s: dist };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function meanFieldAround(field, x, y, radius = 8, sea = null) {
|
||||||
|
let sum = 0;
|
||||||
|
let n = 0;
|
||||||
|
const r = Math.ceil(radius);
|
||||||
|
for (let dy = -r; dy <= r; dy++) {
|
||||||
|
for (let dx = -r; dx <= r; dx++) {
|
||||||
|
if (dx * dx + dy * dy > radius * radius) continue;
|
||||||
|
const nx = x + dx;
|
||||||
|
const ny = y + dy;
|
||||||
|
if (!inside(nx, ny)) continue;
|
||||||
|
const i = indexOf(nx, ny);
|
||||||
|
if (sea?.[i]) continue;
|
||||||
|
const w = 1 - Math.hypot(dx, dy) / Math.max(1, radius);
|
||||||
|
sum += (field?.[i] || 0) * (0.35 + w);
|
||||||
|
n += 0.35 + w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n ? sum / n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function routeQualityStats(path, fields = {}) {
|
export function routeQualityStats(path, fields = {}) {
|
||||||
if (!path?.length) return { length: 0, compactness: Infinity, highElevationShare: 1, steepShare: 1, waterShare: 1, avgPotential: 0, avgPenalty: 0 };
|
if (!path?.length) return { length: 0, compactness: Infinity, highElevationShare: 1, steepShare: 1, waterShare: 1, avgPotential: 0, avgPenalty: 0 };
|
||||||
const length = pathLengthCells(path);
|
const length = pathLengthCells(path);
|
||||||
|
|
|
||||||
8
names.js
8
names.js
|
|
@ -15,13 +15,13 @@ export const NAME_KANJI_POOLS = {
|
||||||
"霞", "朝", "日", "天",
|
"霞", "朝", "日", "天",
|
||||||
"土", "砂", "石", "岩",
|
"土", "砂", "石", "岩",
|
||||||
"卯", "辰",
|
"卯", "辰",
|
||||||
"串","駒", "来", "武", "箱", "羽", "鳴", "横", "永", "早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", "徳", "栄"
|
"串","駒", "来", "武", "箱", "羽", "鳴", "横", "永", "早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", "徳", "栄"
|
||||||
],
|
],
|
||||||
|
|
||||||
inlandTerrain: [
|
inlandTerrain: [
|
||||||
"山", "野", "野", "沢",
|
"山", "野", "野", "沢",
|
||||||
"森", "林", "岡", "丘", "坂",
|
"森", "林", "岡", "丘", "坂",
|
||||||
"峰", "峠", "嶺", "尾", "平", "坪", "延",
|
"峰", "嶺", "尾", "平", "坪", "延",
|
||||||
"窪", "久", "迫", "久保", "玖保", "佐古", "作古",
|
"窪", "久", "迫", "久保", "玖保", "佐古", "作古",
|
||||||
"塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", "植", "生",
|
"塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", "植", "生",
|
||||||
"郷", "里",
|
"郷", "里",
|
||||||
|
|
@ -85,7 +85,7 @@ export const NAME_KANJI_POOLS = {
|
||||||
],
|
],
|
||||||
|
|
||||||
archaicSuffixes: [
|
archaicSuffixes: [
|
||||||
"井", "羽", "江", "恵", "尾",
|
"伊", "衣", "井", "羽", "江", "恵", "尾",
|
||||||
"賀", "鹿", "喜", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子",
|
"賀", "鹿", "喜", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子",
|
||||||
"佐", "紗", "左", "志", "路", "師", "須", "瀬", "曽", "蘇", "総",
|
"佐", "紗", "左", "志", "路", "師", "須", "瀬", "曽", "蘇", "総",
|
||||||
"多", "太", "知", "津", "豆", "土", "登",
|
"多", "太", "知", "津", "豆", "土", "登",
|
||||||
|
|
@ -107,7 +107,7 @@ export const NAME_KANJI_POOLS = {
|
||||||
|
|
||||||
settlementWords: [
|
settlementWords: [
|
||||||
"里", "郷", "村", "町", "宿", "垣", "坪", "軒", "惣",
|
"里", "郷", "村", "町", "宿", "垣", "坪", "軒", "惣",
|
||||||
"庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂",
|
"庄", "ノ庄", "之庄", "宮", "ノ宮", "之宮", "寺", "社", "堂",
|
||||||
"城", "館", "屋", "家", "所",
|
"城", "館", "屋", "家", "所",
|
||||||
"市", "場", "関", "地蔵", "辻", "角", "堰", "通", "路", "道", "筋",
|
"市", "場", "関", "地蔵", "辻", "角", "堰", "通", "路", "道", "筋",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
150
rectContext.js
Normal file
150
rectContext.js
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { clamp } from "./mapUtils.js";
|
||||||
|
|
||||||
|
export function createRectContext(options = {}) {
|
||||||
|
const originX = Math.floor(Number.isFinite(options.originX) ? options.originX : 0);
|
||||||
|
const originY = Math.floor(Number.isFinite(options.originY) ? options.originY : 0);
|
||||||
|
const width = Math.max(1, Math.floor(Number.isFinite(options.width) ? options.width : 1));
|
||||||
|
const height = Math.max(1, Math.floor(Number.isFinite(options.height) ? options.height : 1));
|
||||||
|
return {
|
||||||
|
originX,
|
||||||
|
originY,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
size: width * height,
|
||||||
|
name: options.name || "rect",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rectIndexOf(ctx, x, y) {
|
||||||
|
return y * ctx.width + x;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rectXyOf(ctx, i) {
|
||||||
|
return [i % ctx.width, Math.floor(i / ctx.width)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rectInside(ctx, x, y) {
|
||||||
|
return !!ctx && x >= 0 && y >= 0 && x < ctx.width && y < ctx.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rectWorldX(ctx, x) {
|
||||||
|
return ctx.originX + x;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rectWorldY(ctx, y) {
|
||||||
|
return ctx.originY + y;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rectWorldCoord(ctx, x, y) {
|
||||||
|
return { x: ctx.originX + x, y: ctx.originY + y };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rectLocalCoord(ctx, worldX, worldY) {
|
||||||
|
return { x: Math.round(worldX - ctx.originX), y: Math.round(worldY - ctx.originY) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rectWorldIndex(ctx, worldX, worldY) {
|
||||||
|
const x = Math.round(worldX - ctx.originX);
|
||||||
|
const y = Math.round(worldY - ctx.originY);
|
||||||
|
return rectInside(ctx, x, y) ? rectIndexOf(ctx, x, y) : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rectFromBounds(bounds, name = "rect") {
|
||||||
|
const x0 = Math.floor(Math.min(bounds.x0, bounds.x1));
|
||||||
|
const y0 = Math.floor(Math.min(bounds.y0, bounds.y1));
|
||||||
|
const x1 = Math.ceil(Math.max(bounds.x0, bounds.x1));
|
||||||
|
const y1 = Math.ceil(Math.max(bounds.y0, bounds.y1));
|
||||||
|
return createRectContext({ originX: x0, originY: y0, width: Math.max(1, x1 - x0), height: Math.max(1, y1 - y0), name });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rectBounds(ctx) {
|
||||||
|
return { x0: ctx.originX, y0: ctx.originY, x1: ctx.originX + ctx.width, y1: ctx.originY + ctx.height };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampRectToBounds(rect, bounds) {
|
||||||
|
const x0 = Math.max(bounds.x0 ?? 0, Math.floor(rect.x0));
|
||||||
|
const y0 = Math.max(bounds.y0 ?? 0, Math.floor(rect.y0));
|
||||||
|
const x1 = Math.min(bounds.x1 ?? Infinity, Math.ceil(rect.x1));
|
||||||
|
const y1 = Math.min(bounds.y1 ?? Infinity, Math.ceil(rect.y1));
|
||||||
|
return { x0, y0, x1: Math.max(x0, x1), y1: Math.max(y0, y1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandRectBounds(rect, margin, bounds = null) {
|
||||||
|
const expanded = {
|
||||||
|
x0: Math.floor(rect.x0) - margin,
|
||||||
|
y0: Math.floor(rect.y0) - margin,
|
||||||
|
x1: Math.ceil(rect.x1) + margin,
|
||||||
|
y1: Math.ceil(rect.y1) + margin,
|
||||||
|
};
|
||||||
|
return bounds ? clampRectToBounds(expanded, bounds) : expanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rectNeighbors8(ctx, x, y) {
|
||||||
|
const out = [];
|
||||||
|
for (let dy = -1; dy <= 1; dy++) {
|
||||||
|
for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
if (!dx && !dy) continue;
|
||||||
|
const nx = x + dx;
|
||||||
|
const ny = y + dy;
|
||||||
|
if (rectInside(ctx, nx, ny)) out.push([nx, ny]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rectDistanceToEdge(ctx, x, y) {
|
||||||
|
return Math.min(x, y, ctx.width - 1 - x, ctx.height - 1 - y);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRectTerrainFields(ctx) {
|
||||||
|
const size = ctx.size;
|
||||||
|
const flowTo = new Int32Array(size);
|
||||||
|
flowTo.fill(-1);
|
||||||
|
return {
|
||||||
|
elevation: new Float32Array(size),
|
||||||
|
moisture: new Float32Array(size),
|
||||||
|
slope: new Float32Array(size),
|
||||||
|
sea: new Uint8Array(size),
|
||||||
|
ocean: new Uint8Array(size),
|
||||||
|
lake: new Uint8Array(size),
|
||||||
|
river: new Float32Array(size),
|
||||||
|
floodplain: new Float32Array(size),
|
||||||
|
plain: new Float32Array(size),
|
||||||
|
agriculture: new Float32Array(size),
|
||||||
|
ridgeField: new Float32Array(size),
|
||||||
|
valleyField: new Float32Array(size),
|
||||||
|
basinField: new Float32Array(size),
|
||||||
|
coastalLowland: new Float32Array(size),
|
||||||
|
flowAccum: new Float32Array(size),
|
||||||
|
erosionField: new Float32Array(size),
|
||||||
|
depositionField: new Float32Array(size),
|
||||||
|
arcSpineField: new Float32Array(size),
|
||||||
|
branchRidgeField: new Float32Array(size),
|
||||||
|
depositionalLowland: new Float32Array(size),
|
||||||
|
alluvialFanField: new Float32Array(size),
|
||||||
|
deltaField: new Float32Array(size),
|
||||||
|
naturalBarrierScore: new Float32Array(size),
|
||||||
|
flowTo,
|
||||||
|
portSuitability: new Float32Array(size),
|
||||||
|
crossingSuitability: new Float32Array(size),
|
||||||
|
passSuitability: new Float32Array(size),
|
||||||
|
visibleRavineField: new Float32Array(size),
|
||||||
|
surfaceTextureField: new Float32Array(size),
|
||||||
|
watershedId: (() => { const a = new Int32Array(size); a.fill(-1); return a; })(),
|
||||||
|
naturalCompartmentId: (() => { const a = new Int32Array(size); a.fill(-1); return a; })(),
|
||||||
|
regionId: (() => { const a = new Int32Array(size); a.fill(-1); return a; })(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRectCellField(value, ctx) {
|
||||||
|
return ArrayBuffer.isView(value) && typeof value.length === "number" && value.length === ctx.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rectQuantile(values, q) {
|
||||||
|
const arr = Array.from(values).filter(Number.isFinite).sort((a, b) => a - b);
|
||||||
|
if (!arr.length) return 0;
|
||||||
|
const p = clamp(q) * (arr.length - 1);
|
||||||
|
const i = Math.floor(p);
|
||||||
|
const f = p - i;
|
||||||
|
return arr[i] + (arr[Math.min(arr.length - 1, i + 1)] - arr[i]) * f;
|
||||||
|
}
|
||||||
453
renderer.js
453
renderer.js
|
|
@ -1,12 +1,37 @@
|
||||||
import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf, inside } from "./mapUtils.js";
|
import { CELL_SIZE, MAP_H, MAP_W, clamp } from "./mapUtils.js";
|
||||||
|
|
||||||
|
|
||||||
const segmentVectorCache = new WeakMap();
|
const segmentVectorCache = new WeakMap();
|
||||||
const pathVectorCache = new WeakMap();
|
const pathVectorCache = new WeakMap();
|
||||||
const coastlineCache = new WeakMap();
|
const coastlineCache = new WeakMap();
|
||||||
|
const rasterBorderCache = new WeakMap();
|
||||||
const baseImageCache = new WeakMap();
|
const baseImageCache = new WeakMap();
|
||||||
const MAX_BASE_CACHE_IMAGES = 4;
|
const MAX_BASE_CACHE_IMAGES = 4;
|
||||||
|
|
||||||
|
function mapWidth(map) {
|
||||||
|
return Math.max(1, Math.floor(Number.isFinite(map?.width) ? map.width : MAP_W));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapHeight(map) {
|
||||||
|
return Math.max(1, Math.floor(Number.isFinite(map?.height) ? map.height : MAP_H));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellIndex(map, x, y) {
|
||||||
|
return y * mapWidth(map) + x;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insideMap(map, x, y) {
|
||||||
|
return x >= 0 && y >= 0 && x < mapWidth(map) && y < mapHeight(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPixelWidth(map) {
|
||||||
|
return mapWidth(map) * CELL_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPixelHeight(map) {
|
||||||
|
return mapHeight(map) * CELL_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
function pointKey(p) {
|
function pointKey(p) {
|
||||||
return `${p[0]},${p[1]}`;
|
return `${p[0]},${p[1]}`;
|
||||||
}
|
}
|
||||||
|
|
@ -183,16 +208,18 @@ function getCoastlineSegments(map) {
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const segments = [];
|
const segments = [];
|
||||||
for (let y = 0; y < MAP_H; y++) {
|
const w = mapWidth(map);
|
||||||
for (let x = 0; x < MAP_W; x++) {
|
const h = mapHeight(map);
|
||||||
const i = indexOf(x, y);
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const i = cellIndex(map, x, y);
|
||||||
const a = Boolean(map.sea[i]);
|
const a = Boolean(map.sea[i]);
|
||||||
if (x + 1 < MAP_W) {
|
if (x + 1 < w) {
|
||||||
const b = Boolean(map.sea[indexOf(x + 1, y)]);
|
const b = Boolean(map.sea[cellIndex(map, x + 1, y)]);
|
||||||
if (a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
|
if (a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
|
||||||
}
|
}
|
||||||
if (y + 1 < MAP_H) {
|
if (y + 1 < h) {
|
||||||
const b = Boolean(map.sea[indexOf(x, y + 1)]);
|
const b = Boolean(map.sea[cellIndex(map, x, y + 1)]);
|
||||||
if (a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
|
if (a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -218,14 +245,6 @@ function vectorPath(path) {
|
||||||
return simplified;
|
return simplified;
|
||||||
}
|
}
|
||||||
|
|
||||||
function vectorPathMode(path, mode = "default") {
|
|
||||||
if (mode !== "expressway") return vectorPath(path);
|
|
||||||
if (!path || path.length < 2) return [];
|
|
||||||
const points = path.map(([x, y]) => [x * CELL_SIZE + CELL_SIZE / 2, y * CELL_SIZE + CELL_SIZE / 2]);
|
|
||||||
const smoothIterations = path.length > 18 ? 3 : path.length > 8 ? 2 : 1;
|
|
||||||
return simplifyRdp(chaikin(points, smoothIterations, false), CELL_SIZE * 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawPolylinePoints(ctx, points) {
|
function drawPolylinePoints(ctx, points) {
|
||||||
if (!points || points.length < 2) return;
|
if (!points || points.length < 2) return;
|
||||||
ctx.moveTo(points[0][0], points[0][1]);
|
ctx.moveTo(points[0][0], points[0][1]);
|
||||||
|
|
@ -252,10 +271,10 @@ function drawVectorSegments(ctx, segments, color, width, dashed = false, vectorO
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
function sampleCellIndex(fx, fy) {
|
function sampleCellIndex(map, fx, fy) {
|
||||||
const x = Math.max(0, Math.min(MAP_W - 1, Math.floor(fx)));
|
const x = Math.max(0, Math.min(mapWidth(map) - 1, Math.floor(fx)));
|
||||||
const y = Math.max(0, Math.min(MAP_H - 1, Math.floor(fy)));
|
const y = Math.max(0, Math.min(mapHeight(map) - 1, Math.floor(fy)));
|
||||||
return indexOf(x, y);
|
return cellIndex(map, x, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
function seaCoverageSample(map, fx, fy) {
|
function seaCoverageSample(map, fx, fy) {
|
||||||
|
|
@ -269,7 +288,7 @@ function seaCoverageSample(map, fx, fy) {
|
||||||
[-0.26, -0.26], [0.26, -0.26], [-0.26, 0.26], [0.26, 0.26],
|
[-0.26, -0.26], [0.26, -0.26], [-0.26, 0.26], [0.26, 0.26],
|
||||||
];
|
];
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
for (const [ox, oy] of offsets) sum += fieldSample(map.sea, fx + ox, fy + oy);
|
for (const [ox, oy] of offsets) sum += fieldSample(map, map.sea, fx + ox, fy + oy);
|
||||||
return clamp(sum / offsets.length);
|
return clamp(sum / offsets.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -301,20 +320,21 @@ function blendOutside(color, isInside) {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function fieldSample(field, fx, fy) {
|
function fieldSample(map, field, fx, fy) {
|
||||||
const sx = Math.max(0, Math.min(MAP_W - 1, fx));
|
if (!field) return 0;
|
||||||
const sy = Math.max(0, Math.min(MAP_H - 1, fy));
|
const sx = Math.max(0, Math.min(mapWidth(map) - 1, fx));
|
||||||
|
const sy = Math.max(0, Math.min(mapHeight(map) - 1, fy));
|
||||||
const x0 = Math.floor(sx);
|
const x0 = Math.floor(sx);
|
||||||
const y0 = Math.floor(sy);
|
const y0 = Math.floor(sy);
|
||||||
const x1 = Math.max(0, Math.min(MAP_W - 1, x0 + 1));
|
const x1 = Math.max(0, Math.min(mapWidth(map) - 1, x0 + 1));
|
||||||
const y1 = Math.max(0, Math.min(MAP_H - 1, y0 + 1));
|
const y1 = Math.max(0, Math.min(mapHeight(map) - 1, y0 + 1));
|
||||||
const tx = sx - x0;
|
const tx = sx - x0;
|
||||||
const ty = sy - y0;
|
const ty = sy - y0;
|
||||||
|
|
||||||
const a = field[indexOf(x0, y0)];
|
const a = field[cellIndex(map, x0, y0)] || 0;
|
||||||
const b = field[indexOf(x1, y0)];
|
const b = field[cellIndex(map, x1, y0)] || 0;
|
||||||
const c = field[indexOf(x0, y1)];
|
const c = field[cellIndex(map, x0, y1)] || 0;
|
||||||
const d = field[indexOf(x1, y1)];
|
const d = field[cellIndex(map, x1, y1)] || 0;
|
||||||
|
|
||||||
return ((a * (1 - tx) + b * tx) * (1 - ty)) + ((c * (1 - tx) + d * tx) * ty);
|
return ((a * (1 - tx) + b * tx) * (1 - ty)) + ((c * (1 - tx) + d * tx) * ty);
|
||||||
}
|
}
|
||||||
|
|
@ -337,20 +357,20 @@ function interpolateColorStops(value, stops) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function terrainColorContinuous(map, fx, fy, mode) {
|
function terrainColorContinuous(map, fx, fy, mode) {
|
||||||
const i = sampleCellIndex(fx, fy);
|
const i = sampleCellIndex(map, fx, fy);
|
||||||
const isInside = Boolean(map.prefectureMask[i]);
|
const isInside = Boolean(map.prefectureMask[i]);
|
||||||
|
|
||||||
let color;
|
let color;
|
||||||
|
|
||||||
const waterCoverage = seaCoverageSample(map, fx, fy);
|
const waterCoverage = seaCoverageSample(map, fx, fy);
|
||||||
const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map.elevation, fx, fy)) * 2.4);
|
const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map, map.elevation, fx, fy)) * 2.4);
|
||||||
const waterColor = [Math.round(174 - depth * 7), Math.round(203 + depth * 2), Math.round(224 + depth * 5)];
|
const waterColor = [Math.round(174 - depth * 7), Math.round(203 + depth * 2), Math.round(224 + depth * 5)];
|
||||||
|
|
||||||
let landColor;
|
let landColor;
|
||||||
if (mode === "development") {
|
if (mode === "development") {
|
||||||
const dCity = distToNearest(map.modernCities, fx, fy);
|
const dCity = distToNearest(map.modernCities, fx, fy);
|
||||||
const urban = clamp(1 - dCity / 25);
|
const urban = clamp(1 - dCity / 25);
|
||||||
const density = map.populationDensity ? fieldSample(map.populationDensity, fx, fy) : urban;
|
const density = map.populationDensity ? fieldSample(map, map.populationDensity, fx, fy) : urban;
|
||||||
const base = 235;
|
const base = 235;
|
||||||
landColor = [
|
landColor = [
|
||||||
Math.round(base + density * 20),
|
Math.round(base + density * 20),
|
||||||
|
|
@ -360,7 +380,7 @@ function terrainColorContinuous(map, fx, fy, mode) {
|
||||||
} else {
|
} else {
|
||||||
// 地形の基底色は標高のみに従わせる。
|
// 地形の基底色は標高のみに従わせる。
|
||||||
// 谷や微地形の見え方は陰影側で制御し、谷底だけが不自然に茶色化しないようにする。
|
// 谷や微地形の見え方は陰影側で制御し、谷底だけが不自然に茶色化しないようにする。
|
||||||
const e = fieldSample(map.elevation, fx, fy);
|
const e = fieldSample(map, map.elevation, fx, fy);
|
||||||
landColor = interpolateColorStops(clamp(e), [
|
landColor = interpolateColorStops(clamp(e), [
|
||||||
[0.20, [231, 236, 223]],
|
[0.20, [231, 236, 223]],
|
||||||
[0.30, [223, 231, 214]],
|
[0.30, [223, 231, 214]],
|
||||||
|
|
@ -386,11 +406,11 @@ function terrainColorContinuous(map, fx, fy, mode) {
|
||||||
|
|
||||||
function terrainShadeContinuous(map, fx, fy) {
|
function terrainShadeContinuous(map, fx, fy) {
|
||||||
const step = 0.50;
|
const step = 0.50;
|
||||||
const eC = fieldSample(map.elevation, fx, fy);
|
const eC = fieldSample(map, map.elevation, fx, fy);
|
||||||
const eL = fieldSample(map.elevation, fx - step, fy);
|
const eL = fieldSample(map, map.elevation, fx - step, fy);
|
||||||
const eR = fieldSample(map.elevation, fx + step, fy);
|
const eR = fieldSample(map, map.elevation, fx + step, fy);
|
||||||
const eU = fieldSample(map.elevation, fx, fy - step);
|
const eU = fieldSample(map, map.elevation, fx, fy - step);
|
||||||
const eD = fieldSample(map.elevation, fx, fy + step);
|
const eD = fieldSample(map, map.elevation, fx, fy + step);
|
||||||
|
|
||||||
// x は東向き, y は南向き。法線は (-dz/dx, -dz/dy, 1)。
|
// x は東向き, y は南向き。法線は (-dz/dx, -dz/dy, 1)。
|
||||||
// 描画では地形生成上の差分をやや誇張し、粗いDEMでも山腹の起伏を読ませる。
|
// 描画では地形生成上の差分をやや誇張し、粗いDEMでも山腹の起伏を読ませる。
|
||||||
|
|
@ -406,14 +426,14 @@ function terrainShadeContinuous(map, fx, fy) {
|
||||||
const lz = 0.7071067811865476;
|
const lz = 0.7071067811865476;
|
||||||
const hill = clamp((nx * lx + ny * ly + nz * lz) / nLen * 0.58 + 0.48);
|
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 slope = map.slope ? fieldSample(map, map.slope, fx, fy) : 0;
|
||||||
const valley = map.valleyField ? fieldSample(map.valleyField, fx, fy) : 0;
|
const valley = map.valleyField ? fieldSample(map, map.valleyField, fx, fy) : 0;
|
||||||
const ravine = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy) : 0;
|
const ravine = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx, fy) : 0;
|
||||||
const tex = map.surfaceTextureField ? fieldSample(map.surfaceTextureField, fx, fy) : 0;
|
const tex = map.surfaceTextureField ? fieldSample(map, map.surfaceTextureField, fx, fy) : 0;
|
||||||
const rvL = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx - 0.90, fy) : 0;
|
const rvL = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx - 0.90, fy) : 0;
|
||||||
const rvR = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx + 0.90, fy) : 0;
|
const rvR = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx + 0.90, fy) : 0;
|
||||||
const rvU = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy - 0.90) : 0;
|
const rvU = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx, fy - 0.90) : 0;
|
||||||
const rvD = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy + 0.90) : 0;
|
const rvD = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx, fy + 0.90) : 0;
|
||||||
const ravineRelief = (rvL - rvR) * 0.26 + (rvU - rvD) * 0.20;
|
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 concavity = clamp(((eL + eR + eU + eD) * 0.25 - eC) * 9.0, -0.18, 0.18);
|
||||||
|
|
||||||
|
|
@ -427,7 +447,7 @@ function terrainShadeContinuous(map, fx, fy) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function discreteColor(map, x, y, mode) {
|
function discreteColor(map, x, y, mode) {
|
||||||
const i = indexOf(x, y);
|
const i = cellIndex(map, x, y);
|
||||||
let color;
|
let color;
|
||||||
|
|
||||||
if (map.sea[i]) {
|
if (map.sea[i]) {
|
||||||
|
|
@ -459,35 +479,39 @@ function discreteColor(map, x, y, mode) {
|
||||||
return blendOutside(color, Boolean(map.prefectureMask[i]));
|
return blendOutside(color, Boolean(map.prefectureMask[i]));
|
||||||
}
|
}
|
||||||
|
|
||||||
function baseCacheKey(mode, continuousTerrain) {
|
function baseCacheKey(mode, continuousTerrain, renderScale = 1) {
|
||||||
const continuousModes = ["terrain", "development", "all"];
|
const continuousModes = ["terrain", "development", "all"];
|
||||||
|
const scaleKey = Math.round((renderScale || 1) * 20) / 20;
|
||||||
if (continuousTerrain && continuousModes.includes(mode)) {
|
if (continuousTerrain && continuousModes.includes(mode)) {
|
||||||
return `continuous:${mode === "all" ? "terrain" : mode}`;
|
return `continuous:${mode === "all" ? "terrain" : mode}:${scaleKey}`;
|
||||||
}
|
}
|
||||||
return `discrete:${mode}`;
|
return `discrete:${mode}:${scaleKey}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCachedBaseImage(ctx, map, mode, continuousTerrain) {
|
function getCachedBaseCanvas(ctx, map, mode, continuousTerrain, renderScale = 1) {
|
||||||
let cache = baseImageCache.get(map);
|
let cache = baseImageCache.get(map);
|
||||||
if (!cache) {
|
if (!cache) {
|
||||||
cache = new Map();
|
cache = new Map();
|
||||||
baseImageCache.set(map, cache);
|
baseImageCache.set(map, cache);
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = baseCacheKey(mode, continuousTerrain);
|
const key = baseCacheKey(mode, continuousTerrain, renderScale);
|
||||||
let image = cache.get(key);
|
let canvas = cache.get(key);
|
||||||
if (image) return image;
|
if (canvas) return canvas;
|
||||||
|
|
||||||
const width = MAP_W * CELL_SIZE;
|
const sourceWidth = mapPixelWidth(map);
|
||||||
const height = MAP_H * CELL_SIZE;
|
const sourceHeight = mapPixelHeight(map);
|
||||||
|
const targetScale = Math.max(0.35, Math.min(1, renderScale || 1));
|
||||||
|
const width = Math.max(1, Math.round(sourceWidth * targetScale));
|
||||||
|
const height = Math.max(1, Math.round(sourceHeight * targetScale));
|
||||||
const img = ctx.createImageData(width, height);
|
const img = ctx.createImageData(width, height);
|
||||||
const continuousModes = ["terrain", "development", "all"];
|
const continuousModes = ["terrain", "development", "all"];
|
||||||
|
|
||||||
if (continuousTerrain && continuousModes.includes(mode)) {
|
if (continuousTerrain && continuousModes.includes(mode)) {
|
||||||
for (let py = 0; py < height; py++) {
|
for (let py = 0; py < height; py++) {
|
||||||
const fy = py / CELL_SIZE;
|
const fy = (py / Math.max(1, height)) * mapHeight(map);
|
||||||
for (let px = 0; px < width; px++) {
|
for (let px = 0; px < width; px++) {
|
||||||
const fx = px / CELL_SIZE;
|
const fx = (px / Math.max(1, width)) * mapWidth(map);
|
||||||
const [r, g, b] = terrainColorContinuous(map, fx, fy, mode === "all" ? "terrain" : mode);
|
const [r, g, b] = terrainColorContinuous(map, fx, fy, mode === "all" ? "terrain" : mode);
|
||||||
const shade = terrainShadeContinuous(map, fx, fy);
|
const shade = terrainShadeContinuous(map, fx, fy);
|
||||||
|
|
||||||
|
|
@ -499,8 +523,10 @@ function getCachedBaseImage(ctx, map, mode, continuousTerrain) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (let y = 0; y < MAP_H; y++) {
|
const w = mapWidth(map);
|
||||||
for (let x = 0; x < MAP_W; x++) {
|
const h = mapHeight(map);
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
const [r, g, b] = discreteColor(map, x, y, mode);
|
const [r, g, b] = discreteColor(map, x, y, mode);
|
||||||
for (let dy = 0; dy < CELL_SIZE; dy++) {
|
for (let dy = 0; dy < CELL_SIZE; dy++) {
|
||||||
for (let dx = 0; dx < CELL_SIZE; dx++) {
|
for (let dx = 0; dx < CELL_SIZE; dx++) {
|
||||||
|
|
@ -514,13 +540,53 @@ function getCachedBaseImage(ctx, map, mode, continuousTerrain) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cache.set(key, img);
|
|
||||||
|
canvas = document.createElement("canvas");
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const bctx = canvas.getContext("2d");
|
||||||
|
bctx.putImageData(img, 0, 0);
|
||||||
|
cache.set(key, canvas);
|
||||||
if (cache.size > MAX_BASE_CACHE_IMAGES) cache.delete(cache.keys().next().value);
|
if (cache.size > MAX_BASE_CACHE_IMAGES) cache.delete(cache.keys().next().value);
|
||||||
return img;
|
return canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawBase(ctx, map, mode, continuousTerrain) {
|
function drawBase(ctx, map, mode, continuousTerrain, renderScale = 1) {
|
||||||
ctx.putImageData(getCachedBaseImage(ctx, map, mode, continuousTerrain), 0, 0);
|
// putImageData ignores the current transform, so it made the terrain layer
|
||||||
|
// appear unzoomed while vector layers scaled. Cache the raster into an
|
||||||
|
// offscreen canvas and draw it with drawImage so wheel zoom applies to terrain.
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.drawImage(getCachedBaseCanvas(ctx, map, mode, continuousTerrain, renderScale), 0, 0, mapPixelWidth(map), mapPixelHeight(map));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function getRasterBorderSegments(map, fieldName) {
|
||||||
|
const field = map?.[fieldName];
|
||||||
|
if (!field) return [];
|
||||||
|
let cacheByField = rasterBorderCache.get(field);
|
||||||
|
if (cacheByField?.segments) return cacheByField.segments;
|
||||||
|
const segments = [];
|
||||||
|
const w = mapWidth(map);
|
||||||
|
const h = mapHeight(map);
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const i = cellIndex(map, x, y);
|
||||||
|
const id = field[i];
|
||||||
|
if (id < 0 || map.sea?.[i]) continue;
|
||||||
|
if (x + 1 < w) {
|
||||||
|
const ri = cellIndex(map, x + 1, y);
|
||||||
|
const rid = field[ri];
|
||||||
|
if (!map.sea?.[ri] && rid >= 0 && rid !== id) segments.push([[x + 0.5, y], [x + 0.5, y + 1]]);
|
||||||
|
}
|
||||||
|
if (y + 1 < h) {
|
||||||
|
const di = cellIndex(map, x, y + 1);
|
||||||
|
const did = field[di];
|
||||||
|
if (!map.sea?.[di] && did >= 0 && did !== id) segments.push([[x, y + 0.5], [x + 1, y + 0.5]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rasterBorderCache.set(field, { segments });
|
||||||
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) {
|
function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) {
|
||||||
|
|
@ -531,8 +597,8 @@ function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) {
|
||||||
for (let k = 0; k < path.length - 1; k++) {
|
for (let k = 0; k < path.length - 1; k++) {
|
||||||
const [x1, y1] = path[k];
|
const [x1, y1] = path[k];
|
||||||
const [x2, y2] = path[k + 1];
|
const [x2, y2] = path[k + 1];
|
||||||
const i1 = indexOf(x1, y1);
|
const i1 = cellIndex(map, Math.round(x1), Math.round(y1));
|
||||||
const i2 = indexOf(x2, y2);
|
const i2 = cellIndex(map, Math.round(x2), Math.round(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);
|
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.strokeStyle = color;
|
||||||
ctx.globalAlpha = alpha;
|
ctx.globalAlpha = alpha;
|
||||||
|
|
@ -545,8 +611,8 @@ function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) {
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawPath(ctx, path, color, width, dashed = false, mode = "default") {
|
function drawPath(ctx, path, color, width, dashed = false) {
|
||||||
const points = vectorPathMode(path, mode);
|
const points = vectorPath(path);
|
||||||
if (points.length < 2) return;
|
if (points.length < 2) return;
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.lineCap = "round";
|
ctx.lineCap = "round";
|
||||||
|
|
@ -566,7 +632,7 @@ function landOnlySubpaths(map, path, minCells = 2) {
|
||||||
let cur = [];
|
let cur = [];
|
||||||
for (const p of path) {
|
for (const p of path) {
|
||||||
const [x, y] = p;
|
const [x, y] = p;
|
||||||
const land = inside(x, y) && !map.sea[indexOf(x, y)];
|
const land = insideMap(map, x, y) && !map.sea[cellIndex(map, x, y)];
|
||||||
if (land) {
|
if (land) {
|
||||||
cur.push(p);
|
cur.push(p);
|
||||||
} else if (cur.length >= minCells) {
|
} else if (cur.length >= minCells) {
|
||||||
|
|
@ -580,97 +646,8 @@ function landOnlySubpaths(map, path, minCells = 2) {
|
||||||
return chunks;
|
return chunks;
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawLandPath(ctx, map, path, color, width, dashed = false, minCells = 2, mode = "default") {
|
function drawLandPath(ctx, map, path, color, width, dashed = false, minCells = 2) {
|
||||||
for (const chunk of landOnlySubpaths(map, path, minCells)) drawPath(ctx, chunk, color, width, dashed, mode);
|
for (const chunk of landOnlySubpaths(map, path, minCells)) drawPath(ctx, chunk, color, width, dashed);
|
||||||
}
|
|
||||||
|
|
||||||
function specialTransportSubpaths(map, path, predicate, minCells = 1, includeShoulders = true, maxCoreCells = Infinity) {
|
|
||||||
if (!path || path.length < 2) return [];
|
|
||||||
const chunks = [];
|
|
||||||
let cur = [];
|
|
||||||
let core = 0;
|
|
||||||
function flush(nextPoint = null) {
|
|
||||||
if (cur.length && nextPoint && includeShoulders) cur.push(nextPoint);
|
|
||||||
if (cur.length >= Math.max(2, minCells) && core <= maxCoreCells) chunks.push(cur);
|
|
||||||
cur = [];
|
|
||||||
core = 0;
|
|
||||||
}
|
|
||||||
for (let idx = 0; idx < path.length; idx++) {
|
|
||||||
const [x, y] = path[idx];
|
|
||||||
const i = inside(x, y) ? indexOf(x, y) : -1;
|
|
||||||
const hit = i >= 0 && predicate(i, x, y);
|
|
||||||
if (hit) {
|
|
||||||
if (!cur.length && includeShoulders && idx > 0) cur.push(path[idx - 1]);
|
|
||||||
cur.push(path[idx]);
|
|
||||||
core++;
|
|
||||||
} else if (cur.length) {
|
|
||||||
flush(path[idx]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flush(null);
|
|
||||||
return chunks;
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawOffsetPolyline(ctx, points, offsetPx) {
|
|
||||||
if (!points || points.length < 2) return;
|
|
||||||
ctx.beginPath();
|
|
||||||
for (let i = 0; i < points.length; i++) {
|
|
||||||
const prev = points[Math.max(0, i - 1)];
|
|
||||||
const cur = points[i];
|
|
||||||
const next = points[Math.min(points.length - 1, i + 1)];
|
|
||||||
const dx = next[0] - prev[0];
|
|
||||||
const dy = next[1] - prev[1];
|
|
||||||
const len = Math.hypot(dx, dy) || 1;
|
|
||||||
const ox = -dy / len * offsetPx;
|
|
||||||
const oy = dx / len * offsetPx;
|
|
||||||
if (i === 0) ctx.moveTo(cur[0] + ox, cur[1] + oy);
|
|
||||||
else ctx.lineTo(cur[0] + ox, cur[1] + oy);
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawDottedOutlinePath(ctx, path, color, width, offsetPx, mode = "default") {
|
|
||||||
const points = vectorPathMode(path, mode);
|
|
||||||
if (points.length < 2) return;
|
|
||||||
ctx.save();
|
|
||||||
ctx.lineCap = "round";
|
|
||||||
ctx.lineJoin = "round";
|
|
||||||
ctx.strokeStyle = color;
|
|
||||||
ctx.lineWidth = width;
|
|
||||||
ctx.setLineDash([1.8, 3.2]);
|
|
||||||
drawOffsetPolyline(ctx, points, offsetPx);
|
|
||||||
drawOffsetPolyline(ctx, points, -offsetPx);
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawBridgeOverlay(ctx, map, path, width, mode = "road") {
|
|
||||||
const limit = mode === "expressway" ? 20 : 10;
|
|
||||||
const bridgeChunks = specialTransportSubpaths(map, path, (i) => map.sea?.[i], 2, true, limit);
|
|
||||||
for (const chunk of bridgeChunks) {
|
|
||||||
const vectorMode = mode === "expressway" ? "expressway" : "default";
|
|
||||||
drawPath(ctx, chunk, "rgba(255,255,255,0.98)", width + 2.0, false, vectorMode);
|
|
||||||
drawPath(ctx, chunk, mode === "expressway" ? "rgba(135, 160, 135, 0.95)" : "rgba(245, 225, 130, 1)", width + 0.2, false, vectorMode);
|
|
||||||
drawDottedOutlinePath(ctx, chunk, "rgba(55, 85, 130, 0.95)", 1.0, Math.max(1.8, width * 0.72), vectorMode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawTunnelOverlay(ctx, map, path, width, mode = "road") {
|
|
||||||
if (mode !== "expressway") return;
|
|
||||||
const tunnelChunks = specialTransportSubpaths(
|
|
||||||
map,
|
|
||||||
path,
|
|
||||||
(i) => !map.sea?.[i] && (((map.elevation?.[i] || 0) >= 0.74 && (map.ridgeField?.[i] || 0) >= 0.46) || (map.naturalBarrierScore?.[i] || 0) >= 0.82),
|
|
||||||
2,
|
|
||||||
true,
|
|
||||||
10
|
|
||||||
);
|
|
||||||
for (const chunk of tunnelChunks) {
|
|
||||||
drawDottedOutlinePath(ctx, chunk, "rgba(42, 42, 42, 0.96)", 1.15, Math.max(1.9, width * 0.82), "expressway");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawExpresswayPath(ctx, map, path, color, width, dashed = false, minCells = 2) {
|
|
||||||
drawLandPath(ctx, map, path, color, width, dashed, minCells, "expressway");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 魚の骨(私鉄記号)スタイルを描画するための専用関数
|
// 魚の骨(私鉄記号)スタイルを描画するための専用関数
|
||||||
|
|
@ -757,9 +734,11 @@ function drawUrbanAreas(ctx, map, mode) {
|
||||||
const cbdColor = "rgba(215, 175, 172, 0.84)";
|
const cbdColor = "rgba(215, 175, 172, 0.84)";
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
for (let y = 0; y < MAP_H; y++) {
|
const w = mapWidth(map);
|
||||||
for (let x = 0; x < MAP_W; x++) {
|
const h = mapHeight(map);
|
||||||
const i = indexOf(x, y);
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const i = cellIndex(map, x, y);
|
||||||
const areaMask = map.humanRegionMask || map.prefectureMask;
|
const areaMask = map.humanRegionMask || map.prefectureMask;
|
||||||
if (areaMask && !areaMask[i]) continue;
|
if (areaMask && !areaMask[i]) continue;
|
||||||
const lu = map.landuse[i];
|
const lu = map.landuse[i];
|
||||||
|
|
@ -781,9 +760,11 @@ function drawUrbanAreas(ctx, map, mode) {
|
||||||
function drawDebugCells(ctx, map, field, color) {
|
function drawDebugCells(ctx, map, field, color) {
|
||||||
if (!field) return;
|
if (!field) return;
|
||||||
ctx.save();
|
ctx.save();
|
||||||
for (let y = 0; y < MAP_H; y++) {
|
const w = mapWidth(map);
|
||||||
for (let x = 0; x < MAP_W; x++) {
|
const h = mapHeight(map);
|
||||||
const i = indexOf(x, y);
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const i = cellIndex(map, x, y);
|
||||||
const debugMask = map.humanRegionMask || map.prefectureMask;
|
const debugMask = map.humanRegionMask || map.prefectureMask;
|
||||||
if (!debugMask[i] || map.sea[i]) continue;
|
if (!debugMask[i] || map.sea[i]) continue;
|
||||||
const raw = field[i] || 0;
|
const raw = field[i] || 0;
|
||||||
|
|
@ -841,9 +822,11 @@ function drawPrefectureRegionFill(ctx, map, mode) {
|
||||||
const alpha = mode === "borders-debug" ? 0.34 : 0.18;
|
const alpha = mode === "borders-debug" ? 0.34 : 0.18;
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.globalAlpha = alpha;
|
ctx.globalAlpha = alpha;
|
||||||
for (let y = 0; y < MAP_H; y++) {
|
const w = mapWidth(map);
|
||||||
for (let x = 0; x < MAP_W; x++) {
|
const h = mapHeight(map);
|
||||||
const i = indexOf(x, y);
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const i = cellIndex(map, x, y);
|
||||||
const id = ids[i];
|
const id = ids[i];
|
||||||
if (map.sea[i] || id < 0) continue;
|
if (map.sea[i] || id < 0) continue;
|
||||||
const [r, g, b] = prefectureRegionColor(id);
|
const [r, g, b] = prefectureRegionColor(id);
|
||||||
|
|
@ -903,7 +886,7 @@ function labelWithCollision(ctx, p, occupied) {
|
||||||
const x = baseX + ox;
|
const x = baseX + ox;
|
||||||
const y = baseY + oy;
|
const y = baseY + oy;
|
||||||
const box = { x1: x - 3, y1: y - textH, x2: x + textW + 3, y2: y + 5 };
|
const box = { x1: x - 3, y1: y - textH, x2: x + textW + 3, y2: y + 5 };
|
||||||
if (box.x1 < 0 || box.y1 < 0 || box.x2 > MAP_W * CELL_SIZE || box.y2 > MAP_H * CELL_SIZE) continue;
|
if (box.x1 < 0 || box.y1 < 0 || box.x2 > (ctx.__mapPixelWidth || MAP_W * CELL_SIZE) || box.y2 > (ctx.__mapPixelHeight || MAP_H * CELL_SIZE)) continue;
|
||||||
const overlaps = occupied.filter((b) => boxesOverlap(box, b, isPrefectureLabel ? 5 : isMunicipalityLabel ? 1 : 3));
|
const overlaps = occupied.filter((b) => boxesOverlap(box, b, isPrefectureLabel ? 5 : isMunicipalityLabel ? 1 : 3));
|
||||||
if (!overlaps.length) {
|
if (!overlaps.length) {
|
||||||
ctx.lineJoin = "round";
|
ctx.lineJoin = "round";
|
||||||
|
|
@ -946,11 +929,11 @@ function drawLabels(ctx, points, limit = Infinity, occupied = null) {
|
||||||
return used;
|
return used;
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawScaleBar(ctx) {
|
function drawScaleBar(ctx, cellScreenSize = CELL_SIZE) {
|
||||||
const kmPerCell = 1;
|
const kmPerCell = 0.5;
|
||||||
const targetKm = 25;
|
const targetKm = 25;
|
||||||
const lengthCells = Math.max(8, Math.round(targetKm / kmPerCell));
|
const lengthCells = Math.max(8, Math.round(targetKm / kmPerCell));
|
||||||
const lengthPx = lengthCells * CELL_SIZE;
|
const lengthPx = lengthCells * cellScreenSize;
|
||||||
const margin = 14;
|
const margin = 14;
|
||||||
const x = margin;
|
const x = margin;
|
||||||
const y = margin + 18;
|
const y = margin + 18;
|
||||||
|
|
@ -989,14 +972,34 @@ export function drawMap(canvas, map, options) {
|
||||||
const mode = options.mode || "all";
|
const mode = options.mode || "all";
|
||||||
const showFeatures = options.showFeatures !== false;
|
const showFeatures = options.showFeatures !== false;
|
||||||
const showLabels = options.showLabels !== false;
|
const showLabels = options.showLabels !== false;
|
||||||
|
const continuousTerrain = options.continuousTerrain !== false;
|
||||||
const width = MAP_W * CELL_SIZE;
|
const outputWidth = MAP_W * CELL_SIZE;
|
||||||
const height = MAP_H * CELL_SIZE;
|
const outputHeight = MAP_H * CELL_SIZE;
|
||||||
canvas.width = width;
|
const sourceWidth = mapPixelWidth(map);
|
||||||
canvas.height = height;
|
const sourceHeight = mapPixelHeight(map);
|
||||||
|
const drawScale = Math.min(outputWidth / Math.max(1, sourceWidth), outputHeight / Math.max(1, sourceHeight));
|
||||||
|
const drawOffsetX = (outputWidth - sourceWidth * drawScale) * 0.5;
|
||||||
|
const drawOffsetY = (outputHeight - sourceHeight * drawScale) * 0.5;
|
||||||
|
const cellScreenSize = CELL_SIZE * drawScale;
|
||||||
|
const terrainRenderScale = continuousTerrain ? clamp(drawScale * (options.fastTerrain ? 0.48 : 1.35), 0.30, 1) : 1;
|
||||||
|
|
||||||
|
if (canvas.width !== outputWidth) canvas.width = outputWidth;
|
||||||
|
if (canvas.height !== outputHeight) canvas.height = outputHeight;
|
||||||
|
ctx.clearRect(0, 0, outputWidth, outputHeight);
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(drawOffsetX, drawOffsetY);
|
||||||
|
ctx.scale(drawScale, drawScale);
|
||||||
|
ctx.__mapPixelWidth = sourceWidth;
|
||||||
|
ctx.__mapPixelHeight = sourceHeight;
|
||||||
|
const finish = () => {
|
||||||
|
ctx.restore();
|
||||||
|
drawScaleBar(ctx, cellScreenSize);
|
||||||
|
delete ctx.__mapPixelWidth;
|
||||||
|
delete ctx.__mapPixelHeight;
|
||||||
|
};
|
||||||
|
|
||||||
// 1. Base Terrain & Urban
|
// 1. Base Terrain & Urban
|
||||||
drawBase(ctx, map, mode, true);
|
drawBase(ctx, map, mode, continuousTerrain, terrainRenderScale);
|
||||||
drawUrbanAreas(ctx, map, mode);
|
drawUrbanAreas(ctx, map, mode);
|
||||||
const coastSegments = getCoastlineSegments(map);
|
const coastSegments = getCoastlineSegments(map);
|
||||||
drawVectorSegments(ctx, coastSegments, "rgba(120, 175, 210, 0.22)", 2.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
drawVectorSegments(ctx, coastSegments, "rgba(120, 175, 210, 0.22)", 2.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||||
|
|
@ -1013,7 +1016,7 @@ export function drawMap(canvas, map, options) {
|
||||||
let tailCount = 0;
|
let tailCount = 0;
|
||||||
for (let k = 0; k < path.length; k++) {
|
for (let k = 0; k < path.length; k++) {
|
||||||
const [x, y] = path[k];
|
const [x, y] = path[k];
|
||||||
const i = indexOf(x, y);
|
const i = cellIndex(map, Math.round(x), Math.round(y));
|
||||||
const strength = (map.river?.[i] || 0) + (map.flowAccum?.[i] || 0) * 0.75;
|
const strength = (map.river?.[i] || 0) + (map.flowAccum?.[i] || 0) * 0.75;
|
||||||
peak = Math.max(peak, strength);
|
peak = Math.max(peak, strength);
|
||||||
if (k >= tailStart) {
|
if (k >= tailStart) {
|
||||||
|
|
@ -1064,9 +1067,11 @@ export function drawMap(canvas, map, options) {
|
||||||
const showPrefectureRegions = ["all", "admin", "borders-debug"].includes(mode);
|
const showPrefectureRegions = ["all", "admin", "borders-debug"].includes(mode);
|
||||||
if (showPrefectureRegions) drawPrefectureRegionFill(ctx, map, mode);
|
if (showPrefectureRegions) drawPrefectureRegionFill(ctx, map, mode);
|
||||||
|
|
||||||
if (showAdmin && map.adminBorders) {
|
const adminBorderSegments = map.adminId ? getRasterBorderSegments(map, "adminId") : map.adminBorders;
|
||||||
drawVectorSegments(ctx, map.adminBorders, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
|
const prefectureBorderSegments = map.prefectureRegionId ? getRasterBorderSegments(map, "prefectureRegionId") : map.regionalPrefectureBorders;
|
||||||
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 (showAdmin && adminBorderSegments?.length) {
|
||||||
|
drawVectorSegments(ctx, adminBorderSegments, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
|
||||||
|
drawVectorSegments(ctx, adminBorderSegments, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
|
||||||
}
|
}
|
||||||
if (mode === "borders-debug") {
|
if (mode === "borders-debug") {
|
||||||
// Keep the natural barrier heatmap subtle. A dense cell fill can look like
|
// Keep the natural barrier heatmap subtle. A dense cell fill can look like
|
||||||
|
|
@ -1077,18 +1082,21 @@ export function drawMap(canvas, map, options) {
|
||||||
}
|
}
|
||||||
if (showTransportDebug) drawTransportDebug(ctx, map);
|
if (showTransportDebug) drawTransportDebug(ctx, map);
|
||||||
|
|
||||||
if (showPrefectureRegions && map.regionalPrefectureBorders) {
|
if (showPrefectureRegions && prefectureBorderSegments?.length) {
|
||||||
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, prefectureBorderSegments, "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, prefectureBorderSegments, "rgba(82, 60, 102, 0.96)", 1.8, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!showPrefectureRegions) {
|
if (!showPrefectureRegions) {
|
||||||
const finalPrefectureBorders = (map.regionalPrefectureBorders && map.regionalPrefectureBorders.length) ? map.regionalPrefectureBorders : map.prefectureBorder;
|
const finalPrefectureBorders = (prefectureBorderSegments && prefectureBorderSegments.length) ? prefectureBorderSegments : map.prefectureBorder;
|
||||||
drawVectorSegments(ctx, finalPrefectureBorders, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
drawVectorSegments(ctx, finalPrefectureBorders, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||||
drawVectorSegments(ctx, finalPrefectureBorders, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
drawVectorSegments(ctx, finalPrefectureBorders, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!showFeatures) return;
|
if (!showFeatures) {
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Transport casings. Layer order: local roads, trunk roads, railways, expressways.
|
// 4. Transport casings. Layer order: local roads, trunk roads, railways, expressways.
|
||||||
const localRoadCasing = "rgba(112, 112, 104, 0.58)";
|
const localRoadCasing = "rgba(112, 112, 104, 0.58)";
|
||||||
|
|
@ -1115,8 +1123,8 @@ export function drawMap(canvas, map, options) {
|
||||||
for (const path of map.externalRailways) drawLandPath(ctx, map, path, "rgba(255, 255, 255, 0.78)", 3.6, false, 3);
|
for (const path of map.externalRailways) drawLandPath(ctx, map, path, "rgba(255, 255, 255, 0.78)", 3.6, false, 3);
|
||||||
}
|
}
|
||||||
if (showRoads) {
|
if (showRoads) {
|
||||||
for (const path of map.expressways) drawExpresswayPath(ctx, map, path, "rgba(105, 125, 105, 0.78)", 4.6);
|
for (const path of map.expressways) drawPath(ctx, path, "rgba(105, 125, 105, 0.78)", 4.6);
|
||||||
for (const path of map.externalExpressways) drawExpresswayPath(ctx, map, path, "rgba(105, 125, 105, 0.78)", 4.6);
|
for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(105, 125, 105, 0.78)", 4.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Transport fills. Expressways remain above railways; railways sit above ordinary roads.
|
// 5. Transport fills. Expressways remain above railways; railways sit above ordinary roads.
|
||||||
|
|
@ -1134,11 +1142,8 @@ export function drawMap(canvas, map, options) {
|
||||||
for (const path of map.externalRailways) drawLandRailway(ctx, map, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0);
|
for (const path of map.externalRailways) drawLandRailway(ctx, map, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0);
|
||||||
}
|
}
|
||||||
if (showRoads) {
|
if (showRoads) {
|
||||||
for (const path of generalRoadPaths) { drawBridgeOverlay(ctx, map, path, 1.55, "road"); }
|
for (const path of map.expressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4);
|
||||||
for (const path of map.nationalRoads) { drawBridgeOverlay(ctx, map, path, 2.0, "road"); }
|
for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4);
|
||||||
for (const path of map.externalRoads) { drawBridgeOverlay(ctx, map, path, 2.0, "road"); }
|
|
||||||
for (const path of map.expressways) { drawBridgeOverlay(ctx, map, path, 2.45, "expressway"); drawTunnelOverlay(ctx, map, path, 2.25, "expressway"); }
|
|
||||||
for (const path of map.externalExpressways) { drawBridgeOverlay(ctx, map, path, 2.45, "expressway"); drawTunnelOverlay(ctx, map, path, 2.25, "expressway"); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Icons & Labels
|
// 6. Icons & Labels
|
||||||
|
|
@ -1146,15 +1151,22 @@ export function drawMap(canvas, map, options) {
|
||||||
for (const p of map.adminCenters || []) dot(ctx, p, 2.8, "rgba(255,255,255,0.96)", "rgba(75,60,90,0.95)");
|
for (const p of map.adminCenters || []) dot(ctx, p, 2.8, "rgba(255,255,255,0.96)", "rgba(75,60,90,0.95)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const settlementIconLabelPoints = ["all", "modern", "history"].includes(mode)
|
||||||
|
? [
|
||||||
|
...(map.markets || []).filter((p) => (p.population || 0) >= 3000),
|
||||||
|
...(map.villages || []).filter((p) => (p.population || 0) >= 3000),
|
||||||
|
...(mode === "history" ? (map.ports || []).filter((p) => p.portClass === "major" || p.portClass === "regional") : []),
|
||||||
|
...(mode === "history" ? (map.castles || []) : []),
|
||||||
|
].filter((p) => !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y))
|
||||||
|
.map((p) => ({ ...p, forceAllLayerTownLabel: true, labelPriorityBase: p.labelPriorityBase || (p.kind === "Village" ? 58 : p.kind === "Castle" ? 88 : 66) }))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (["all", "modern", "history"].includes(mode)) {
|
||||||
|
for (const p of settlementIconLabelPoints) dot(ctx, p, p.kind === "Castle" ? 3.2 : 3.1, "rgba(240, 110, 110, 0.95)", "rgba(255,255,255,0.88)");
|
||||||
|
}
|
||||||
|
|
||||||
if (showModern) {
|
if (showModern) {
|
||||||
for (const p of map.stations) dot(ctx, p, 2.5, "#fff", "#444");
|
for (const p of map.stations) dot(ctx, p, 2.5, "#fff", "#444");
|
||||||
const allLayerTowns = mode === "all"
|
|
||||||
? [
|
|
||||||
...(map.markets || []).filter((p) => (p.population || 0) >= 5000),
|
|
||||||
...(map.villages || []).filter((p) => (p.population || 0) >= 5000),
|
|
||||||
].filter((p) => !(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) {
|
for (const p of map.modernCities) {
|
||||||
const popRadius = p.population ? Math.min(9.4, 3.8 + Math.sqrt(p.population) / 360) : 4.8;
|
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)");
|
dot(ctx, p, popRadius, "rgba(240, 110, 110, 0.95)", "rgba(255,255,255,0.9)");
|
||||||
|
|
@ -1175,37 +1187,28 @@ export function drawMap(canvas, map, options) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showLabels) {
|
if (showLabels) {
|
||||||
const prefectureLabels = (map.prefectureRegions || []).map((p) => ({ ...p, isPrefectureLabel: true, labelPriorityBase: p.labelPriorityBase || 1700 }));
|
const prefectureLabels = (map.prefectureRegions || []).map((p) => ({ ...p, isPrefectureLabel: true, forceLabel: true, labelPriorityBase: p.labelPriorityBase || 1900 }));
|
||||||
if (mode === "admin") {
|
if (mode === "admin") {
|
||||||
drawLabels(ctx, [...prefectureLabels, ...(map.adminCenters || [])], Infinity);
|
const municipalLabels = (map.adminCenters || [])
|
||||||
drawScaleBar(ctx);
|
.filter((p) => p && p.name && Number.isFinite(p.x) && Number.isFinite(p.y) && p.x >= 0 && p.y >= 0 && p.x < map.width && p.y < map.height && map.adminId?.[Math.round(p.y) * map.width + Math.round(p.x)] === (p.adminId ?? p.municipalityId ?? p.adminNumericId))
|
||||||
|
.map((p) => ({ ...p, labelStyle: "municipality", forceLabel: true, labelPriorityBase: 1200 }));
|
||||||
|
drawLabels(ctx, [...prefectureLabels, ...municipalLabels], Infinity);
|
||||||
|
finish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mode === "borders-debug") {
|
if (mode === "borders-debug") {
|
||||||
drawLabels(ctx, [...prefectureLabels, ...(map.adminCenters || [])], Infinity);
|
drawLabels(ctx, prefectureLabels, Infinity);
|
||||||
drawScaleBar(ctx);
|
finish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// In All mode, draw town/village dots above but suppress town/village labels.
|
|
||||||
// The Admin/Municipal Borders view still labels municipal centers normally.
|
|
||||||
const allLayerTowns = mode === "all"
|
|
||||||
? [
|
|
||||||
...(map.markets || []).filter((p) => (p.population || 0) >= 5000),
|
|
||||||
...(map.villages || []).filter((p) => (p.population || 0) >= 5000),
|
|
||||||
].filter((p) => !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y))
|
|
||||||
: [];
|
|
||||||
const important = [
|
const important = [
|
||||||
...prefectureLabels,
|
...prefectureLabels,
|
||||||
...map.modernCities,
|
...map.modernCities,
|
||||||
...(map.ports || []).map((p) => ({
|
...map.ports,
|
||||||
...p,
|
|
||||||
labelPriorityBase: p.portClass === "major" ? 170 : p.portClass === "regional" ? 120 : p.portClass === "fishing" ? 95 : 85,
|
|
||||||
})),
|
|
||||||
...(map.logisticsParks || []).map((p) => ({ ...p, labelPriorityBase: 60 })),
|
|
||||||
...(map.satelliteCities || []),
|
...(map.satelliteCities || []),
|
||||||
...allLayerTowns.map((p) => ({ ...p, forceAllLayerTownLabel: true, labelPriorityBase: 80 })),
|
...settlementIconLabelPoints,
|
||||||
].filter((p) => p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000);
|
].filter((p) => !p.suppressSettlementLabel && (p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000));
|
||||||
drawLabels(ctx, important, mode === "all" ? 95 : 60);
|
drawLabels(ctx, important, mode === "all" || mode === "history" ? 78 : 60);
|
||||||
}
|
}
|
||||||
drawScaleBar(ctx);
|
finish();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
styles.css
24
styles.css
|
|
@ -1,6 +1,6 @@
|
||||||
*{box-sizing:border-box}
|
*{box-sizing:border-box}
|
||||||
body{margin:0;background:#f0f2f5;color:#2c2c2c;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
body{margin:0;background:#f0f2f5;color:#2c2c2c;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
||||||
button,input{font:inherit}
|
button,input,select{font:inherit}
|
||||||
code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
|
code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
|
||||||
.app{min-height:100vh;padding:16px}
|
.app{min-height:100vh;padding:16px}
|
||||||
.layout{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:16px;max-width:1360px;margin:0 auto}
|
.layout{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:16px;max-width:1360px;margin:0 auto}
|
||||||
|
|
@ -13,6 +13,7 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
|
||||||
.sidebar{display:flex;flex-direction:column;gap:12px}
|
.sidebar{display:flex;flex-direction:column;gap:12px}
|
||||||
.card{padding:14px}
|
.card{padding:14px}
|
||||||
.label,.card-title{display:block;margin-bottom:12px;color:#1a1a1a;font-size:14px;font-weight:600}
|
.label,.card-title{display:block;margin-bottom:12px;color:#1a1a1a;font-size:14px;font-weight:600}
|
||||||
|
.inline-label{margin-top:12px}
|
||||||
.input{width:100%;border:1px solid rgba(0,0,0,0.15);background:#fff;color:#2c2c2c;border-radius:8px;padding:10px 12px;outline:none;transition:border-color 0.2s}
|
.input{width:100%;border:1px solid rgba(0,0,0,0.15);background:#fff;color:#2c2c2c;border-radius:8px;padding:10px 12px;outline:none;transition:border-color 0.2s}
|
||||||
.input:focus{border-color:#1a73e8;box-shadow:0 0 0 3px rgba(26,115,232,0.15)}
|
.input:focus{border-color:#1a73e8;box-shadow:0 0 0 3px rgba(26,115,232,0.15)}
|
||||||
.primary-button,.mode-button{border:0;border-radius:8px;padding:10px 12px;cursor:pointer;font-weight:600;transition:background 0.2s}
|
.primary-button,.mode-button{border:0;border-radius:8px;padding:10px 12px;cursor:pointer;font-weight:600;transition:background 0.2s}
|
||||||
|
|
@ -60,7 +61,7 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
|
||||||
.port-icon{width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #4682c8;border-top:0;box-shadow:none;background:transparent;border-radius:0}
|
.port-icon{width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #4682c8;border-top:0;box-shadow:none;background:transparent;border-radius:0}
|
||||||
.newtown-icon{width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #b4d2f0;border-top:0;box-shadow:none;background:transparent;border-radius:0}
|
.newtown-icon{width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #b4d2f0;border-top:0;box-shadow:none;background:transparent;border-radius:0}
|
||||||
|
|
||||||
.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{position:absolute;z-index:20;pointer-events:none;min-width:200px;max-width:280px;background:rgba(255,255,255,0.74);border:1px solid rgba(0,0,0,0.10);border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,0.10);backdrop-filter:blur(4px);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{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}
|
||||||
|
|
@ -71,4 +72,21 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
|
||||||
.progress-timing-row{display:flex;justify-content:space-between;gap:16px;border-top:1px solid rgba(0,0,0,0.06);padding-top:4px}
|
.progress-timing-row{display:flex;justify-content:space-between;gap:16px;border-top:1px solid rgba(0,0,0,0.06);padding-top:4px}
|
||||||
|
|
||||||
.canvas-shell.panning{cursor:grabbing}
|
.canvas-shell.panning{cursor:grabbing}
|
||||||
.canvas-shell.panning .map-canvas{pointer-events:none}
|
.map-selection-svg{position:absolute;inset:12px;z-index:18;display:none;pointer-events:none;overflow:visible}
|
||||||
|
.map-selection-svg polygon{fill:rgba(26,115,232,0.16);stroke:rgba(26,115,232,0.88);stroke-width:2;vector-effect:non-scaling-stroke;stroke-linejoin:round}
|
||||||
|
.map-selection-svg.invalid polygon{fill:rgba(179,38,30,0.14);stroke:rgba(179,38,30,0.88)}
|
||||||
|
.canvas-shell.selecting{cursor:crosshair}
|
||||||
|
.map-selection{position:absolute;z-index:18;display:none;pointer-events:none;border:2px solid rgba(26,115,232,0.86);background:rgba(26,115,232,0.16);box-shadow:0 0 0 1px rgba(255,255,255,0.70) inset,0 8px 22px rgba(26,115,232,0.20)}
|
||||||
|
.primary-button:disabled{background:#a8b6c8;color:#eef3f8;cursor:not-allowed}
|
||||||
|
.patch-status{margin:10px 0 0;color:#5f6368;font-size:12px;line-height:1.45}
|
||||||
|
.patch-status.invalid{color:#b3261e;font-weight:600}
|
||||||
|
.map-selection.invalid{border-color:rgba(179,38,30,0.88);background:rgba(179,38,30,0.14);box-shadow:0 0 0 1px rgba(255,255,255,0.70) inset,0 8px 22px rgba(179,38,30,0.18)}
|
||||||
|
|
||||||
|
.patch-variant-row{display:grid;grid-template-columns:1fr 92px;gap:8px;align-items:end;margin-top:12px}
|
||||||
|
.patch-variant-label{margin-bottom:0;align-self:center}
|
||||||
|
.patch-variant-input{padding:8px 10px;text-align:right;font-family:ui-monospace,monospace}
|
||||||
|
.patch-button-row{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:12px}
|
||||||
|
.patch-button-row .primary-button,.patch-button-row .secondary-button{margin-top:0;width:100%}
|
||||||
|
.secondary-button{border:1px solid rgba(26,115,232,0.35);border-radius:8px;padding:10px 12px;cursor:pointer;font-weight:600;background:#eef4ff;color:#1557b0;transition:background 0.2s,border-color 0.2s}
|
||||||
|
.secondary-button:hover{background:#e1edff;border-color:rgba(26,115,232,0.55)}
|
||||||
|
.secondary-button:disabled{background:#eef1f4;color:#8a98a8;border-color:rgba(0,0,0,0.08);cursor:not-allowed}
|
||||||
|
|
|
||||||
30
test.js
30
test.js
|
|
@ -15,7 +15,7 @@ const result = document.getElementById("result");
|
||||||
const logLines = [];
|
const logLines = [];
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|
||||||
const [namesSource, mapGeneratorSource, mapOutputSource, mapTerrainSource, rendererSource, appSource, mapPipelineSource, mapAdminStageSource, testSource] = await Promise.all([
|
const [namesSource, mapGeneratorSource, mapOutputSource, mapTerrainSource, rendererSource, appSource, mapPipelineSource, mapAdminStageSource, mapPatchSource, worldMapSource, municipalSource, testSource] = await Promise.all([
|
||||||
fetch("./names.js").then((response) => response.text()),
|
fetch("./names.js").then((response) => response.text()),
|
||||||
fetch("./mapGenerator.js").then((response) => response.text()),
|
fetch("./mapGenerator.js").then((response) => response.text()),
|
||||||
fetch("./mapOutput.js").then((response) => response.text()),
|
fetch("./mapOutput.js").then((response) => response.text()),
|
||||||
|
|
@ -24,6 +24,9 @@ const [namesSource, mapGeneratorSource, mapOutputSource, mapTerrainSource, rende
|
||||||
fetch("./app.js").then((response) => response.text()),
|
fetch("./app.js").then((response) => response.text()),
|
||||||
fetch("./mapPipeline.js").then((response) => response.text()),
|
fetch("./mapPipeline.js").then((response) => response.text()),
|
||||||
fetch("./mapAdminStage.js").then((response) => response.text()),
|
fetch("./mapAdminStage.js").then((response) => response.text()),
|
||||||
|
fetch("./mapPatch.js").then((response) => response.text()),
|
||||||
|
fetch("./worldMap.js").then((response) => response.text()),
|
||||||
|
fetch("./mapMunicipalCoherence.js").then((response) => response.text()),
|
||||||
fetch("./test.js").then((response) => response.text()),
|
fetch("./test.js").then((response) => response.text()),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -675,6 +678,23 @@ try {
|
||||||
const removedContextSuffixKey = "context" + "Suffixes";
|
const removedContextSuffixKey = "context" + "Suffixes";
|
||||||
assert(!namesSource.includes(removedContextSuffixConst) && !namesSource.includes(removedContextSuffixKey), "hidden context suffix arrays are absent");
|
assert(!namesSource.includes(removedContextSuffixConst) && !namesSource.includes(removedContextSuffixKey), "hidden context suffix arrays are absent");
|
||||||
assert(!/export\s+const\s+NAME_PROBABILITIES[\s\S]*export\s+const\s+NAME_PROBABILITIES/.test(namesSource), "NAME_PROBABILITIES has one source");
|
assert(!/export\s+const\s+NAME_PROBABILITIES[\s\S]*export\s+const\s+NAME_PROBABILITIES/.test(namesSource), "NAME_PROBABILITIES has one source");
|
||||||
|
assert(mapPatchSource.includes("splitWorldPathByPatch") && mapPatchSource.includes("patchAffected"), "patch path merging is alpha-aware for lasso selections");
|
||||||
|
assert(!mapPatchSource.includes("patchAlpha(x, y, rects, 0)"), "patch admin repair uses the active patch seed");
|
||||||
|
assert(mapPatchSource.includes("refreshPatchInfluenceFields") && mapPatchSource.includes("roadCellsPainted"), "patch generation refreshes derived transport influence fields after path merges");
|
||||||
|
assert(mapPatchSource.includes("normalizeGeneratedPointIds"), "patch generation normalizes generated point admin and prefecture ids");
|
||||||
|
assert(mapPatchSource.includes("generateMap(seed") && mapPatchSource.includes('patchGenerationMode = "legacy-full-pipeline"'), "patch generation remains full-pipeline simulation");
|
||||||
|
assert(mapPatchSource.includes("patchTimings") && appSource.includes("result.patchTimings"), "patch generation returns and renders timing rows");
|
||||||
|
assert(mapPatchSource.includes("PATCH_CANDIDATE_CACHE_LIMIT") && mapPatchSource.includes("patchCandidateCacheKey") && mapPatchSource.includes("cache.size > PATCH_CANDIDATE_CACHE_LIMIT"), "patch candidate cache is bounded and keyed");
|
||||||
|
assert(mapPatchSource.includes("getPatchAlphaCache") && mapPatchSource.includes("getPatchSourceIndexCache"), "patch generation caches alpha and source-index grids for merge work");
|
||||||
|
assert(mapPatchSource.includes("const searchRect = expandRect(rect, 16, world)") && mapPatchSource.includes("connectorAttempts"), "patch connector pathfinding uses bounded attempts and a shared search rect");
|
||||||
|
assert(worldMapSource.includes("shiftSelectionShape") && worldMapSource.includes("selectionShape = shiftSelectionShape"), "world expansion shifts stored lasso patch polygons");
|
||||||
|
assert(municipalSource.includes("reconcileMunicipalMetadata") && mapOutputSource.includes("reconcileMunicipalMetadata") && mapPatchSource.includes("reconcileMunicipalMetadata"), "municipal metadata is reconciled in output and patch repair");
|
||||||
|
assert(appSource.includes("mappedPref === id") && !appSource.includes("return nearestNamedAdminCenter(map, cellIndex, maxDistance, null)"), "tooltip municipal fallback requires exact coherent ids");
|
||||||
|
assert(mapPatchSource.includes("signedDist") && mapPatchSource.includes("patchBand"), "lasso patch alpha uses a feathered signed seam band");
|
||||||
|
assert(mapPatchSource.includes("repairDiscreteSeamOwnership") && mapPatchSource.includes("chooseSeamOwnerValue"), "patch admin and prefecture seams use ownership repair");
|
||||||
|
assert(mapPatchSource.includes("featherTerrainSeam") && mapPatchSource.includes("terrainFeatherCells"), "patch terrain transition bands are feather-smoothed");
|
||||||
|
assert(mapPatchSource.includes("strongOnly") && mapPatchSource.includes("patchAlpha(x, y, rects, seed)"), "patch water topology avoids weak low-alpha seam flips");
|
||||||
|
assert(!municipalSource.includes("Municipality ${id + 1}") && !municipalSource.includes("Prefecture ${id + 1}") && municipalSource.includes("自治${id + 1}") && municipalSource.includes("県域${id + 1}"), "fallback municipal and prefecture metadata avoids generic English labels");
|
||||||
|
|
||||||
assert(map.elevation.length === size, "elevation length matches map size");
|
assert(map.elevation.length === size, "elevation length matches map size");
|
||||||
assert(map.sea.length === size, "sea length matches map size");
|
assert(map.sea.length === size, "sea length matches map size");
|
||||||
|
|
@ -777,6 +797,14 @@ try {
|
||||||
assert(map.externalGateways.length > 0, "external gateways exist");
|
assert(map.externalGateways.length > 0, "external gateways exist");
|
||||||
assert(map.minorRoads.length > 0, "minor roads exist");
|
assert(map.minorRoads.length > 0, "minor roads exist");
|
||||||
assert(map.adminCenters.length >= 18, "municipality center count is sufficiently large");
|
assert(map.adminCenters.length >= 18, "municipality center count is sufficiently large");
|
||||||
|
const activeMunicipalityIds = new Set([...map.adminId].filter((id, i) => id >= 0 && !map.sea[i]));
|
||||||
|
const centerIds = new Set(map.adminCenters.map((center) => center.adminId ?? center.municipalityId ?? center.adminNumericId).filter((id) => Number.isFinite(id)));
|
||||||
|
assert(activeMunicipalityIds.size === map.adminCenters.length && [...activeMunicipalityIds].every((id) => centerIds.has(id)), "every active municipality has exactly one municipal center");
|
||||||
|
assert(map.adminCenters.every((center) => activeMunicipalityIds.has(center.adminId ?? center.municipalityId ?? center.adminNumericId)), "municipal centers do not point to inactive municipalities");
|
||||||
|
assert([...activeMunicipalityIds].every((id) => map.municipalityToPrefectureId?.[id] >= 0), "every active municipality maps to a prefecture");
|
||||||
|
const activePrefectureIds = new Set([...map.prefectureRegionId].filter((id, i) => id >= 0 && !map.sea[i]));
|
||||||
|
const prefMetadataIds = new Set((map.prefectureRegions || []).map((region) => region.id));
|
||||||
|
assert([...activePrefectureIds].every((id) => prefMetadataIds.has(id)), "every active prefecture id has metadata");
|
||||||
assert(adminMetrics.municipalityCount >= 10, "terrain snapping preserves a reasonable municipality count");
|
assert(adminMetrics.municipalityCount >= 10, "terrain snapping preserves a reasonable municipality count");
|
||||||
assert(adminMetrics.centerValidRatio >= 0.95, "municipality centers remain on valid assigned land cells");
|
assert(adminMetrics.centerValidRatio >= 0.95, "municipality centers remain on valid assigned land cells");
|
||||||
assert(adminMetrics.maxComponents <= 4, "municipal topology repair prevents excessive disconnected fragments");
|
assert(adminMetrics.maxComponents <= 4, "municipal topology repair prevents excessive disconnected fragments");
|
||||||
|
|
|
||||||
201
worldMap.js
Normal file
201
worldMap.js
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
import { MAP_H, MAP_W, SIZE } from "./mapUtils.js";
|
||||||
|
|
||||||
|
export const DEFAULT_WORLD_PADDING_X = MAP_W;
|
||||||
|
export const DEFAULT_WORLD_PADDING_Y = MAP_H;
|
||||||
|
|
||||||
|
const NEGATIVE_ONE_FIELDS = new Set([
|
||||||
|
"adminId",
|
||||||
|
"prefectureRegionId",
|
||||||
|
"regionId",
|
||||||
|
"municipalityId",
|
||||||
|
"naturalCompartmentId",
|
||||||
|
"watershedId",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isCellField(value) {
|
||||||
|
return ArrayBuffer.isView(value) && typeof value.length === "number" && value.length === SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultForField(name, Constructor) {
|
||||||
|
if (name === "sea") return 1;
|
||||||
|
if (name === "elevation") return 0.08;
|
||||||
|
if (name === "seaLevel") return undefined;
|
||||||
|
if (NEGATIVE_ONE_FIELDS.has(name)) return -1;
|
||||||
|
if (Constructor === Float32Array || Constructor === Float64Array) return 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeWorldField(name, source, worldWidth, worldHeight, originX, originY) {
|
||||||
|
const Constructor = NEGATIVE_ONE_FIELDS.has(name) ? Int32Array : source.constructor;
|
||||||
|
const out = new Constructor(worldWidth * worldHeight);
|
||||||
|
const fallback = defaultForField(name, Constructor);
|
||||||
|
if (fallback !== 0) out.fill(fallback);
|
||||||
|
|
||||||
|
for (let y = 0; y < MAP_H; y++) {
|
||||||
|
const srcRow = y * MAP_W;
|
||||||
|
const dstRow = (originY + y) * worldWidth + originX;
|
||||||
|
for (let x = 0; x < MAP_W; x++) out[dstRow + x] = source[srcRow + x];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function sanitizeInitialWorldFields(fields, width, height) {
|
||||||
|
const sea = fields.sea;
|
||||||
|
if (!sea) return;
|
||||||
|
const adminFields = ["adminId", "municipalityId", "prefectureRegionId"];
|
||||||
|
for (let i = 0; i < width * height; i++) {
|
||||||
|
const isSea = Boolean(sea[i]);
|
||||||
|
if (fields.landMask) fields.landMask[i] = isSea ? 0 : (fields.prefectureMask?.[i] ? 1 : fields.landMask[i]);
|
||||||
|
if (fields.humanRegionMask && isSea) fields.humanRegionMask[i] = 0;
|
||||||
|
if (isSea) {
|
||||||
|
for (const key of adminFields) if (fields[key]) fields[key][i] = -1;
|
||||||
|
if (fields.landuse) fields.landuse[i] = 0;
|
||||||
|
} else {
|
||||||
|
if (fields.municipalityId && fields.adminId && fields.municipalityId[i] < 0 && fields.adminId[i] >= 0) fields.municipalityId[i] = fields.adminId[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorldMap(initialMap, options = {}) {
|
||||||
|
const paddingX = Number.isFinite(options.paddingX) ? Math.max(0, Math.floor(options.paddingX)) : DEFAULT_WORLD_PADDING_X;
|
||||||
|
const paddingY = Number.isFinite(options.paddingY) ? Math.max(0, Math.floor(options.paddingY)) : DEFAULT_WORLD_PADDING_Y;
|
||||||
|
const worldWidth = MAP_W + paddingX * 2;
|
||||||
|
const worldHeight = MAP_H + paddingY * 2;
|
||||||
|
const originX = paddingX;
|
||||||
|
const originY = paddingY;
|
||||||
|
const fields = {};
|
||||||
|
|
||||||
|
for (const [name, value] of Object.entries(initialMap || {})) {
|
||||||
|
if (isCellField(value)) fields[name] = makeWorldField(name, value, worldWidth, worldHeight, originX, originY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fields.sea) {
|
||||||
|
fields.sea = new Uint8Array(worldWidth * worldHeight);
|
||||||
|
fields.sea.fill(1);
|
||||||
|
}
|
||||||
|
if (!fields.elevation) {
|
||||||
|
fields.elevation = new Float32Array(worldWidth * worldHeight);
|
||||||
|
fields.elevation.fill(0.08);
|
||||||
|
}
|
||||||
|
sanitizeInitialWorldFields(fields, worldWidth, worldHeight);
|
||||||
|
|
||||||
|
return {
|
||||||
|
seed: initialMap?.seed ?? 0,
|
||||||
|
width: worldWidth,
|
||||||
|
height: worldHeight,
|
||||||
|
originX,
|
||||||
|
originY,
|
||||||
|
sourceWidth: initialMap?.width || MAP_W,
|
||||||
|
sourceHeight: initialMap?.height || MAP_H,
|
||||||
|
sourceMap: initialMap,
|
||||||
|
fields,
|
||||||
|
invalidatedRects: [],
|
||||||
|
humanPatchHistory: [],
|
||||||
|
generatedRects: [{
|
||||||
|
x0: originX,
|
||||||
|
y0: originY,
|
||||||
|
x1: originX + MAP_W,
|
||||||
|
y1: originY + MAP_H,
|
||||||
|
terrainType: initialMap?.terrainTemplate?.terrainType || initialMap?.terrainDebug?.terrainType || "auto",
|
||||||
|
label: initialMap?.terrainTemplate?.terrainTypeLabel || initialMap?.terrainDebug?.terrainTypeLabel || "Initial generation",
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitialCamera(world) {
|
||||||
|
return {
|
||||||
|
x: world?.originX || 0,
|
||||||
|
y: world?.originY || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampCameraToWorld(camera, world, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||||
|
if (!camera || !world) return { x: 0, y: 0 };
|
||||||
|
const maxX = Math.max(0, world.width - viewWidth);
|
||||||
|
const maxY = Math.max(0, world.height - viewHeight);
|
||||||
|
return {
|
||||||
|
x: Math.min(Math.max(Math.round(camera.x || 0), 0), maxX),
|
||||||
|
y: Math.min(Math.max(Math.round(camera.y || 0), 0), maxY),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandRectByOffset(rect, dx, dy) {
|
||||||
|
if (!rect) return rect;
|
||||||
|
return { ...rect, x0: rect.x0 + dx, y0: rect.y0 + dy, x1: rect.x1 + dx, y1: rect.y1 + dy };
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftSelectionShape(shape, dx, dy) {
|
||||||
|
if (!shape?.polygon) return shape;
|
||||||
|
return {
|
||||||
|
...shape,
|
||||||
|
x0: Number.isFinite(shape.x0) ? shape.x0 + dx : shape.x0,
|
||||||
|
y0: Number.isFinite(shape.y0) ? shape.y0 + dy : shape.y0,
|
||||||
|
x1: Number.isFinite(shape.x1) ? shape.x1 + dx : shape.x1,
|
||||||
|
y1: Number.isFinite(shape.y1) ? shape.y1 + dy : shape.y1,
|
||||||
|
polygon: shape.polygon.map((p) => ({ ...p, x: p.x + dx, y: p.y + dy })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftPatchMetadata(item, dx, dy) {
|
||||||
|
const out = expandRectByOffset(item, dx, dy);
|
||||||
|
for (const sub of ["coreRect", "writeRect", "repairRect", "contextRect", "blendRect", "transportReachRect"]) {
|
||||||
|
if (out?.[sub]) out[sub] = expandRectByOffset(out[sub], dx, dy);
|
||||||
|
}
|
||||||
|
if (out?.selectionShape) out.selectionShape = shiftSelectionShape(out.selectionShape, dx, dy);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftRectCollections(world, dx, dy) {
|
||||||
|
if (!dx && !dy) return;
|
||||||
|
for (const key of ["generatedRects", "invalidatedRects", "humanPatchHistory"]) {
|
||||||
|
if (!Array.isArray(world[key])) continue;
|
||||||
|
world[key] = world[key].map((item) => shiftPatchMetadata(item, dx, dy));
|
||||||
|
}
|
||||||
|
if (world.lastPatchResult) world.lastPatchResult = shiftPatchMetadata(world.lastPatchResult, dx, dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandWorldMap(world, margins = {}) {
|
||||||
|
if (!world) return { world, dx: 0, dy: 0, expanded: false };
|
||||||
|
const left = Math.max(0, Math.floor(margins.left || 0));
|
||||||
|
const right = Math.max(0, Math.floor(margins.right || 0));
|
||||||
|
const top = Math.max(0, Math.floor(margins.top || 0));
|
||||||
|
const bottom = Math.max(0, Math.floor(margins.bottom || 0));
|
||||||
|
if (!left && !right && !top && !bottom) return { world, dx: 0, dy: 0, expanded: false };
|
||||||
|
const oldWidth = world.width;
|
||||||
|
const oldHeight = world.height;
|
||||||
|
const newWidth = oldWidth + left + right;
|
||||||
|
const newHeight = oldHeight + top + bottom;
|
||||||
|
const newFields = {};
|
||||||
|
for (const [name, field] of Object.entries(world.fields || {})) {
|
||||||
|
if (!ArrayBuffer.isView(field)) continue;
|
||||||
|
const Constructor = NEGATIVE_ONE_FIELDS.has(name) ? Int32Array : field.constructor;
|
||||||
|
const out = new Constructor(newWidth * newHeight);
|
||||||
|
const fallback = defaultForField(name, Constructor);
|
||||||
|
if (fallback !== 0) out.fill(fallback);
|
||||||
|
for (let y = 0; y < oldHeight; y++) {
|
||||||
|
const srcRow = y * oldWidth;
|
||||||
|
const dstRow = (y + top) * newWidth + left;
|
||||||
|
for (let x = 0; x < oldWidth; x++) out[dstRow + x] = field[srcRow + x];
|
||||||
|
}
|
||||||
|
newFields[name] = out;
|
||||||
|
}
|
||||||
|
world.width = newWidth;
|
||||||
|
world.height = newHeight;
|
||||||
|
world.originX += left;
|
||||||
|
world.originY += top;
|
||||||
|
world.fields = newFields;
|
||||||
|
shiftRectCollections(world, left, top);
|
||||||
|
return { world, dx: left, dy: top, expanded: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureWorldPaddingForCamera(world, camera, viewWidth = MAP_W, viewHeight = MAP_H, padding = Math.floor(Math.min(MAP_W, MAP_H) * 0.45)) {
|
||||||
|
if (!world || !camera) return { dx: 0, dy: 0, expanded: false };
|
||||||
|
const grow = Math.max(64, Math.floor(padding));
|
||||||
|
const margins = { left: 0, right: 0, top: 0, bottom: 0 };
|
||||||
|
if (camera.x < grow) margins.left = grow;
|
||||||
|
if (camera.y < grow) margins.top = grow;
|
||||||
|
if (camera.x + viewWidth > world.width - grow) margins.right = grow;
|
||||||
|
if (camera.y + viewHeight > world.height - grow) margins.bottom = grow;
|
||||||
|
return expandWorldMap(world, margins);
|
||||||
|
}
|
||||||
250
worldViewport.js
Normal file
250
worldViewport.js
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
import { MAP_H, MAP_W, SIZE } from "./mapUtils.js";
|
||||||
|
|
||||||
|
const EMPTY_ARRAY_KEYS = new Set([
|
||||||
|
"villages", "markets", "castles", "ports", "modernCities", "satelliteCities", "stations",
|
||||||
|
"interchanges", "industrialZones", "logisticsParks", "newTowns", "adminCenters",
|
||||||
|
"premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads",
|
||||||
|
"railways", "branchRailways", "ringRailways", "externalRailways", "expressways", "externalExpressways",
|
||||||
|
"mainRivers", "tributaryRivers", "smallStreams", "prefectureBorder", "regionalPrefectureBorders",
|
||||||
|
"adminBorders", "externalGateways", "prefectureRegions",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const PATH_ARRAY_KEYS = new Set([
|
||||||
|
"premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads",
|
||||||
|
"railways", "branchRailways", "ringRailways", "externalRailways", "expressways", "externalExpressways",
|
||||||
|
"mainRivers", "tributaryRivers", "smallStreams",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SEGMENT_ARRAY_KEYS = new Set([
|
||||||
|
"prefectureBorder", "regionalPrefectureBorders", "adminBorders",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const POINT_ARRAY_KEYS = new Set([
|
||||||
|
"villages", "markets", "castles", "ports", "modernCities", "satelliteCities", "stations",
|
||||||
|
"interchanges", "industrialZones", "logisticsParks", "newTowns", "adminCenters",
|
||||||
|
"externalGateways", "prefectureRegions",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const NEGATIVE_ONE_FIELDS = new Set([
|
||||||
|
"adminId",
|
||||||
|
"prefectureRegionId",
|
||||||
|
"regionId",
|
||||||
|
"municipalityId",
|
||||||
|
"naturalCompartmentId",
|
||||||
|
"watershedId",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isCellField(value) {
|
||||||
|
return ArrayBuffer.isView(value) && typeof value.length === "number" && value.length === SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultForField(name, Constructor) {
|
||||||
|
if (name === "sea") return 1;
|
||||||
|
if (name === "elevation") return 0.08;
|
||||||
|
if (NEGATIVE_ONE_FIELDS.has(name)) return -1;
|
||||||
|
if (Constructor === Float32Array || Constructor === Float64Array) return 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function worldIndex(world, x, y) {
|
||||||
|
if (!world || x < 0 || y < 0 || x >= world.width || y >= world.height) return -1;
|
||||||
|
return y * world.width + x;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyViewportField(name, source, world, camera, viewWidth, viewHeight) {
|
||||||
|
const Constructor = source.constructor;
|
||||||
|
const out = new Constructor(viewWidth * viewHeight);
|
||||||
|
const fallback = defaultForField(name, Constructor);
|
||||||
|
if (fallback !== 0) out.fill(fallback);
|
||||||
|
|
||||||
|
const cx = Math.round(camera.x || 0);
|
||||||
|
const cy = Math.round(camera.y || 0);
|
||||||
|
for (let y = 0; y < viewHeight; y++) {
|
||||||
|
for (let x = 0; x < viewWidth; x++) {
|
||||||
|
const src = worldIndex(world, cx + x, cy + y);
|
||||||
|
if (src >= 0) out[y * viewWidth + x] = source[src];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inViewportPoint(p, margin = 0, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||||
|
return p && p.x >= -margin && p.y >= -margin && p.x < viewWidth + margin && p.y < viewHeight + margin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformPointObject(point, camera, originX, originY) {
|
||||||
|
if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) return null;
|
||||||
|
return {
|
||||||
|
...point,
|
||||||
|
x: point.x + originX - camera.x,
|
||||||
|
y: point.y + originY - camera.y,
|
||||||
|
worldX: point.x + originX,
|
||||||
|
worldY: point.y + originY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformPointArray(items, camera, originX, originY, margin = 36, preserveIndexes = false, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||||
|
const mapped = (items || []).map((item) => transformPointObject(item, camera, originX, originY));
|
||||||
|
return preserveIndexes ? mapped : mapped.filter((item) => inViewportPoint(item, margin, viewWidth, viewHeight));
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformTuple(tuple, camera, originX, originY) {
|
||||||
|
if (!Array.isArray(tuple) || tuple.length < 2) return null;
|
||||||
|
return [tuple[0] + originX - camera.x, tuple[1] + originY - camera.y];
|
||||||
|
}
|
||||||
|
|
||||||
|
function tupleInside(tuple, margin = 0, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||||
|
return tuple && tuple[0] >= -margin && tuple[1] >= -margin && tuple[0] < viewWidth + margin && tuple[1] < viewHeight + margin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTransformedPath(path, camera, originX, originY, margin = 0, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||||
|
const chunks = [];
|
||||||
|
let current = [];
|
||||||
|
for (const tuple of path || []) {
|
||||||
|
const p = transformTuple(tuple, camera, originX, originY);
|
||||||
|
const inside = tupleInside(p, margin, viewWidth, viewHeight);
|
||||||
|
if (inside) {
|
||||||
|
current.push([Math.round(p[0]), Math.round(p[1])]);
|
||||||
|
} else if (current.length >= 2) {
|
||||||
|
chunks.push(current);
|
||||||
|
current = [];
|
||||||
|
} else {
|
||||||
|
current = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current.length >= 2) chunks.push(current);
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformPaths(paths, camera, originX, originY, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||||
|
const out = [];
|
||||||
|
for (const path of paths || []) out.push(...splitTransformedPath(path, camera, originX, originY, 0, viewWidth, viewHeight));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentIntersectsViewport(seg, margin = 4, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||||
|
if (!seg || seg.length < 2) return false;
|
||||||
|
const xs = [seg[0][0], seg[1][0]];
|
||||||
|
const ys = [seg[0][1], seg[1][1]];
|
||||||
|
return Math.max(...xs) >= -margin && Math.min(...xs) <= viewWidth + margin && Math.max(...ys) >= -margin && Math.min(...ys) <= viewHeight + margin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformSegments(segments, camera, originX, originY, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||||
|
return (segments || [])
|
||||||
|
.map((seg) => [transformTuple(seg?.[0], camera, originX, originY), transformTuple(seg?.[1], camera, originX, originY)])
|
||||||
|
.filter((seg) => segmentIntersectsViewport(seg, 4, viewWidth, viewHeight));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function copySourceMapViewportField(source, camera, originX, originY, viewWidth, viewHeight) {
|
||||||
|
if (!ArrayBuffer.isView(source) || typeof source.length !== "number" || source.length !== SIZE) return source;
|
||||||
|
const out = new source.constructor(viewWidth * viewHeight);
|
||||||
|
const cx = Math.round(camera.x || 0);
|
||||||
|
const cy = Math.round(camera.y || 0);
|
||||||
|
for (let y = 0; y < viewHeight; y++) {
|
||||||
|
for (let x = 0; x < viewWidth; x++) {
|
||||||
|
const sx = cx + x - originX;
|
||||||
|
const sy = cy + y - originY;
|
||||||
|
if (sx >= 0 && sy >= 0 && sx < MAP_W && sy < MAP_H) out[y * viewWidth + x] = source[sy * MAP_W + sx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformTransportDebug(debug, camera, originX, originY, viewport = null, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||||
|
if (!debug?.layers) return debug;
|
||||||
|
const layers = { ...debug.layers };
|
||||||
|
for (const [key, value] of Object.entries(layers)) {
|
||||||
|
if (ArrayBuffer.isView(value) && value.length === SIZE) layers[key] = copySourceMapViewportField(value, camera, originX, originY, viewWidth, viewHeight);
|
||||||
|
}
|
||||||
|
// The original transport-debug potential layers are fixed-map arrays. Once the
|
||||||
|
// viewport pans into patched world cells, synthesize equivalent viewport-sized
|
||||||
|
// debug fields from the current world-backed fields so the color overlay moves
|
||||||
|
// with the terrain instead of staying tied to the initial source map.
|
||||||
|
if (viewport) {
|
||||||
|
const n = viewWidth * viewHeight;
|
||||||
|
const make = (fn) => {
|
||||||
|
const out = new Float32Array(n);
|
||||||
|
for (let i = 0; i < n; i++) out[i] = fn(i);
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
const sea = viewport.sea || new Uint8Array(n);
|
||||||
|
const slope = viewport.slope || new Float32Array(n);
|
||||||
|
const plain = viewport.plain || new Float32Array(n);
|
||||||
|
const pop = viewport.populationDensity || viewport.settlementScore || new Float32Array(n);
|
||||||
|
const road = viewport.roadInfluence || new Float32Array(n);
|
||||||
|
const rail = viewport.railInfluence2 || viewport.stationInfluence || new Float32Array(n);
|
||||||
|
layers.expresswayPotential = make((i) => sea[i] ? 0 : Math.max(0, Math.min(1, road[i] * 0.72 + pop[i] * 0.42 + plain[i] * 0.22 - slope[i] * 0.52)));
|
||||||
|
layers.nationalRoadPotential = make((i) => sea[i] ? 0 : Math.max(0, Math.min(1, road[i] * 0.58 + pop[i] * 0.55 + plain[i] * 0.18 - slope[i] * 0.38)));
|
||||||
|
layers.railPotential = make((i) => sea[i] ? 0 : Math.max(0, Math.min(1, rail[i] * 0.72 + pop[i] * 0.38 + plain[i] * 0.26 - slope[i] * 0.72)));
|
||||||
|
layers.slopeSeaPenalty = make((i) => sea[i] ? 1 : Math.max(0, Math.min(1, slope[i] * 1.35)));
|
||||||
|
}
|
||||||
|
if (Array.isArray(layers.components)) {
|
||||||
|
layers.components = layers.components.map((component) => ({
|
||||||
|
...component,
|
||||||
|
cells: (component.cells || [])
|
||||||
|
.map((cell) => transformTuple(cell, camera, originX, originY))
|
||||||
|
.filter((cell) => tupleInside(cell, 0, viewWidth, viewHeight))
|
||||||
|
.map(([x, y]) => [Math.round(x), Math.round(y)]),
|
||||||
|
})).filter((component) => component.cells.length);
|
||||||
|
}
|
||||||
|
if (Array.isArray(layers.repairedSegments)) {
|
||||||
|
layers.repairedSegments = layers.repairedSegments.flatMap((repair) => transformPaths([repair.path || []], camera, originX, originY, viewWidth, viewHeight).map((path) => ({ ...repair, path })));
|
||||||
|
}
|
||||||
|
if (Array.isArray(layers.unservedSettlements)) layers.unservedSettlements = transformPointArray(layers.unservedSettlements, camera, originX, originY, 36, false, viewWidth, viewHeight);
|
||||||
|
return { ...debug, layers };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmptyViewportFromSource(sourceMap, world, camera, viewWidth, viewHeight) {
|
||||||
|
const viewport = { ...sourceMap };
|
||||||
|
viewport.width = viewWidth;
|
||||||
|
viewport.height = viewHeight;
|
||||||
|
viewport.worldCamera = { x: camera.x, y: camera.y };
|
||||||
|
viewport.worldOrigin = { x: world.originX, y: world.originY };
|
||||||
|
viewport.generatedRects = world.generatedRects || [];
|
||||||
|
for (const key of EMPTY_ARRAY_KEYS) if (Array.isArray(viewport[key])) viewport[key] = [];
|
||||||
|
return viewport;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getViewportMap(world, camera, viewWidth = MAP_W, viewHeight = MAP_H, options = {}) {
|
||||||
|
const sourceMap = world?.sourceMap || {};
|
||||||
|
const normalizedCamera = {
|
||||||
|
x: Math.round(camera?.x || 0),
|
||||||
|
y: Math.round(camera?.y || 0),
|
||||||
|
};
|
||||||
|
const viewport = buildEmptyViewportFromSource(sourceMap, world, normalizedCamera, viewWidth, viewHeight);
|
||||||
|
|
||||||
|
for (const [name, value] of Object.entries(world?.fields || {})) {
|
||||||
|
viewport[name] = copyViewportField(name, value, world, normalizedCamera, viewWidth, viewHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.light) return viewport;
|
||||||
|
|
||||||
|
const originX = world?.originX || 0;
|
||||||
|
const originY = world?.originY || 0;
|
||||||
|
for (const key of POINT_ARRAY_KEYS) {
|
||||||
|
if (!Array.isArray(sourceMap[key])) continue;
|
||||||
|
viewport[key] = transformPointArray(sourceMap[key], normalizedCamera, originX, originY, 36, key === "adminCenters", viewWidth, viewHeight);
|
||||||
|
}
|
||||||
|
for (const key of PATH_ARRAY_KEYS) if (Array.isArray(sourceMap[key])) viewport[key] = transformPaths(sourceMap[key], normalizedCamera, originX, originY, viewWidth, viewHeight);
|
||||||
|
for (const key of SEGMENT_ARRAY_KEYS) if (Array.isArray(sourceMap[key])) viewport[key] = transformSegments(sourceMap[key], normalizedCamera, originX, originY, viewWidth, viewHeight);
|
||||||
|
|
||||||
|
if (sourceMap.adminDebug) {
|
||||||
|
viewport.adminDebug = {
|
||||||
|
...sourceMap.adminDebug,
|
||||||
|
compartmentBorders: transformSegments(sourceMap.adminDebug.compartmentBorders || [], normalizedCamera, originX, originY, viewWidth, viewHeight),
|
||||||
|
lowlandAdminSeeds: transformPointArray(sourceMap.adminDebug.lowlandAdminSeeds || [], normalizedCamera, originX, originY, 36, false, viewWidth, viewHeight),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (sourceMap.transportDebug) viewport.transportDebug = transformTransportDebug(sourceMap.transportDebug, normalizedCamera, originX, originY, viewport, viewWidth, viewHeight);
|
||||||
|
if (sourceMap.neighborPrefectureDetails) {
|
||||||
|
viewport.neighborPrefectureDetails = {
|
||||||
|
...sourceMap.neighborPrefectureDetails,
|
||||||
|
cities: transformPointArray(sourceMap.neighborPrefectureDetails.cities || [], normalizedCamera, originX, originY, 36, false, viewWidth, viewHeight),
|
||||||
|
adminCenters: transformPointArray(sourceMap.neighborPrefectureDetails.adminCenters || [], normalizedCamera, originX, originY, 36, false, viewWidth, viewHeight),
|
||||||
|
roads: transformPaths(sourceMap.neighborPrefectureDetails.roads || [], normalizedCamera, originX, originY, viewWidth, viewHeight),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return viewport;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue