diff --git a/adminRegionsCore.js b/adminRegionsCore.js
index 04f902c..2dd8ebc 100644
--- a/adminRegionsCore.js
+++ b/adminRegionsCore.js
@@ -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) {
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);
diff --git a/app.js b/app.js
index b9bdc54..6eab3ad 100644
--- a/app.js
+++ b/app.js
@@ -1,6 +1,10 @@
import { generateMapAsync } from "./mapGenerator.js";
import { drawMap } from "./renderer.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 = [
["all", "All"],
@@ -16,89 +20,487 @@ const modes = [
const state = {
seedText: "114514",
+ generationType: "auto",
mode: "all",
showFeatures: true,
showLabels: true,
map: null,
+ world: null,
+ camera: { x: 0, y: 0 },
+ viewportMap: null,
+ viewWidth: MAP_W,
+ viewHeight: MAP_H,
hoverEntities: [],
+ selectionRect: null,
+ patchVariant: 0,
+ zoom: 1,
+ lastPatchResult: null,
};
const canvas = document.getElementById("mapCanvas");
const canvasShell = document.querySelector(".canvas-shell");
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 showFeaturesInput = document.getElementById("showFeatures");
const showLabelsInput = document.getElementById("showLabels");
const modeGrid = document.getElementById("modeGrid");
const statsEl = document.getElementById("stats");
const tooltipEl = document.getElementById("mapTooltip");
+const selectionSvgEl = document.getElementById("mapSelectionSvg");
+const selectionEl = document.getElementById("mapSelection");
const progressEl = document.getElementById("generationProgress");
const progressStageEl = document.getElementById("generationProgressStage");
const progressTimingsEl = document.getElementById("generationProgressTimings");
let generationStartedAt = 0;
let generationCurrentStage = "";
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) {
- if (!state.map) return null;
- const rect = canvas.getBoundingClientRect();
- if (!rect.width || !rect.height) return null;
- const relX = (event.clientX - rect.left) / rect.width;
- const relY = (event.clientY - rect.top) / rect.height;
+function activeMap() {
+ return state.viewportMap || state.map;
+}
+
+function clampZoom(value) {
+ 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 {
- x: Math.floor(relX * state.map.width),
- y: Math.floor(relY * state.map.height),
+ width: Math.max(1, Math.ceil(MAP_W / z)),
+ height: Math.max(1, Math.ceil(MAP_H / z)),
};
}
-function isEditableTarget(target) {
- if (!target) return false;
- const tag = target.tagName?.toLowerCase?.();
- return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
+function clampCameraForView(camera, size = viewportSizeForZoom(state.zoom)) {
+ return clampCameraToWorld(camera, state.world, size?.width || MAP_W, size?.height || MAP_H);
}
-function panFrame(time) {
- if (!canvasShell || panState.keys.size === 0) {
- panState.raf = null;
- panState.lastTime = 0;
+function syncViewportSize() {
+ const size = viewportSizeForZoom(state.zoom);
+ state.viewWidth = size.width;
+ 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;
}
- const dt = panState.lastTime ? Math.min(0.05, (time - panState.lastTime) / 1000) : 0;
- panState.lastTime = time;
- let dx = 0;
- let dy = 0;
- if (panState.keys.has("a")) dx -= 1;
- if (panState.keys.has("d")) dx += 1;
- if (panState.keys.has("w")) dy -= 1;
- if (panState.keys.has("s")) dy += 1;
- if (dx || dy) {
- const normalizer = dx && dy ? Math.SQRT1_2 : 1;
- const amount = panState.speedPxPerSecond * dt;
- canvasShell.scrollLeft += dx * normalizer * amount;
- canvasShell.scrollTop += dy * normalizer * amount;
- tooltipEl?.classList.remove("visible");
+ const pts = points.map((p) => `${p.x},${p.y}`).join(" ");
+ selectionSvgEl.setAttribute("viewBox", `0 0 ${canvas.clientWidth || canvas.width || 1} ${canvas.clientHeight || canvas.height || 1}`);
+ selectionSvgEl.innerHTML = ` `;
+ selectionSvgEl.style.display = "block";
+ selectionSvgEl.classList.toggle("invalid", !!invalid);
+}
+
+function hideSelectionSvg() {
+ if (!selectionSvgEl) return;
+ selectionSvgEl.style.display = "none";
+ selectionSvgEl.innerHTML = "";
+ selectionSvgEl.classList.remove("invalid");
+}
+
+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() {
- if (panState.raf == null) panState.raf = requestAnimationFrame(panFrame);
+function updateSelectionOverlayFromWorldRect() {
+ 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) {
- const key = event.key?.toLowerCase?.();
- if (!key || !"wasd".includes(key) || isEditableTarget(event.target)) return;
- panState.keys.add(key);
- startKeyboardPan();
+function formatRectSize(rect) {
+ if (!rect) return "-";
+ const w = Math.max(0, rect.x1 - rect.x0);
+ const h = Math.max(0, rect.y1 - rect.y0);
+ 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();
}
-function handlePanKeyUp(event) {
- const key = event.key?.toLowerCase?.();
- if (!key || !"wasd".includes(key)) return;
- panState.keys.delete(key);
+function handleMapPointerMove(event) {
+ if (!dragState.mode || dragState.pointerId !== event.pointerId || !canvasShell) return;
+ tooltipEl?.classList.remove("visible");
+
+ 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();
}
@@ -253,7 +655,8 @@ function buildHoverEntities(map) {
function nearestEntity(items, x, y, maxDistance = 5) {
let best = null;
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);
if (d < bestD) { best = item; bestD = d; }
}
@@ -264,46 +667,140 @@ function landuseName(value) {
return landuseLabel(value);
}
-function adminName(map, adminId) {
- const center = (map.adminCenters || [])[adminId];
- return center?.name || (adminId >= 0 ? `Municipality ${adminId + 1}` : "-");
+const ADMIN_ID_KEYS = ["adminId", "municipalityId", "adminNumericId", "id", "numericId"];
+const PREFECTURE_ID_KEYS = ["prefectureRegionId", "prefectureId", "id", "numericId"];
+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) {
- const center = (map.adminCenters || [])[adminId];
- return Number.isFinite(center?.municipalityPopulation) ? center.municipalityPopulation : null;
+function hasNumericId(item, id, keys = ADMIN_ID_KEYS) {
+ if (!item || id == null || id < 0) return false;
+ 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) {
const id = map.prefectureRegionId?.[i] ?? -1;
- const region = (map.prefectureRegions || []).find((p) => p.id === id);
- return region?.name || (id >= 0 ? `Prefecture ${id + 1}` : "-");
+ const region = (map.prefectureRegions || []).find((p) => hasNumericId(p, id, PREFECTURE_ID_KEYS));
+ 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) {
- if (!state.map || !tooltipEl) return;
+ const map = activeMap();
+ if (!map || !tooltipEl || dragState.mode) return;
const rect = canvas.getBoundingClientRect();
const cell = mapClientToCell(event);
if (!cell) return;
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");
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 elevation = state.map.elevation?.[i] ?? 0;
- const density = state.map.populationDensity?.[i] ?? 0;
- const hoveredAdminId = state.map.adminId?.[i] ?? -1;
- const hoveredAdminPopulation = adminPopulation(state.map, hoveredAdminId);
+ const elevation = map.elevation?.[i] ?? 0;
+ const density = map.populationDensity?.[i] ?? map.settlementScore?.[i] ?? 0;
+ const hoveredAdminId = map.adminId?.[i] ?? -1;
+ 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 = [
- `${entity ? `${entity.name} / ${entity.kind}` : `${x}, ${y}`} `,
- `Prefecture: ${prefectureNameForCell(state.map, i)}`,
- `Admin: ${adminName(state.map, hoveredAdminId)}`,
+ `${entityTitle} `,
+ `Prefecture: ${prefectureNameForCell(map, i)}`,
+ `Admin: ${adminName(map, hoveredAdminId, i)}`,
`Admin Pop: ${hoveredAdminPopulation === null ? "-" : hoveredAdminPopulation.toLocaleString()}`,
- `Land: ${state.map.sea?.[i] ? "Sea" : landuseName(state.map.landuse?.[i])}`,
- `Elevation: ${elevation.toFixed(3)} / Slope: ${(state.map.slope?.[i] ?? 0).toFixed(3)}`,
- `River: ${(state.map.river?.[i] ?? 0).toFixed(2)} / Density: ${density.toFixed(2)}`,
+ `Land: ${map.sea?.[i] ? "Sea" : landuseName(map.landuse?.[i])}`,
+ `Elevation: ${elevation.toFixed(3)} / Slope: ${(map.slope?.[i] ?? 0).toFixed(3)}`,
+ `River: ${(map.river?.[i] ?? 0).toFixed(2)} / Density: ${density.toFixed(2)}`,
];
if (entity?.population) lines.splice(1, 0, `Population: ${entity.population.toLocaleString()}`);
tooltipEl.innerHTML = lines.join(" ");
@@ -336,11 +833,16 @@ function renderModeButtons() {
async function regenerate() {
state.seedText = seedInput.value;
+ state.generationType = generationTypeInput?.value || "auto";
setProgressVisible(true, "Preparing generation...");
await nextFrame();
try {
- state.map = await generateMapAsync(parseSeed(state.seedText), { onProgress: updateGenerationProgress });
- state.hoverEntities = buildHoverEntities(state.map);
+ state.map = await generateMapAsync(parseSeed(state.seedText), { onProgress: updateGenerationProgress, terrainType: state.generationType });
+ state.world = createWorldMap(state.map);
+ state.camera = createInitialCamera(state.world);
+ state.lastPatchResult = null;
+ resetPatchVariant({ update: false });
+ hideSelectionOverlay();
renderStats(state.map);
redraw();
if (progressStageEl) progressStageEl.textContent = `Done in ${formatMs(state.map.generationTotalMs || 0)}`;
@@ -352,13 +854,133 @@ async function regenerate() {
}
}
-function redraw() {
- if (!state.map) return;
- drawMap(canvas, state.map, {
+
+function derivePatchSeed(rect, terrainType, variant = 0) {
+ 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,
- showFeatures: state.showFeatures,
- showLabels: state.showLabels,
+ showFeatures: state.showFeatures && !options.fastTerrain,
+ 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() {
@@ -369,6 +991,22 @@ function init() {
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", () => {
seedInput.value = String(Math.floor(Math.random() * 9999999));
regenerate();
@@ -385,13 +1023,18 @@ function init() {
});
canvasShell?.setAttribute("tabindex", "0");
- window.addEventListener("keydown", handlePanKeyDown);
- window.addEventListener("keyup", handlePanKeyUp);
+ canvas.addEventListener("contextmenu", (event) => event.preventDefault());
+ 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("mouseleave", () => {
tooltipEl?.classList.remove("visible");
});
+ updatePatchControls();
regenerate();
}
diff --git a/index.html b/index.html
index 1179b32..a0b500d 100644
--- a/index.html
+++ b/index.html
@@ -13,12 +13,14 @@
+
+
Generating map...
Preparing
@@ -32,9 +34,43 @@
+
+
+ Patch Generation
+ Patch Terrain Type
+
+ Auto
+ Tohoku spine
+ Chubu mountain
+ Setouchi inland sea
+ Oceanic archipelago
+ Kanto alluvial plain
+ Mixed archipelago
+
+
+ Patch Variant
+
+
+
+ Generate Selected Area
+ Alternative
+
+ Right-drag to lasso a freeform patch area.
+
+
Display Layers
@@ -77,7 +113,7 @@
Notes
- Open index.html with Live Server. Open test.html to run browser tests.
+ Open index.html with Live Server. Left-drag pans the viewport; right-drag draws a freeform regeneration area; use Patch Generation to write terrain into that area.
Add preferred reusable place names in CUSTOM_NAME_LIST inside names.js.
diff --git a/mapAdminStage.js b/mapAdminStage.js
index 49a9cb2..fe94308 100644
--- a/mapAdminStage.js
+++ b/mapAdminStage.js
@@ -4,6 +4,7 @@ import {
lockSmallUrbanComponentsToMunicipality,
mergeTinyMunicipalities,
removeMunicipalExclaves,
+ enforceMunicipalityConnectivityStrict,
smoothAdminRegionsTerrainAware,
snapAdminBoundariesToTerrain,
} from "./adminRegions.js";
@@ -162,8 +163,10 @@ function generateAdminLayoutForMask({
});
const changedAfterFinalCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
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 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 adminDebug = {
...compartmentAssignment.debug,
@@ -179,6 +182,8 @@ function generateAdminLayoutForMask({
targetMunicipalityCount,
actualMunicipalityCount,
finalMunicipalityCount: actualMunicipalityCount,
+ changedAfterStrictMunicipalityConnectivity: strictConnectivityChangedCells,
+ changedAfterStrictMunicipalityEnclaveRepair: strictEnclaveRepairChangedCells,
candidateSeedCount: adminCentersRaw.length,
municipalOfficePointCount: compacted.adminCentersRaw.length,
seedCellRevivalCount: 0,
@@ -436,6 +441,8 @@ function generateAdminLayoutForMask({
adminDebug.changedAfterUrbanUnification = lockCompactUrbanAreasToDominantAdmin(adminId, prefectureMask, sea, landuse, populationDensity, 980);
adminDebug.changedAfterAdminEnclaveRepair = repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, 6);
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 satelliteAreas = [];
diff --git a/mapFeatureContext.js b/mapFeatureContext.js
new file mode 100644
index 0000000..cbfc2d6
--- /dev/null
+++ b/mapFeatureContext.js
@@ -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,
+ };
+}
\ No newline at end of file
diff --git a/mapFeatureLanduse.js b/mapFeatureLanduse.js
new file mode 100644
index 0000000..42b346d
--- /dev/null
+++ b/mapFeatureLanduse.js
@@ -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 };
+}
diff --git a/mapFeatureSettlements.js b/mapFeatureSettlements.js
new file mode 100644
index 0000000..2b48307
--- /dev/null
+++ b/mapFeatureSettlements.js
@@ -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,
+ };
+}
\ No newline at end of file
diff --git a/mapFeatureTransportTools.js b/mapFeatureTransportTools.js
new file mode 100644
index 0000000..2d4cb92
--- /dev/null
+++ b/mapFeatureTransportTools.js
@@ -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 };
+ }
+
diff --git a/mapFeatures.js b/mapFeatures.js
index 254c77d..9ffaada 100644
--- a/mapFeatures.js
+++ b/mapFeatures.js
@@ -1,8 +1,12 @@
-import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, fbm, hash2, indexOf, inside, pickEntities, rand, valueNoise, xyOf } from "./mapUtils.js";
-import { distanceToNearest, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js";
-import { LANDUSE } from "./landuseCodes.js";
+import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, valueNoise, xyOf } from "./mapUtils.js";
+import { createPointSpatialIndex, distanceToNearest, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js";
import { buildDensityFlowRoadTransportSystem, createPathInfluenceCache, packDebugField, pathAverageField, pathLengthCells, routeQualityAcceptable } from "./mapTransport.js";
import { buildUnifiedRailODNetwork } from "./mapTransportOD.js";
+import { buildFeatureContext } from "./mapFeatureContext.js";
+import { buildFeatureLanduse } from "./mapFeatureLanduse.js";
+import { buildSettlementDemandFields } from "./mapFeatureSettlements.js";
+import { buildFeatureTransportCostFields } from "./mapFeatureTransportTools.js";
+import { buildCoarseCostGraph, refineCoarsePath, routeCoarsePath } from "./mapTransportGraph.js";
// Lightweight Human Geography V2
// --------------------------------
@@ -14,9 +18,18 @@ import { buildUnifiedRailODNetwork } from "./mapTransportOD.js";
// 4. synthesize population and land-use fields in one raster pass
export function generateMapFeatures(seed, terrain) {
+ const SPEED_TOLERANCE = 0.90;
+ const featureTimings = [];
+ const nowMs = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
+ let timingMark = nowMs();
+ function markFeatureTiming(key) {
+ const t = nowMs();
+ featureTimings.push({ key, ms: Math.round((t - timingMark) * 10) / 10 });
+ timingMark = t;
+ }
+
const {
elevation,
- moisture,
slope,
sea,
river,
@@ -28,290 +41,42 @@ export function generateMapFeatures(seed, terrain) {
basinField,
coastalLowland,
flowAccum,
- arcSpineField,
- branchRidgeField,
depositionalLowland,
- alluvialFanField,
deltaField,
portSuitability,
crossingSuitability,
passSuitability,
- 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 });
- }
-
+ const featureContext = buildFeatureContext(seed, terrain);
+ const {
+ geoHabitability,
+ geoAccessibility,
+ geoNaturalCentrality,
+ geoLowlandCapacity,
+ geoValleyAccess,
+ geoCoastalAccess,
+ geoBarrier,
+ fieldValue,
+ regionIdAt,
+ inFocusedPrefecture,
+ developable,
+ ruralSuitability,
+ townSuitability,
+ valleySettlement,
+ coastalSettlement,
+ confluenceField,
+ barrierCost,
+ corridorCost,
+ settlementCluster,
+ settlementScore,
+ regionStats,
+ visibilityFactor,
+ pickRegionalPoints,
+ pickGlobalPoints,
+ } = featureContext;
+ markFeatureTiming("context");
// --- 2. Sparse points ----------------------------------------------------
let ports = pickGlobalPoints(portSuitability || coastalSettlement, {
threshold: 0.30 + rand(seed, 1001) * 0.08,
@@ -331,6 +96,8 @@ export function generateMapFeatures(seed, terrain) {
ports[0].kind = "Major Port";
}
const commercialPorts = ports.filter((p) => p.portClass === "major" || p.portClass === "regional");
+ const portIndex = createPointSpatialIndex(ports, 12);
+ const commercialPortIndex = createPointSpatialIndex(commercialPorts, 12);
const crossings = pickGlobalPoints(crossingSuitability || confluenceField, {
threshold: 0.30 + rand(seed, 1011) * 0.06,
@@ -339,6 +106,7 @@ export function generateMapFeatures(seed, terrain) {
seedOffset: 1010,
predicate: (x, y, i) => river[i] > 0.12 || confluenceField[i] > 0.09,
}).map((p) => ({ ...p, kind: "River Crossing" }));
+ const crossingIndex = createPointSpatialIndex(crossings, 8);
const passes = pickGlobalPoints(passSuitability || valleySettlement, {
threshold: 0.18 + rand(seed, 1021) * 0.06,
@@ -446,6 +214,7 @@ export function generateMapFeatures(seed, terrain) {
const open = Math.max(0, plain[i] * 0.92 + agriculture[i] * 0.72 + basinField[i] * 0.26 + (depositionalLowland?.[i] || 0) * 0.20 - river[i] * 0.22 - valleyField[i] * 0.10 - flowAccum[i] * 0.08 - slope[i] * 0.24 - ridgeField[i] * 0.14);
openPlainVillageScore[i] = clamp(open * 0.82 + settlementCluster[i] * 0.14 + ruralSuitability[i] * 0.16 + fieldValue(geoHabitability, i, developable[i]) * 0.16 + fieldValue(geoLowlandCapacity, i, plain[i]) * 0.12 - geographicAnchorInfluence[i] * 0.05);
}
+ const initialVillageIndex = createPointSpatialIndex(villages, 8);
const supplementalPlainVillages = pickRegionalPoints(openPlainVillageScore, {
stride: 2,
threshold: 0.235 + rand(seed, 1036) * 0.020,
@@ -463,7 +232,7 @@ export function generateMapFeatures(seed, terrain) {
return Math.round(clamp(raw + rand(seed, 1037 + regionId * 29) * 1.4, min, max));
},
extraScore: (x, y, i) => Math.max(0, plain[i] * 0.34 + agriculture[i] * 0.26 - river[i] * 0.20 - valleyField[i] * 0.12),
- }).filter((p) => distanceToNearest(villages, p.x, p.y) >= 6.5)
+ }).filter((p) => !initialVillageIndex.hasWithin(p.x, p.y, 6.5))
.map((p, n) => {
const i = indexOf(p.x, p.y);
const population = Math.round((1100 + Math.pow(rand(seed, 18220 + n * 31 + p.x * 7 + p.y), 1.12) * 7600 + agriculture[i] * 3900 + plain[i] * 2200) / 100) * 100;
@@ -480,8 +249,8 @@ export function generateMapFeatures(seed, terrain) {
if (sea[i]) continue;
const openPlainMarket = Math.max(0, plain[i] * 0.68 + agriculture[i] * 0.52 + basinField[i] * 0.24 + (depositionalLowland?.[i] || 0) * 0.18 - river[i] * 0.16 - valleyField[i] * 0.06 - slope[i] * 0.18);
const featurePull = Math.max(
- distanceToNearest(ports, x, y) < 10 ? 0.16 : 0,
- distanceToNearest(crossings, x, y) < 6 ? 0.035 : 0,
+ portIndex.hasWithin(x, y, 10) ? 0.16 : 0,
+ crossingIndex.hasWithin(x, y, 6) ? 0.035 : 0,
confluenceField[i] * 0.08
);
const valleyMouth = valleyField[i] > 0.22 && (plain[i] > 0.22 || basinField[i] > 0.18 || coastalLowland[i] > 0.18) ? 0.14 : 0;
@@ -525,10 +294,10 @@ export function generateMapFeatures(seed, terrain) {
const max = st.area > 3600 ? 20 : st.area > 2200 ? 14 : st.area > 800 ? 7 : 3;
return Math.round(clamp(raw + rand(seed, 1043 + regionId * 23) * 0.8, min, max));
},
- extraScore: (x, y, i) => (distanceToNearest(commercialPorts, x, y) < 10 ? 0.12 : 0) + coastalSettlement[i] * 0.08 + Math.max(0, plain[i] * 0.32 + agriculture[i] * 0.20 - river[i] * 0.16) * 0.09 + confluenceField[i] * 0.035,
+ extraScore: (x, y, i) => (commercialPortIndex.hasWithin(x, y, 10) ? 0.12 : 0) + coastalSettlement[i] * 0.08 + Math.max(0, plain[i] * 0.32 + agriculture[i] * 0.20 - river[i] * 0.16) * 0.09 + confluenceField[i] * 0.035,
}).map((p, n) => {
const i = indexOf(p.x, p.y);
- const kind = coastalSettlement[i] > 0.38 && distanceToNearest(ports, p.x, p.y) < 11 ? "Port Town" : valleySettlement[i] > 0.48 ? "Valley Market Town" : "Market Town";
+ const kind = coastalSettlement[i] > 0.38 && portIndex.hasWithin(p.x, p.y, 11) ? "Port Town" : valleySettlement[i] > 0.48 ? "Valley Market Town" : "Market Town";
const population = Math.round((9000 + Math.pow(rand(seed, 18100 + n * 19 + p.x * 5 + p.y), 1.02) * 70000 + marketScore[i] * 40000 + villageInfluence[i] * 7800 + Math.max(0, plain[i] * 0.48 + agriculture[i] * 0.30 + basinField[i] * 0.18 - river[i] * 0.14) * 22000 + coastalSettlement[i] * 12000) / 1000) * 1000;
return { ...p, kind, population };
});
@@ -539,6 +308,8 @@ export function generateMapFeatures(seed, terrain) {
const open = Math.max(0, plain[i] * 0.86 + agriculture[i] * 0.64 + basinField[i] * 0.28 + (depositionalLowland?.[i] || 0) * 0.22 - river[i] * 0.20 - valleyField[i] * 0.10 - flowAccum[i] * 0.08 - slope[i] * 0.24 - ridgeField[i] * 0.16);
openPlainMarketScore[i] = clamp(open * 0.78 + townSuitability[i] * 0.16 + fieldValue(geoNaturalCentrality, i, townSuitability[i]) * 0.18 + fieldValue(geoHabitability, i, developable[i]) * 0.12 + villageInfluence[i] * 0.16 + settlementCluster[i] * 0.10 + geographicAnchorInfluence[i] * 0.05);
}
+ const preSupplementalMarketIndex = createPointSpatialIndex(markets, 12);
+ const villageIndexForMarketSpacing = createPointSpatialIndex(villages, 8);
const supplementalPlainMarkets = pickRegionalPoints(openPlainMarketScore, {
stride: 2,
threshold: 0.335 + rand(seed, 1046) * 0.025,
@@ -556,13 +327,15 @@ export function generateMapFeatures(seed, terrain) {
return Math.round(clamp(raw + rand(seed, 1047 + regionId * 31) * 0.9, min, max));
},
extraScore: (x, y, i) => Math.max(0, plain[i] * 0.24 + agriculture[i] * 0.18 - river[i] * 0.14 - valleyField[i] * 0.08),
- }).filter((p) => distanceToNearest(markets, p.x, p.y) >= 10.5 && distanceToNearest(villages, p.x, p.y) >= 4.5)
+ }).filter((p) => !preSupplementalMarketIndex.hasWithin(p.x, p.y, 10.5) && !villageIndexForMarketSpacing.hasWithin(p.x, p.y, 4.5))
.map((p, n) => {
const i = indexOf(p.x, p.y);
const population = Math.round((10000 + Math.pow(rand(seed, 18340 + n * 37 + p.x * 11 + p.y), 1.02) * 52000 + openPlainMarketScore[i] * 26000 + agriculture[i] * 9000 + plain[i] * 8000) / 1000) * 1000;
return { ...p, kind: "Plain Market Town", population };
});
markets = [...markets, ...supplementalPlainMarkets];
+ let marketIndex = createPointSpatialIndex(markets, 12);
+ let villageIndex = createPointSpatialIndex(villages, 8);
// Sparse-area towns: when a developable basin/plain/coast has few nearby towns,
// add a small market town candidate. This avoids large inhabited regions being
@@ -577,6 +350,10 @@ export function generateMapFeatures(seed, terrain) {
const livable = clamp(townSuitability[i] * 0.34 + fieldValue(geoNaturalCentrality, i, townSuitability[i]) * 0.22 + fieldValue(geoHabitability, i, developable[i]) * 0.20 + fieldValue(geoAccessibility, i, 0) * 0.12 + developable[i] * 0.22 + plain[i] * 0.24 + agriculture[i] * 0.18 + basinField[i] * 0.16 + coastalSettlement[i] * 0.14 + valleySettlement[i] * 0.12 - fieldValue(geoBarrier, i, 0) * 0.12 - slope[i] * 0.22 - ridgeField[i] * 0.10);
sparseTownScore[i] = clamp(livable * (0.42 + remoteness * 0.88) + settlementGap * 0.16);
}
+ const preSparseMarketIndex = marketIndex;
+ const preSparseVillageIndex = villageIndex;
+ const marketCountByRegion = new Map();
+ for (const m of markets) marketCountByRegion.set(m.regionId, (marketCountByRegion.get(m.regionId) || 0) + 1);
const sparseMarkets = pickRegionalPoints(sparseTownScore, {
stride: 2,
threshold: 0.315 + rand(seed, 1049) * 0.025,
@@ -588,19 +365,20 @@ export function generateMapFeatures(seed, terrain) {
quotaForRegion: (regionId, st) => {
if (!st || st.developableCells < 90) return 0;
const vf = visibilityFactor(regionId, st);
- const underServed = clamp(1.0 - ((markets.filter((m) => m.regionId === regionId).length || 0) / Math.max(1, st.area / 850)));
+ const underServed = clamp(1.0 - ((marketCountByRegion.get(regionId) || 0) / Math.max(1, st.area / 850)));
const raw = (st.developableCells / 900 + st.plainCells / 720 + st.coastCells / 560 + 0.55) * vf * (0.55 + underServed * 0.75);
return Math.round(clamp(raw + rand(seed, 1050 + regionId * 37) * 0.45, 0, st.area > 2000 ? 2 : 1));
},
extraScore: (x, y, i) => clamp((0.34 - existingTownInfluenceForSparseFill[i]) * 0.38 + plain[i] * 0.10 + agriculture[i] * 0.08 + coastalSettlement[i] * 0.06),
})
- .filter((p) => distanceToNearest(markets, p.x, p.y) >= 12 && distanceToNearest(villages, p.x, p.y) >= 4.5)
+ .filter((p) => !preSparseMarketIndex.hasWithin(p.x, p.y, 12) && !preSparseVillageIndex.hasWithin(p.x, p.y, 4.5))
.map((p, n) => {
const i = indexOf(p.x, p.y);
const population = Math.round((8000 + Math.pow(rand(seed, 18480 + n * 41 + p.x * 13 + p.y), 1.08) * 36000 + sparseTownScore[i] * 26000) / 1000) * 1000;
return { ...p, kind: "Sparse Market Town", population };
});
markets = [...markets, ...sparseMarkets];
+ marketIndex = createPointSpatialIndex(markets, 12);
const defenseScore = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
@@ -724,7 +502,7 @@ export function generateMapFeatures(seed, terrain) {
// regional pass.
modernCities.sort((a, b) => b.capacity - a.capacity || b.score - a.score);
- const regionalCapitalSlots = Math.max(1, Math.min(4, Math.round(Math.sqrt(Math.max(1, modernCities.length)))));
+ const regionalCapitalSlots = Math.max(1, Math.min(2, Math.floor(Math.sqrt(Math.max(1, modernCities.length)) / 2)));
for (const [rank, city] of modernCities.entries()) {
const i = indexOf(city.x, city.y);
const st = regionStats.get(city.regionId);
@@ -736,11 +514,35 @@ export function generateMapFeatures(seed, terrain) {
fieldValue(geoAccessibility, i, 0) * 0.18 +
Math.log10((city.capacity || 26000) + 1) / 7 * 0.26
);
- const isTopCenter = rank < regionalCapitalSlots || geoTierScore > 0.58;
+ const isTopCenter = rank < regionalCapitalSlots || geoTierScore > 0.8;
const isRegionalCapital = isFirstInRegion && (isTopCenter || (city.capacity || 0) > 210000 || (st?.highCentralityCells || 0) > 220);
- const rawPop = isRegionalCapital
- ? 180000 + rand(seed, 12201 + city.regionId * 17) * (isTopCenter ? 760000 : 360000)
- : 52000 + Math.pow(rand(seed, 12202 + rank * 19 + city.x), 0.68) * 360000;
+const u = rand(st.seed, city.x, city.y, 9101);
+const v = rand(st.seed, city.x, city.y, 9102);
+const w = rand(st.seed, city.x, city.y, 9103);
+
+let rawPop;
+
+if (isRegionalCapital) {
+ if (isTopCenter) {
+ // largest 3M - 11M
+ rawPop =
+ 3000000 +
+ Math.pow(u, 0.42) * 5200000 +
+ Math.pow(v, 3.2) * 2800000;
+ } else {
+ // larger 0.25M - 2.5M
+ rawPop =
+ 250000 +
+ Math.pow(u, 0.55) * 1450000 +
+ Math.pow(v, 2.4) * 900000;
+ }
+} else {
+ // normal 5k - 0.75k
+ rawPop =
+ 52000 +
+ Math.pow(u, 0.72) * 520000 +
+ Math.pow(v, 3.0) * 320000;
+}
const capMultiplier = isRegionalCapital ? (isTopCenter ? 1.66 : 1.42) : 1.20;
const population = Math.round(Math.min(rawPop, city.capacity * capMultiplier) / 1000) * 1000;
const floor = isRegionalCapital ? (isTopCenter ? 210000 : 120000) : 42000;
@@ -771,14 +573,18 @@ export function generateMapFeatures(seed, terrain) {
if (!nearestCity.city) return true;
return nearestCity.d >= urbanSettlementExclusionRadius(nearestCity.city, "market");
});
+ marketIndex = createPointSpatialIndex(markets, 12);
villages = villages.filter((v) => {
const nearestCity = modernCities.reduce((best, city) => {
const d = Math.hypot(v.x - city.x, v.y - city.y);
return d < best.d ? { city, d } : best;
}, { city: null, d: Infinity });
if (nearestCity.city && nearestCity.d < urbanSettlementExclusionRadius(nearestCity.city, "village")) return false;
- return distanceToNearest(markets, v.x, v.y) >= 3.4;
+ return !marketIndex.hasWithin(v.x, v.y, 3.4);
});
+ marketIndex = createPointSpatialIndex(markets, 12);
+ villageIndex = createPointSpatialIndex(villages, 8);
+ const cityIndex = createPointSpatialIndex(modernCities, 12);
villageInfluence = influenceFromPoints(villages, 6, (v) => clamp((v.population || 1800) / 4200, 0.35, 1.2));
const settlementHierarchyDebug = {
version: "phase2-unified-settlement-hierarchy",
@@ -789,6 +595,7 @@ export function generateMapFeatures(seed, terrain) {
villagesAfterHierarchyFilter: villages.length,
regionalCapitalSlots,
};
+ markFeatureTiming("settlement-placement");
function cityPopulationCap(city) {
const radius = city?.isRegionalCapital ? 29 : city?.population >= 350000 ? 26 : 18;
@@ -797,217 +604,46 @@ export function generateMapFeatures(seed, terrain) {
}
// --- 4. Field-derived transport corridors -------------------------------
- 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
- );
- }
- }
-
- function buildTransportCostFields() {
- 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 };
- }
-
- const transportFields = buildTransportCostFields();
+ const {
+ preliminaryUrbanInfluence,
+ preliminaryTownInfluence,
+ preliminaryVillageInfluence,
+ settlementDemand,
+ urbanEdge,
+ logisticsPreSuitability,
+ } = buildSettlementDemandFields({
+ sea, agriculture, plain, basinField, coastalLowland, slope, ridgeField,
+ modernCities, markets, commercialPorts, villages,
+ });
+ markFeatureTiming("settlement-demand");
+ const transportFields = buildFeatureTransportCostFields({
+ seed,
+ sea, elevation, slope, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland,
+ portSuitability, passSuitability, crossingSuitability, naturalBarrierScore,
+ settlementDemand, urbanEdge, logisticsPreSuitability,
+ preliminaryTownInfluence, preliminaryVillageInfluence,
+ valleySettlement, coastalSettlement, developable,
+ });
+ markFeatureTiming("transport-cost-fields");
const cachedInfluenceFromPaths = createPathInfluenceCache(influenceFromPaths);
+ const coarseRouteStats = { graphBuilds: 0, attempted: 0, routed: 0, refined: 0, fallback: 0, skipped: 0 };
+ const coarseGraphCache = new WeakMap();
+ function coarseGraphFor(costField, mode = "national") {
+ const scale = mode === "local" ? 5 : mode === "rail" ? 4 : mode === "expressway" ? 4 : 4;
+ let byMode = coarseGraphCache.get(costField);
+ if (!byMode) {
+ byMode = new Map();
+ coarseGraphCache.set(costField, byMode);
+ }
+ const key = `${mode}:${scale}`;
+ let graph = byMode.get(key);
+ if (!graph) {
+ graph = buildCoarseCostGraph({ sea, costField }, { scale });
+ byMode.set(key, graph);
+ coarseRouteStats.graphBuilds++;
+ }
+ return graph;
+ }
const componentCityInfluence = influenceFromPoints([...modernCities, ...markets], 11, (p) => clamp((p.population || 8000) / 50000, 0.18, 8.0));
const componentCapitalInfluence = influenceFromPoints(modernCities.filter((p) => p.isPrefecturalCapital), 16, () => 5.0);
@@ -1075,17 +711,30 @@ export function generateMapFeatures(seed, terrain) {
return support > 0.14 || transportFields.nationalPotential[i] > 0.37;
}
+ const routeScratch = {
+ score: new Float32Array(SIZE),
+ cameFrom: new Int32Array(SIZE),
+ seen: new Int32Array(SIZE),
+ closed: new Int32Array(SIZE),
+ epoch: 0,
+ };
+
function traceCorridorByCost(start, goalRegionPredicate, costField, penaltyField, options = {}) {
if (!start || !inside(start.x, start.y)) return [];
const startIndex = indexOf(start.x, start.y);
if (sea[startIndex] || costField[startIndex] >= INF) return [];
- const score = new Float32Array(SIZE);
- const cameFrom = new Int32Array(SIZE);
- const closed = new Uint8Array(SIZE);
- score.fill(INF);
- cameFrom.fill(-1);
+ const { score, cameFrom, seen, closed } = routeScratch;
+ let epoch = ++routeScratch.epoch;
+ if (routeScratch.epoch > 2000000000) {
+ routeScratch.seen.fill(0);
+ routeScratch.closed.fill(0);
+ routeScratch.epoch = 1;
+ epoch = 1;
+ }
const heap = new MinHeap();
+ seen[startIndex] = epoch;
score[startIndex] = 0;
+ cameFrom[startIndex] = -1;
heap.push({ i: startIndex, f: 0 });
const curvePenalty = options.curvePenalty ?? 0.12;
const penaltyStrength = options.penaltyStrength ?? 1.0;
@@ -1100,9 +749,10 @@ export function generateMapFeatures(seed, terrain) {
while (heap.length && expanded++ < maxExpanded) {
const current = heap.pop();
- if (!current || closed[current.i]) continue;
- closed[current.i] = 1;
- const [cx, cy] = xyOf(current.i);
+ if (!current || closed[current.i] === epoch) continue;
+ closed[current.i] = epoch;
+ const cx = current.i % MAP_W;
+ const cy = Math.floor(current.i / MAP_W);
if (current.i !== startIndex && Math.hypot(cx - start.x, cy - start.y) >= minGoalDistance && goalRegionPredicate(cx, cy, current.i)) {
goalIndex = current.i;
break;
@@ -1115,12 +765,13 @@ export function generateMapFeatures(seed, terrain) {
if (!inside(nx, ny)) continue;
if (bounds && (nx < bounds.minX || nx > bounds.maxX || ny < bounds.minY || ny > bounds.maxY)) continue;
const ni = indexOf(nx, ny);
- if (closed[ni] || sea[ni] || costField[ni] >= INF) continue;
+ if (closed[ni] === epoch || sea[ni] || costField[ni] >= INF) continue;
if (sameRegion >= 0 && options.keepRegion !== false && regionIdAt(nx, ny) !== sameRegion) continue;
const prev = cameFrom[current.i];
let turn = 0;
if (prev >= 0) {
- const [px, py] = xyOf(prev);
+ const px = prev % MAP_W;
+ const py = Math.floor(prev / MAP_W);
const ax = cx - px;
const ay = cy - py;
turn = Math.abs(ax * dy - ay * dx) > 0 ? curvePenalty : 0;
@@ -1137,7 +788,8 @@ export function generateMapFeatures(seed, terrain) {
);
const surfaceGrain = (options.surfaceGrain ?? 0) * valueNoise(nx, ny, seed + 13941, 18);
const nd = score[current.i] + Math.max(0.08, costField[ni] + antiConcentration + turn - terrainFlowBias + surfaceGrain) * Math.hypot(dx, dy);
- if (nd < score[ni]) {
+ if (seen[ni] !== epoch || nd < score[ni]) {
+ seen[ni] = epoch;
score[ni] = nd;
cameFrom[ni] = current.i;
const h = goalHint ? Math.hypot(nx - goalHint.x, ny - goalHint.y) * heuristicWeight : 0;
@@ -1149,7 +801,7 @@ export function generateMapFeatures(seed, terrain) {
if (goalIndex < 0) return [];
const path = [];
for (let p = goalIndex; p >= 0; p = cameFrom[p]) {
- path.push(xyOf(p));
+ path.push([p % MAP_W, Math.floor(p / MAP_W)]);
if (p === startIndex) break;
}
return path.reverse();
@@ -1333,11 +985,11 @@ export function generateMapFeatures(seed, terrain) {
const water = pathWaterCrossingStats(path);
const tunnel = pathTunnelStats(path);
const bridgeLimit = overrides.bridgeLimit ?? (mode === "expressway" ? 20 : 10);
- const tunnelLimit = overrides.tunnelLimit ?? (mode === "expressway" ? 10 : 0);
+ const tunnelLimit = overrides.tunnelLimit ?? (mode === "expressway" ? 10 : mode === "national" ? 10 : 0);
const maxSeaRun = overrides.maxSeaRun ?? bridgeLimit;
const maxTunnelRun = overrides.maxTunnelRun ?? tunnelLimit;
const maxSeaShare = overrides.maxSeaShare ?? (mode === "expressway" ? 0.22 : mode === "rail" ? 0.030 : mode === "national" ? 0.10 : 0.05);
- const maxTunnelShare = overrides.maxTunnelShare ?? (mode === "expressway" ? 0.12 : 0);
+ const maxTunnelShare = overrides.maxTunnelShare ?? (mode === "expressway" ? 0.12 : mode === "national" ? 0.12 : 0);
if (water.maxSeaRun > maxSeaRun || water.seaShare > maxSeaShare) return false;
if (tunnel.maxTunnelRun > maxTunnelRun || tunnel.tunnelShare > maxTunnelShare) return false;
if (water.seaCells > 0) {
@@ -1845,7 +1497,7 @@ export function generateMapFeatures(seed, terrain) {
...markets.filter(inRegion).map((p) => ({ ...p, nodeWeight: 3.2 + (p.population || 0) / 25000 })),
...commercialPorts.filter(inRegion).map((p) => ({ ...p, nodeWeight: p.portClass === "major" ? 6.5 : 4.6 })),
...passes.filter(inRegion).map((p) => ({ ...p, nodeWeight: 2.2 })),
- ].sort((a, b) => b.nodeWeight - a.nodeWeight).slice(0, (regionStats.get(regionId)?.area || 0) > 2200 ? 16 : 10);
+ ].sort((a, b) => b.nodeWeight - a.nodeWeight).slice(0, (regionStats.get(regionId)?.area || 0) > 2200 ? 13 : 8);
}
function dedupePointCandidates(points, minDistance = 5) {
@@ -1912,13 +1564,13 @@ export function generateMapFeatures(seed, terrain) {
}
function transportCandidatePoints(mode, potentialField, options = {}) {
- const maxCells = options.maxCells ?? (mode === "expressway" ? 18 : mode === "rail" ? 26 : 40);
- const maxSettlements = options.maxSettlements ?? (mode === "expressway" ? 20 : mode === "rail" ? 34 : 56);
+ const maxCells = options.maxCells ?? (mode === "expressway" ? 14 : mode === "rail" ? 20 : 30);
+ const maxSettlements = options.maxSettlements ?? (mode === "expressway" ? 16 : mode === "rail" ? 27 : 44);
const minDistance = options.minDistance ?? (mode === "expressway" ? 13 : mode === "rail" ? 10 : 8);
const cells = preferenceCellAnchors(mode, potentialField, maxCells, minDistance);
const settlements = modeSettlementAnchors(mode, potentialField, maxSettlements);
return dedupePointCandidates([...settlements, ...cells].sort((a, b) => b.score - a.score), Math.max(4, minDistance * 0.55))
- .slice(0, options.maxTotal ?? (mode === "expressway" ? 32 : mode === "rail" ? 48 : 74));
+ .slice(0, options.maxTotal ?? (mode === "expressway" ? 26 : mode === "rail" ? 38 : 58));
}
function routeBetweenTrafficCandidates(a, b, mode, costField, penaltyField, options = {}) {
@@ -1929,14 +1581,38 @@ export function generateMapFeatures(seed, terrain) {
if (sea[indexOf(start.x, start.y)] || sea[indexOf(target.x, target.y)]) return [];
const d = Math.hypot(start.x - target.x, start.y - target.y);
const snap = options.snapRadius ?? (mode === "expressway" ? 3 : mode === "rail" ? 2.5 : 2.25);
- const searchPad = options.searchPad ?? Math.ceil(Math.max(18, Math.min(58, d * (mode === "expressway" ? 0.46 : mode === "rail" ? 0.42 : 0.36))));
+ const searchPad = options.searchPad ?? Math.ceil(Math.max(16, Math.min(48, d * (mode === "expressway" ? 0.40 : mode === "rail" ? 0.37 : 0.31))));
const bounds = options.bounds || {
minX: Math.max(0, Math.min(start.x, target.x) - searchPad),
maxX: Math.min(MAP_W - 1, Math.max(start.x, target.x) + searchPad),
minY: Math.max(0, Math.min(start.y, target.y) - searchPad),
maxY: Math.min(MAP_H - 1, Math.max(start.y, target.y) + searchPad),
};
- const path = traceCorridorByCost(
+ const coarseThreshold = options.coarseThreshold ?? (mode === "local" ? 18 : mode === "rail" ? 22 : mode === "expressway" ? 30 : 20);
+ let path = [];
+ if (!options.forceFullResolution && d >= coarseThreshold) {
+ coarseRouteStats.attempted++;
+ const graph = coarseGraphFor(costField, mode);
+ const coarse = routeCoarsePath(start, target, graph, { heuristicWeight: mode === "rail" ? 0.72 : 0.86 });
+ if (coarse.length >= 2) {
+ const refined = refineCoarsePath([[start.x, start.y], ...coarse.slice(1, -1), [target.x, target.y]], costField, {
+ sea,
+ snapRadius: mode === "local" ? 2 : 1,
+ });
+ if (refined.length >= 4) {
+ path = refined;
+ coarseRouteStats.routed++;
+ coarseRouteStats.refined++;
+ } else {
+ coarseRouteStats.skipped++;
+ }
+ } else {
+ coarseRouteStats.skipped++;
+ }
+ }
+ if (!path.length) {
+ coarseRouteStats.fallback++;
+ path = traceCorridorByCost(
start,
(x, y) => Math.hypot(x - target.x, y - target.y) <= snap,
costField,
@@ -1946,17 +1622,19 @@ export function generateMapFeatures(seed, terrain) {
penaltyStrength: options.penaltyStrength ?? (mode === "expressway" ? 1.9 : mode === "rail" ? 1.35 : mode === "national" ? 1.05 : 0.78),
minGoalDistance: Math.min(8, Math.max(2, d * 0.08)),
keepRegion: false,
- maxExpanded: Math.min(SIZE, Math.max(1800, Math.floor(d * d * (mode === "expressway" ? 3.8 : mode === "rail" ? 4.6 : 5.2)))),
+ maxExpanded: Math.min(Math.floor(SIZE * SPEED_TOLERANCE), Math.max(1300, Math.floor(d * d * (mode === "expressway" ? 2.9 : mode === "rail" ? 3.5 : 3.9)))),
terrainFlowBias: options.terrainFlowBias ?? (mode === "expressway" ? 0.10 : mode === "rail" ? 0.12 : mode === "national" ? 0.24 : 0.30),
surfaceGrain: options.surfaceGrain ?? (mode === "national" ? 0.030 : mode === "local" ? 0.042 : 0.010),
bounds,
goalHint: options.goalHint || target,
heuristicWeight: options.heuristicWeight ?? (mode === "expressway" ? 0.66 : mode === "rail" ? 0.54 : mode === "national" ? 0.46 : 0.30),
}
- );
+ );
+ }
if (path.length < 4) return [];
if (options.maxPathLength && pathLengthCells(path) > options.maxPathLength) return [];
- const relaxed = relaxRouteToTerrain(path, costField, {
+ const skipRelax = options.skipRelax ?? (mode === "local" && path.length > 42);
+ const relaxed = skipRelax ? path : relaxRouteToTerrain(path, costField, {
radius: options.relaxRadius ?? (mode === "national" ? 2 : 1),
lineWeight: options.relaxLineWeight ?? (mode === "national" ? 0.30 : mode === "expressway" ? 0.24 : 0.48),
grain: options.surfaceGrain ?? 0.020,
@@ -2133,12 +1811,77 @@ const premodernRoads = [];
const interchanges = [];
const externalGateways = [];
- // Premodern roads connect castles/markets/ports sparsely.
- for (const c of castles) {
- const near = [...markets, ...ports, ...crossings].sort((a, b) => Math.hypot(a.x - c.x, a.y - c.y) - Math.hypot(b.x - c.x, b.y - c.y)).slice(0, 2);
- for (const n of near) {
- const path = routeLight(c, n, 2);
- if (path.length > 2) premodernRoads.push(path);
+ function pathEntirelyLand(path) {
+ if (!path || path.length < 2) return false;
+ for (let k = 1; k < path.length; k++) {
+ const a = path[k - 1];
+ const b = path[k];
+ if (!a || !b) return false;
+ const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])));
+ for (let s = 0; s <= steps; s++) {
+ const t = s / steps;
+ const x = Math.round(a[0] + (b[0] - a[0]) * t);
+ const y = Math.round(a[1] + (b[1] - a[1]) * t);
+ if (!inside(x, y)) return false;
+ const i = indexOf(x, y);
+ if (sea[i] || transportFields.local[i] >= INF) return false;
+ }
+ }
+ return true;
+ }
+
+ function addPremodernRoad(a, b, edgeSet) {
+ if (!a || !b) return false;
+ const keyA = `${Math.round(a.x)},${Math.round(a.y)}`;
+ const keyB = `${Math.round(b.x)},${Math.round(b.y)}`;
+ const key = keyA < keyB ? `${keyA}|${keyB}` : `${keyB}|${keyA}`;
+ if (edgeSet.has(key)) return false;
+ edgeSet.add(key);
+ const d = Math.hypot(a.x - b.x, a.y - b.y);
+ if (d < 5 || d > 96) return false;
+ const path = routeLight(a, b, 2.4, transportFields.local);
+ if (path.length <= 2 || !pathEntirelyLand(path)) return false;
+ if (!routePhysicalAcceptable(path, 'local', { maxSeaRun: 0, maxSeaShare: 0, maxTunnelRun: 0, maxTunnelShare: 0 })) return false;
+ premodernRoads.push(path);
+ return true;
+ }
+
+ // Premodern roads are a loose land-only network around the labelled historical
+ // places (castles, market/village nodes, ports, crossings), not sea chords.
+ {
+ const edgeSet = new Set();
+ const labelledPremodernNodes = dedupePointCandidates([
+ ...castles.map((p) => ({ ...p, premodernRole: "castle", premodernWeight: 1.35 })),
+ ...castleTowns.map((p) => ({ ...p, premodernRole: "castle-town", premodernWeight: 1.25 })),
+ ...markets.filter((p) => (p.population || 0) >= 2500).map((p) => ({ ...p, premodernRole: "market", premodernWeight: 1.0 + Math.min(0.45, (p.population || 0) / 60000) })),
+ ...villages.filter((p) => (p.population || 0) >= 4200).map((p) => ({ ...p, premodernRole: "village", premodernWeight: 0.74 })),
+ ...commercialPorts.map((p) => ({ ...p, premodernRole: "port", premodernWeight: p.portClass === "major" ? 1.22 : 1.0 })),
+ ...crossings.map((p) => ({ ...p, premodernRole: "crossing", premodernWeight: 0.78 })),
+ ].filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)]), 5.0)
+ .sort((a, b) => (b.premodernWeight || 0) - (a.premodernWeight || 0))
+ .slice(0, 54);
+
+ for (const c of castles) {
+ const near = labelledPremodernNodes
+ .filter((n) => n !== c && Math.hypot(n.x - c.x, n.y - c.y) <= 88)
+ .sort((a, b) => Math.hypot(a.x - c.x, a.y - c.y) - Math.hypot(b.x - c.x, b.y - c.y))
+ .slice(0, 2);
+ for (const n of near) addPremodernRoad(c, n, edgeSet);
+ }
+
+ for (const node of labelledPremodernNodes) {
+ if (premodernRoads.length >= 68) break;
+ const quota = node.premodernRole === "castle" || node.premodernRole === "castle-town" ? 3 : node.premodernRole === "market" ? 2 : 1;
+ const near = labelledPremodernNodes
+ .filter((n) => n !== node)
+ .map((n) => ({ n, d: Math.hypot(n.x - node.x, n.y - node.y), cost: Math.hypot(n.x - node.x, n.y - node.y) / Math.max(0.45, n.premodernWeight || 0.8) }))
+ .filter((e) => e.d >= 7 && e.d <= 82)
+ .sort((a, b) => a.cost - b.cost)
+ .slice(0, quota);
+ for (const { n } of near) {
+ if (premodernRoads.length >= 68) break;
+ addPremodernRoad(node, n, edgeSet);
+ }
}
}
@@ -2188,10 +1931,12 @@ const premodernRoads = [];
transportFields, settlementDemand, preliminaryUrbanInfluence, preliminaryTownInfluence, preliminaryVillageInfluence,
modernCities, markets, ports, commercialPorts, externalGateways, geographicUrbanAnchors,
regionIdAt, routeBetweenTrafficCandidates, addCorridorInfluencePenalty, transportRouteAcceptable, pruneParallelSameMode, cachedInfluenceFromPaths,
+ speedTolerance: SPEED_TOLERANCE,
});
railways.push(...railOD.railways);
branchRailways.push(...railOD.branchRailways);
railODDebug = railOD.debug;
+ markFeatureTiming("rail-od");
const { transportDebugLayers, runLocalAccessPass, stitchRasterNearContacts, stitchLongLocalBranches, sanitizeLocalRoads, downgradeShortNationalRoads, connectAllRoadNetworksFinal } = buildDensityFlowRoadTransportSystem({
@@ -2209,14 +1954,15 @@ const premodernRoads = [];
relaxRouteToTerrain, transportRouteAcceptable, repairTransportConnectivity,
repairDanglingTransportEndpoints, pruneDanglingTerminalSegments, pruneParallelSameMode,
});
+ markFeatureTiming("road-system");
// Land-use road influence intentionally excludes expressways. Expressways
// are through-corridors here, not automatic suburbanization generators.
// A narrow field controls land-use attachment, while a broader field raises
// population density around trunk roads without painting a wide suburb band.
- const roadLanduseInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 2.25, "road:landuse");
- const roadInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 5.0, "road:influence");
- const roadDensityInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 9.0, "road:density");
+ let roadLanduseInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 2.25, "road:landuse:provisional");
+ let roadInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 5.0, "road:influence:provisional");
+ let roadDensityInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 9.0, "road:density:provisional");
const railInfluence2 = cachedInfluenceFromPaths([...railways, ...branchRailways, ...externalRailways], 4, "rail:influence");
const stations = [];
@@ -2236,9 +1982,9 @@ const premodernRoads = [];
return urbanDensity > 0.50 ? 6 : ruralDensity > 0.24 ? 12 : 20;
}
function shouldPlaceRailStation(x, y, i) {
- const nearCity = (preliminaryUrbanInfluence[i] || 0) > 0.12 || distanceToNearest(modernCities, x, y) < 4.8;
- const nearTown = (preliminaryTownInfluence[i] || 0) > 0.13 || distanceToNearest(markets, x, y) < 4.2;
- const nearVillage = (preliminaryVillageInfluence[i] || 0) > 0.20 || distanceToNearest(villages, x, y) < 3.4;
+ const nearCity = (preliminaryUrbanInfluence[i] || 0) > 0.12 || cityIndex.hasWithin(x, y, 4.8);
+ const nearTown = (preliminaryTownInfluence[i] || 0) > 0.13 || marketIndex.hasWithin(x, y, 4.2);
+ const nearVillage = (preliminaryVillageInfluence[i] || 0) > 0.20 || villageIndex.hasWithin(x, y, 3.4);
const lowland = plain[i] > 0.16 || coastalLowland[i] > 0.16 || basinField[i] > 0.20 || valleyField[i] > 0.22;
const terrainOk = slope[i] < 0.42 && ridgeField[i] < 0.60 && elevation[i] < 0.76;
return terrainOk && lowland && (nearCity || nearTown || nearVillage);
@@ -2260,12 +2006,12 @@ const premodernRoads = [];
}
const stationInfluence = influenceFromPoints(stations, 7, (s) => s.kind === "Major Station" ? 1.35 : 0.85);
const stationDensityInfluence = influenceFromPoints(stations, 10, (s) => s.kind === "Major Station" ? 1.85 : 1.05);
+ markFeatureTiming("transport-influence-stations");
// --- 5. Approximate city/town influence and land-use ---------------------
const cityInfluence = new Float32Array(SIZE);
const coreInfluence = new Float32Array(SIZE);
const oldTownInfluence = influenceFromPoints([...markets, ...castleTowns, ...ports], 7, (p) => p.kind === "Major Port" ? 1.2 : 0.9);
- const populationDensity = new Float32Array(SIZE);
function addKernel(grid, p, radius, weight, exponent = 1.7, terrainWeighted = true, combine = "max") {
const r = Math.ceil(radius);
@@ -2360,7 +2106,7 @@ const premodernRoads = [];
slope[i] * 0.44 -
ridgeField[i] * 0.32
);
- const nearMajorCity = modernCities.some((c) => Math.hypot(c.x - x, c.y - y) < 4);
+ const nearMajorCity = cityIndex.hasWithin(x, y, 4);
logisticsScore[i] = nearMajorCity ? 0 : flatAgriculturalCorridor;
}
}
@@ -2389,9 +2135,9 @@ const premodernRoads = [];
return { ...p, score: (p.population || 1200) / 16000 + remoteness * 3.2 + ruralValue + (p.portClass ? 0.8 : 0) + (p.kind === "Logistics Park" ? 1.0 : 0) + transportFields.localPotential[i] };
})
.sort((a, b) => b.score - a.score)
- .slice(0, 130);
+ .slice(0, 62);
const localPenalty = cachedInfluenceFromPaths(minorRoads, 4, "final-local:minor");
- runLocalAccessPass({ candidates, accessInfluence, localPenalty, maxAdded: 90, maxLength: 92, debugMode: "local-access", from: "unserved", to: "network" });
+ runLocalAccessPass({ candidates, accessInfluence, localPenalty, maxAdded: 42, maxLength: 78, debugMode: "local-access", from: "unserved", to: "network" });
}
function addRuralRoadMeshConnectors() {
@@ -2406,14 +2152,14 @@ const premodernRoads = [];
})
.filter((p) => p.score > 0.30)
.sort((a, b) => b.score - a.score)
- .slice(0, 170);
+ .slice(0, 78);
runLocalAccessPass({
candidates,
accessInfluence: roadInfluenceNow,
localPenalty,
- maxAdded: 70,
+ maxAdded: 32,
minSpacing: 3.0,
- maxLength: 76,
+ maxLength: 64,
debugMode: "rural-mesh",
from: "rural-settlement",
to: "local-network",
@@ -2429,7 +2175,8 @@ const premodernRoads = [];
transportDebugLayers.contactStitchesAfterConnectivity = stitchRasterNearContacts();
// Keep this as the final road topology operation. Later sanitization can cut
// the short connectors that intentionally merge isolated components.
- transportDebugLayers.finalRoadNetworkConnectivity = connectAllRoadNetworksFinal(96);
+ transportDebugLayers.finalRoadNetworkConnectivity = connectAllRoadNetworksFinal(46);
+ markFeatureTiming("local-road-cleanup");
function dedupeTransportPathSet(paths, options = {}) {
const before = paths.length;
@@ -2582,10 +2329,12 @@ const premodernRoads = [];
let added = 0;
for (const path of [...expressways, ...externalExpressways]) {
if (!path || path.length < 2) continue;
- const endpoints = [path[0], path[path.length - 1]];
- for (const [x, y] of endpoints) if (ensureInterchangePoint(x, y)) added++;
+ const first = path[0];
+ const last = path[path.length - 1];
+ if (ensureInterchangePoint(first[0], first[1], 'Terminal IC', 'expressway-terminal-endpoint')) added++;
+ if (ensureInterchangePoint(last[0], last[1], 'Terminal IC', 'expressway-terminal-endpoint')) added++;
}
- return { added, total: interchanges.length };
+ return { added, total: interchanges.length, strategy: "terminal ICs restored" };
}
function ensureNationalRoadCoverageForTowns(minPopulation = 5000) {
@@ -2606,7 +2355,7 @@ const premodernRoads = [];
.filter((row) => row.d >= 10 && row.d <= 90)
.sort((a, b) => a.d - b.d);
let addedPath = null;
- for (const cand of candidates.slice(0, 8)) {
+ for (const cand of candidates.slice(0, 5)) {
const path = routeBetweenTrafficCandidates(town, cand.q, 'national', transportFields.national, nationalPenalty, {
curvePenalty: 0.055,
penaltyStrength: 0.60,
@@ -2631,26 +2380,73 @@ const premodernRoads = [];
return debug;
}
+ function expresswayFringeAnchorForCity(city) {
+ if (!city || !inside(city.x, city.y)) return null;
+ const inner = Math.max(10, Math.round((city.coreRadius || 4) + 7));
+ const outer = Math.max(inner + 8, Math.round(Math.min(34, (city.urbanRadius || 13) * 2.0)));
+ let best = null;
+ for (let dy = -outer; dy <= outer; dy++) {
+ for (let dx = -outer; dx <= outer; dx++) {
+ const x = Math.round(city.x + dx);
+ const y = Math.round(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] || elevation[i] >= 0.70 || transportFields.expressway[i] >= INF) continue;
+ const radialBand = clamp(1 - Math.abs(d - (inner + outer) * 0.54) / Math.max(2, (outer - inner) * 0.55));
+ const corePenalty = clamp(settlementDemand[i] * 0.78 + preliminaryTownInfluence[i] * 0.58 + preliminaryVillageInfluence[i] * 0.42);
+ const score =
+ (transportFields.expresswayPotential[i] || 0) * 1.18 +
+ (urbanEdge[i] || 0) * 0.54 +
+ (logisticsPreSuitability[i] || 0) * 0.40 +
+ (plain[i] || 0) * 0.20 +
+ (basinField[i] || 0) * 0.12 +
+ radialBand * 0.32 -
+ corePenalty * 1.18 -
+ (slope[i] || 0) * 1.05 -
+ (ridgeField[i] || 0) * 0.82 +
+ hash2(x, y, seed + 24891 + city.x * 11 + city.y * 17) * 0.04;
+ if (!best || score > best.score) best = { x, y, score, city, population: city.population || 0, regionId: regionIdAt(x, y), role: 'expressway-fringe-anchor' };
+ }
+ }
+ return best;
+ }
+
+ function nearPath(path, p, radius) {
+ return (path || []).some(([x, y]) => Math.hypot(x - p.x, y - p.y) <= radius);
+ }
+
function ensureMajorCityExpresswayConnections(minPopulation = 100000) {
const majorCities = modernCities
.filter((c) => (c.population || 0) >= minPopulation)
.sort((a, b) => (b.population || 0) - (a.population || 0));
- const debug = { minPopulation, cityCount: majorCities.length, added: 0, pairs: [] };
+ const debug = { minPopulation, cityCount: majorCities.length, added: 0, pairs: [], strategy: 'fringe-anchor-only' };
if (majorCities.length < 2) return debug;
+ const anchors = majorCities
+ .map((city) => expresswayFringeAnchorForCity(city))
+ .filter(Boolean)
+ .sort((a, b) => (b.population || 0) - (a.population || 0));
+ debug.anchorCount = anchors.length;
+ if (anchors.length < 2) return debug;
+
const permissiveExpresswayCost = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
+ if (sea[i] || elevation[i] >= 0.70) {
+ permissiveExpresswayCost[i] = INF;
+ continue;
+ }
+ const urbanCore = clamp(settlementDemand[i] * 0.78 + preliminaryTownInfluence[i] * 0.62 + preliminaryVillageInfluence[i] * 0.46);
const terrainBase = Number.isFinite(transportFields.expressway[i]) && transportFields.expressway[i] < INF
? transportFields.expressway[i]
- : 0.28 + Math.max(0, slope[i] - 0.18) * 0.9 + Math.max(0, elevation[i] - 0.58) * 1.6 + ridgeField[i] * 0.45;
- permissiveExpresswayCost[i] = sea[i]
- ? 0.72 + (naturalBarrierScore?.[i] || 0) * 0.16
- : terrainBase + Math.max(0, elevation[i] - 0.70) * 1.7;
+ : 0.40 + Math.max(0, slope[i] - 0.18) * 1.25 + Math.max(0, elevation[i] - 0.58) * 2.1 + ridgeField[i] * 0.80;
+ permissiveExpresswayCost[i] = terrainBase + urbanCore * 2.6 + Math.max(0, elevation[i] - 0.62) * 3.0;
}
- const componentOfCities = () => {
+ const componentOfAnchors = () => {
const parent = new Map();
- const keyOf = (city) => city.name || `${city.x},${city.y}`;
+ const keyOf = (anchor) => anchor.city?.name || `${anchor.x},${anchor.y}`;
function find(k) {
const p = parent.get(k);
if (p === k) return k;
@@ -2662,274 +2458,95 @@ const premodernRoads = [];
const ra = find(a); const rb = find(b);
if (ra !== rb) parent.set(ra, rb);
}
- for (const city of majorCities) parent.set(keyOf(city), keyOf(city));
- const paths = [...expressways, ...externalExpressways];
- for (const path of paths) {
- const near = majorCities.filter((city) => path.some(([x, y]) => Math.hypot(city.x - x, city.y - y) <= 7.5));
+ for (const anchor of anchors) parent.set(keyOf(anchor), keyOf(anchor));
+ for (const path of [...expressways, ...externalExpressways]) {
+ const near = anchors.filter((anchor) => nearPath(path, anchor, 8.5));
if (near.length >= 2) {
const k0 = keyOf(near[0]);
for (let i = 1; i < near.length; i++) union(k0, keyOf(near[i]));
}
}
- return new Map(majorCities.map((city) => [keyOf(city), find(keyOf(city))]));
+ return new Map(anchors.map((anchor) => [keyOf(anchor), find(keyOf(anchor))]));
};
- const pairPriority = (a, b) => {
- const d = Math.hypot(a.x - b.x, a.y - b.y);
- const pop = Math.sqrt((a.population || minPopulation) * (b.population || minPopulation));
- return d / Math.max(1, Math.log2(pop));
- };
-
- for (let iter = 0; iter < majorCities.length * 2; iter++) {
- const comps = componentOfCities();
+ const keyOf = (anchor) => anchor.city?.name || `${anchor.x},${anchor.y}`;
+ for (let iter = 0; iter < anchors.length * 2; iter++) {
+ const comps = componentOfAnchors();
const reps = new Set(comps.values());
if (reps.size <= 1) break;
let best = null;
- for (const a of majorCities) {
- for (const b of majorCities) {
- if (a === b) continue;
- const ka = a.name || `${a.x},${a.y}`;
- const kb = b.name || `${b.x},${b.y}`;
- if (comps.get(ka) === comps.get(kb)) continue;
- const score = pairPriority(a, b);
- if (!best || score < best.score) best = { a, b, score, d: Math.hypot(a.x - b.x, a.y - b.y) };
+ for (const a of anchors) {
+ for (const b of anchors) {
+ if (a === b || comps.get(keyOf(a)) === comps.get(keyOf(b))) continue;
+ const d = Math.hypot(a.x - b.x, a.y - b.y);
+ if (d < 32 || d > 220) continue;
+ const pop = Math.sqrt((a.population || minPopulation) * (b.population || minPopulation));
+ const score = d / Math.max(1, Math.log2(pop)) - ((a.score || 0) + (b.score || 0)) * 0.18;
+ if (!best || score < best.score) best = { a, b, score, d };
}
}
if (!best) break;
let path = routeBetweenTrafficCandidates(best.a, best.b, 'expressway', permissiveExpresswayCost, null, {
- curvePenalty: 0.045,
- penaltyStrength: 0.18,
- terrainFlowBias: 0.03,
- surfaceGrain: 0.001,
- relaxRadius: 2,
- relaxLineWeight: 0.15,
- maxPathLength: best.d * 3.5 + 110,
- snapRadius: 4.0,
- searchPad: Math.ceil(Math.max(20, Math.min(96, best.d * 0.55 + 16))),
- heuristicWeight: 0.78,
- maxSeaRun: 20,
- maxTunnelRun: 20,
- maxSeaShare: 0.45,
- maxTunnelShare: 0.45,
+ curvePenalty: 0.125,
+ penaltyStrength: 0.95,
+ terrainFlowBias: 0.09,
+ surfaceGrain: 0.006,
+ relaxRadius: 1,
+ relaxLineWeight: 0.20,
+ maxPathLength: best.d * 2.70 + 84,
+ snapRadius: 2.0,
+ searchPad: Math.ceil(Math.max(42, Math.min(112, best.d * 0.58 + 12))),
+ heuristicWeight: 0.72,
+ maxSeaRun: 0,
+ maxTunnelRun: 10,
+ maxSeaShare: 0,
+ maxTunnelShare: 0.12,
});
- if (!path.length) path = directBridgeTunnelConnector(best.a, best.b, 20);
- if (path.length >= 2) {
- path = smoothRasterPath(path, 2);
- expressways.push(path);
- debug.added++;
- debug.pairs.push({ from: best.a.name, to: best.b.name, distance: Math.round(best.d), length: Math.round(pathLengthCells(path)) });
- } else {
- break;
+ if (path.length >= 4 && transportRouteAcceptable(path, 'expressway', transportFields.expresswayPotential, null, { minLength: 16, maxLength: best.d * 2.85 + 96, maxSeaRun: 0, maxTunnelRun: 10, maxSeaShare: 0, maxTunnelShare: 0.12 })) {
+ path = smoothRasterPath(path, 1);
+ if (transportRouteAcceptable(path, 'expressway', transportFields.expresswayPotential, null, { minLength: 16, maxLength: best.d * 2.95 + 104, maxSeaRun: 0, maxTunnelRun: 10, maxSeaShare: 0, maxTunnelShare: 0.12 })) {
+ expressways.push(path);
+ debug.added++;
+ debug.pairs.push({ from: best.a.city?.name, to: best.b.city?.name, distance: Math.round(best.d), length: Math.round(pathLengthCells(path)) });
+ continue;
+ }
}
+ break;
}
debug.finalExpresswayCount = expressways.length;
return debug;
}
- transportDebugLayers.postConnectivityNationalCoverage = ensureNationalRoadCoverageForTowns(5000);
- transportDebugLayers.postConnectivityMajorCityExpresswayGuarantee = ensureMajorCityExpresswayConnections(100000);
+ transportDebugLayers.postConnectivityNationalCoverage = ensureNationalRoadCoverageForTowns(6000);
+ transportDebugLayers.postConnectivityMajorCityExpresswayGuarantee = ensureMajorCityExpresswayConnections(110000);
transportDebugLayers.postConnectivityExpresswayDedup = dedupeTransportPathSet(expressways, { minLength: 8, sampleStep: 2 });
transportDebugLayers.postConnectivityExpresswayEndpointICs = ensureExpresswayEndpointsHaveICs();
- var landuse = new Uint8Array(SIZE);
+ markFeatureTiming("post-connectivity-guarantees");
- // 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[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);
- const densityTransport = Math.max(roadDensityInfluence[i] * 0.95, stationDensityInfluence[i] * 1.05, railInfluence2[i] * 0.85);
- let urbanNeighbors = 0;
- let cbdNeighbors = 0;
- for (let dy = -1; dy <= 1; dy++) {
- for (let dx = -1; dx <= 1; dx++) {
- if (!dx && !dy) continue;
- const lu = baseLanduse[indexOf(x + dx, y + dy)];
- if (isBuilt(lu)) urbanNeighbors++;
- if (lu === LANDUSE.CBD) cbdNeighbors++;
- }
- }
- if (baseLanduse[i] === LANDUSE.OLD_URBAN && coreInfluence[i] > 0.31 && cbdNeighbors >= 3) {
- landuse[i] = LANDUSE.CBD;
- continue;
- }
- if ((baseLanduse[i] === LANDUSE.FARMLAND || baseLanduse[i] === LANDUSE.RURAL) && urbanCapacity[i] > 0.10) {
- const fringeChance = urbanNeighbors * 0.055 + cityInfluence[i] * 0.13 + transport * 0.12 + stationInfluence[i] * 0.08;
- const noise = 0.23 + hash2(x, y, seed + 15050) * 0.24;
- if (fringeChance > 0.34 + noise) {
- landuse[i] = 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;
- }
+ const finalRoadInfluencePaths = [...nationalRoads, ...ringRoads, ...externalRoads];
+ roadLanduseInfluence = cachedInfluenceFromPaths(finalRoadInfluencePaths, 2.25, "road:landuse:final");
+ roadInfluence = cachedInfluenceFromPaths(finalRoadInfluencePaths, 5.0, "road:influence:final");
+ roadDensityInfluence = cachedInfluenceFromPaths(finalRoadInfluencePaths, 9.0, "road:density:final");
+ transportDebugLayers.finalRoadInfluenceRefresh = {
+ roadPaths: finalRoadInfluencePaths.length,
+ nationalRoads: nationalRoads.length,
+ externalRoads: externalRoads.length,
+ ringRoads: ringRoads.length,
+ };
+ const { landuse, populationDensity } = buildFeatureLanduse({
+ 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,
+ });
+ markFeatureTiming("landuse");
const transportDebug = {
humanStageVersion: "v2-sparse-raster",
+ featureTimings,
+ coarseRouting: coarseRouteStats,
aStarRoutes: 0,
regionalNodeCount: [...regionStats.keys()].reduce((sum, regionId) => sum + importantNodesForRegion(regionId).length, 0),
fieldCorridorTransport: false,
diff --git a/mapGeneratorHelpers.js b/mapGeneratorHelpers.js
index c7885a5..68a3b16 100644
--- a/mapGeneratorHelpers.js
+++ b/mapGeneratorHelpers.js
@@ -31,6 +31,61 @@ export function distanceToNearest(points, x, y, fallback = 999) {
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) {
const startIndex = indexOf(start.x, start.y);
const goalIndex = indexOf(goal.x, goal.y);
@@ -83,15 +138,18 @@ export function aStar(start, goal, costAt) {
export function influenceFromPaths(paths, radius) {
const grid = new Float32Array(SIZE);
+ const r = Math.ceil(radius);
+ const r2 = radius * radius;
for (const path of paths) {
for (const [x, y] of path) {
- for (let dy = -radius; dy <= radius; dy++) {
- for (let dx = -radius; dx <= radius; dx++) {
+ for (let dy = -r; dy <= r; dy++) {
+ for (let dx = -r; dx <= r; dx++) {
const nx = x + dx;
const ny = y + dy;
if (!inside(nx, ny)) continue;
- const d = Math.hypot(dx, dy);
- if (d > radius) continue;
+ const d2 = dx * dx + dy * dy;
+ if (d2 > r2) continue;
+ const d = Math.sqrt(d2);
const i = indexOf(nx, ny);
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) {
const grid = new Float32Array(SIZE);
+ const r = Math.ceil(radius);
+ const r2 = radius * radius;
for (const p of points) {
const weight = weightFn(p);
- for (let dy = -radius; dy <= radius; dy++) {
- for (let dx = -radius; dx <= radius; dx++) {
+ for (let dy = -r; dy <= r; dy++) {
+ for (let dx = -r; dx <= r; dx++) {
const nx = p.x + dx;
const ny = p.y + dy;
if (!inside(nx, ny)) continue;
- const d = Math.hypot(dx, dy);
- if (d > radius) continue;
+ const d2 = dx * dx + dy * dy;
+ if (d2 > r2) continue;
+ const d = Math.sqrt(d2);
const i = indexOf(nx, ny);
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) => {
const id = `${prefix}-${i}`;
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);
if (usedNames) usedNames.add(name);
return {
diff --git a/mapMunicipalCoherence.js b/mapMunicipalCoherence.js
new file mode 100644
index 0000000..88684ed
--- /dev/null
+++ b/mapMunicipalCoherence.js
@@ -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,
+ });
+}
diff --git a/mapOutput.js b/mapOutput.js
index 5cf4f3a..20c7e23 100644
--- a/mapOutput.js
+++ b/mapOutput.js
@@ -1,6 +1,7 @@
import { createNameDebug } from "./names.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 { reconcileMunicipalMetadata } from "./mapMunicipalCoherence.js";
import { routeQualityAcceptable } from "./mapTransport.js";
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 = []) {
if (!adminCenters?.length || !adminId) return;
- const totals = new Float64Array(adminCenters.length);
- const settlementTotals = new Float64Array(adminCenters.length);
- const landCells = new Uint32Array(adminCenters.length);
- const inhabitedCells = new Uint32Array(adminCenters.length);
+ const centerById = new Map();
+ let maxId = -1;
+ for (const center of adminCenters) {
+ 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++) {
const id = adminId[i];
- if (id < 0 || id >= totals.length || fields.sea?.[i]) continue;
+ if (id < 0 || fields.sea?.[i]) continue;
landCells[id]++;
const density = fields.populationDensity?.[i] || 0;
const lu = fields.landuse?.[i] ?? 0;
@@ -68,7 +87,7 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields, settlement
let skippedDuplicateSettlementPopulation = 0;
for (const { feature, i } of uniqueSettlementByCell.values()) {
const id = adminId[i];
- if (id < 0 || id >= totals.length) continue;
+ if (id < 0 || id >= settlementTotals.length) continue;
settlementTotals[id] += feature.population;
}
for (const feature of settlementFeatures || []) {
@@ -77,7 +96,7 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields, settlement
const kept = uniqueSettlementByCell.get(key)?.feature;
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 minimumResidentPopulation = landCells[id] > 0
? 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 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);
- adminCenters[id].municipalityPopulation = safePopulation;
+ center.municipalityPopulation = safePopulation;
// Some consumers still read the generic `population` field from municipal
// centers. Mirror the municipality total there so no municipality is shown
// as 0人 merely because it is not a canonical city/market entity.
- adminCenters[id].population = Math.max(adminCenters[id].population || 0, safePopulation);
- adminCenters[id].municipalitySettlementPopulation = Math.max(0, Math.round((settlementTotals[id] || 0) / 100) * 100);
- adminCenters[id].municipalitySkippedDuplicateSettlementPopulation = Math.max(0, Math.round(skippedDuplicateSettlementPopulation / 100) * 100);
+ center.population = Math.max(center.population || 0, safePopulation);
+ center.municipalitySettlementPopulation = Math.max(0, Math.round((settlementTotals[id] || 0) / 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;
const prefIds = new Set();
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;
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)) {
const cities = (modernCities || []).filter((p) => prefAt(p) === prefId);
let target = cities.slice().sort((a, b) =>
@@ -175,10 +212,11 @@ function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCiti
target.population = promotedPopulation;
promoted++;
}
- target.isPrefecturalCapital = true;
target.isRegionalCapital = true;
- target.rank = target.rank || "Regional Capital";
- target.kind = target.kind === "Market Town" || target.kind === "Port Town" || target.kind === "Valley Market Town" ? "Regional Capital" : (target.kind || "Regional Capital");
+ target.isPrefecturalCapital = prefId === focusedPrefId;
+ 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.coreRadius = clamp(2.0 + Math.sqrt(target.population) / 390, 2.4, 6.4);
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)];
if (prefId < 0) continue;
const current = capitalNameByPref.get(prefId);
- const score = (city.isPrefecturalCapital ? 2_000_000 : 0) + (city.isRegionalCapital ? 500_000 : 0) + (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: "city" });
+ 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;
+ 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 || []) {
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;
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)
.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);
externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug);
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 = [
...modernCities.map((p) => ({ ...p, representativeWeight: 5.0 + (p.population || 0) / 180000 })),
...markets.map((p) => ({ ...p, representativeWeight: 3.2 })),
@@ -478,31 +530,44 @@ export function finishMapOutput({
center.canonicalSettlementId = best.id;
center.canonicalSettlementName = 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 {
center.municipalityRootName = center.generatedMunicipalityName;
}
}
const usedAdminNames = new Set();
for (const [index, center] of adminCenters.entries()) {
- center.adminNumericId = index;
- center.municipalityId = index;
- let candidate = municipalityNameFromRoot(center.municipalityRootName || center.canonicalSettlementName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index);
- const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, index);
+ const municipalId = centerMunicipalityId(center, index);
+ center.adminId = municipalId;
+ center.adminNumericId = municipalId;
+ 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)) {
candidate = generated;
}
if (usedAdminNames.has(candidate)) {
- const suffix = municipalitySuffixForCenter(center, nameFields, seed + 313, index);
- const rootSource = String(center.generatedMunicipalityName || center.name || center.municipalityRootName || `自治${index + 1}`).replace(/[市町村区駅港城跡宿]$/gu, "");
+ const suffix = municipalitySuffixForCenter(center, nameFields, seed + 313, municipalId);
+ const rootSource = String(center.generatedMunicipalityName || center.name || center.municipalityRootName || `自治${municipalId + 1}`).replace(/[市町村区駅港城跡宿]$/gu, "");
const chars = Array.from(rootSource || "里郷");
const alternates = [
chars.slice(0, 2).join(""),
chars.slice(-2).join(""),
- `${chars[0] || "里"}${["里", "郷", "野", "田", "川", "原", "浜", "浦"][(seed + index) % 8]}`,
- `${["東", "西", "南", "北", "上", "下", "中"][(seed + index) % 7]}${chars[0] || "里"}`,
+ `${chars[0] || "里"}${["里", "郷", "野", "田", "川", "原", "浜", "浦"][(seed + municipalId) % 8]}`,
+ `${["東", "西", "南", "北", "上", "下", "中"][(seed + municipalId) % 7]}${chars[0] || "里"}`,
].filter((v) => Array.from(v).length >= 2);
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}`;
}
}
@@ -511,7 +576,7 @@ export function finishMapOutput({
center.municipalityName = candidate;
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, [
...modernCities,
...markets,
@@ -553,7 +618,7 @@ export function finishMapOutput({
return bs - as;
})
.filter((p) => {
- const prefId = municipalityToPrefectureId?.[p.municipalityId] ?? -1;
+ const prefId = coherentMunicipalityToPrefectureId?.[p.municipalityId] ?? -1;
const used = perPrefectureQuota.get(prefId) || 0;
if (used >= 18) return false;
perPrefectureQuota.set(prefId, used + 1);
@@ -720,6 +785,24 @@ export function finishMapOutput({
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() {
const occ = new Uint8Array(MAP_W * MAP_H);
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);
}
+ 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();
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 };
for (let pass = 0; pass < 4 && comps.length > 1; pass++) {
const mainMask = new Uint8Array(MAP_W * MAP_H);
@@ -776,7 +1016,7 @@ export function finishMapOutput({
for (const [key, paths] of groups) {
const kept = [];
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]++;
}
paths.length = 0;
@@ -784,7 +1024,7 @@ export function finishMapOutput({
}
comps = components();
}
- debugLayers.finalOutputRoadConnectivity = { beforeComponents: before, afterComponents: comps.length, pruned };
+ debugLayers.finalOutputRoadConnectivity = { beforeComponents: before, afterComponents: comps.length, pruned, mountainAdminConnections: mountainConnect };
}
pruneIsolatedFinalRoadComponents();
@@ -815,6 +1055,178 @@ export function finishMapOutput({
}
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;
const prefectureRegions = buildPrefectureRegions(prefectureRegionId, sea, nameFields, seed, usedNames, nameDebug, { modernCities, adminCenters });
const regionalPrefectureBorders = adminRegionalPrefectureBorders || [];
@@ -845,6 +1257,9 @@ export function finishMapOutput({
return applyOutputOptions({
width: MAP_W,
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,
terrainTemplate,
seaLevel,
@@ -852,7 +1267,7 @@ export function finishMapOutput({
humanRegionMask,
prefectureBorder: regionalPrefectureBorders.length ? regionalPrefectureBorders : prefectureBorder,
prefectureRegionId,
- municipalityToPrefectureId,
+ municipalityToPrefectureId: coherentMunicipalityToPrefectureId,
prefectureRegions,
regionalDebug,
terrainDebug,
diff --git a/mapPatch.js b/mapPatch.js
new file mode 100644
index 0000000..f8457d2
--- /dev/null
+++ b/mapPatch.js
@@ -0,0 +1,2272 @@
+import { MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, lerp, smoothstep, valueNoise } from "./mapUtils.js";
+import { generateMap } from "./mapPipeline.js";
+import { LANDUSE } from "./landuseCodes.js";
+import { reconcileMunicipalMetadata, refreshPrefectureRegionsMetadata } from "./mapMunicipalCoherence.js";
+
+export const PATCH_MIN_WIDTH = 48;
+export const PATCH_MIN_HEIGHT = 48;
+export const PATCH_MIN_AREA = 3000;
+
+const POINT_LAYER_KEYS = [
+ "villages", "geographicUrbanAnchors", "markets", "castles", "castleTowns", "castleRuins",
+ "ports", "crossings", "passes", "modernCities", "satelliteCities", "stations",
+ "interchanges", "industrialZones", "logisticsParks", "newTowns", "adminCenters",
+ "externalGateways", "prefectureRegions",
+];
+
+const PATH_LAYER_KEYS = [
+ "premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads",
+ "railways", "branchRailways", "ringRailways", "externalRailways", "expressways", "externalExpressways",
+ "icAccessRoads", "mainRivers", "tributaryRivers", "smallStreams", "riverPaths",
+];
+
+const ROAD_LAYER_KEYS = new Set(["premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads", "expressways", "externalExpressways", "icAccessRoads"]);
+const RAIL_LAYER_KEYS = new Set(["railways", "branchRailways", "ringRailways", "externalRailways"]);
+const RIVER_LAYER_KEYS = new Set(["mainRivers", "tributaryRivers", "smallStreams", "riverPaths"]);
+
+const SEGMENT_LAYER_KEYS = ["adminBorders", "regionalPrefectureBorders", "prefectureBorder"];
+const PATCH_CANDIDATE_CACHE_LIMIT = 3;
+
+const ID_FIELD_OFFSETS = new Map([
+ ["adminId", 100000],
+ ["municipalityId", 100000],
+ ["prefectureRegionId", 200000],
+ ["regionId", 300000],
+ ["naturalCompartmentId", 400000],
+ ["watershedId", 500000],
+]);
+
+const DISCRETE_FIELD_NAMES = new Set([
+ "sea", "ocean", "lake", "prefectureMask", "landMask", "landuse",
+ "adminId", "municipalityId", "prefectureRegionId", "regionId", "naturalCompartmentId", "watershedId",
+]);
+
+
+const ADMIN_CONTINUITY_FIELD_NAMES = new Set(["adminId", "municipalityId", "prefectureRegionId"]);
+const NATURAL_CONTINUITY_FIELD_NAMES = new Set(["regionId", "naturalCompartmentId", "watershedId"]);
+const CONTINUITY_FIELD_NAMES = new Set([...ADMIN_CONTINUITY_FIELD_NAMES, ...NATURAL_CONTINUITY_FIELD_NAMES]);
+
+const SKIP_CELL_FIELDS = new Set(["flowTo", "prefectureMask", "humanRegionMask"]);
+
+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 sourceIndex(x, y) {
+ if (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H) return -1;
+ return y * MAP_W + x;
+}
+
+function isCellField(value) {
+ return ArrayBuffer.isView(value) && typeof value.length === "number" && value.length === SIZE;
+}
+
+function rectWidth(rect) {
+ return Math.max(0, Math.floor(rect.x1) - Math.floor(rect.x0));
+}
+
+function rectHeight(rect) {
+ return Math.max(0, Math.floor(rect.y1) - Math.floor(rect.y0));
+}
+
+function rectArea(rect) {
+ return rectWidth(rect) * rectHeight(rect);
+}
+
+function nowMs() {
+ return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
+}
+
+function createPatchTimer() {
+ const timings = [];
+ let mark = nowMs();
+ return {
+ timings,
+ mark(key, label = key) {
+ const t = nowMs();
+ timings.push({ key, label, ms: Math.round((t - mark) * 10) / 10 });
+ mark = t;
+ },
+ };
+}
+
+function normalizeRect(rect) {
+ if (!rect) return null;
+ const x0 = Math.floor(Math.min(rect.x0, rect.x1));
+ const y0 = Math.floor(Math.min(rect.y0, rect.y1));
+ const x1 = Math.ceil(Math.max(rect.x0, rect.x1));
+ const y1 = Math.ceil(Math.max(rect.y0, rect.y1));
+ return { x0, y0, x1, y1 };
+}
+
+function isPolygonSelection(input) {
+ return !!input && Array.isArray(input.polygon) && input.polygon.length >= 3;
+}
+
+function clampPointToWorld(point, world) {
+ return {
+ x: clamp(Math.round(point.x ?? 0), 0, Math.max(0, (world?.width || 1) - 1)),
+ y: clamp(Math.round(point.y ?? 0), 0, Math.max(0, (world?.height || 1) - 1)),
+ };
+}
+
+function polygonBounds(polygon) {
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ for (const p of polygon || []) {
+ if (!Number.isFinite(p?.x) || !Number.isFinite(p?.y)) continue;
+ minX = Math.min(minX, p.x);
+ minY = Math.min(minY, p.y);
+ maxX = Math.max(maxX, p.x);
+ maxY = Math.max(maxY, p.y);
+ }
+ if (!Number.isFinite(minX)) return null;
+ return { x0: Math.floor(minX), y0: Math.floor(minY), x1: Math.ceil(maxX + 1), y1: Math.ceil(maxY + 1) };
+}
+
+function polygonAreaCells(polygon) {
+ if (!polygon || polygon.length < 3) return 0;
+ let area = 0;
+ for (let i = 0; i < polygon.length; i++) {
+ const a = polygon[i];
+ const b = polygon[(i + 1) % polygon.length];
+ area += a.x * b.y - b.x * a.y;
+ }
+ return Math.abs(area) * 0.5;
+}
+
+function normalizeSelectionShape(input, world = null) {
+ if (!isPolygonSelection(input)) return normalizeRect(input);
+ const polygon = (input.polygon || []).map((p) => world ? clampPointToWorld(p, world) : { x: Math.round(p.x), y: Math.round(p.y) });
+ const bounds = polygonBounds(polygon);
+ if (!bounds) return null;
+ return {
+ kind: input.kind || 'lasso',
+ polygon,
+ areaCells: Math.max(1, Math.round(input.areaCells || polygonAreaCells(polygon))),
+ x0: bounds.x0,
+ y0: bounds.y0,
+ x1: bounds.x1,
+ y1: bounds.y1,
+ };
+}
+
+function pointInPolygon(px, py, polygon) {
+ let inside = false;
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
+ const xi = polygon[i].x + 0.5;
+ const yi = polygon[i].y + 0.5;
+ const xj = polygon[j].x + 0.5;
+ const yj = polygon[j].y + 0.5;
+ const intersect = ((yi > py) !== (yj > py)) && (px < ((xj - xi) * (py - yi)) / Math.max(1e-6, (yj - yi)) + xi);
+ if (intersect) inside = !inside;
+ }
+ return inside;
+}
+
+function pointSegmentDistance(px, py, ax, ay, bx, by) {
+ const dx = bx - ax;
+ const dy = by - ay;
+ const len2 = dx * dx + dy * dy;
+ if (len2 <= 1e-6) return Math.hypot(px - ax, py - ay);
+ const t = clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0, 1);
+ return Math.hypot(px - (ax + dx * t), py - (ay + dy * t));
+}
+
+function distanceToPolygonEdge(px, py, polygon) {
+ let best = Infinity;
+ for (let i = 0; i < polygon.length; i++) {
+ const a = polygon[i];
+ const b = polygon[(i + 1) % polygon.length];
+ best = Math.min(best, pointSegmentDistance(px, py, a.x + 0.5, a.y + 0.5, b.x + 0.5, b.y + 0.5));
+ }
+ return best;
+}
+
+function insideRect(x, y, rect) {
+ return !!rect && x >= rect.x0 && y >= rect.y0 && x < rect.x1 && y < rect.y1;
+}
+
+function expandRect(rect, margin, world = null) {
+ return {
+ x0: Math.max(0, rect.x0 - margin),
+ y0: Math.max(0, rect.y0 - margin),
+ x1: Math.min(world?.width ?? Infinity, rect.x1 + margin),
+ y1: Math.min(world?.height ?? Infinity, rect.y1 + margin),
+ };
+}
+
+function distanceToRectEdge(x, y, rect) {
+ return Math.min(x - rect.x0, y - rect.y0, rect.x1 - 1 - x, rect.y1 - 1 - y);
+}
+
+function defaultForField(name, Constructor) {
+ if (name === "sea" || name === "ocean") return 1;
+ if (name === "elevation") return 0.08;
+ if (ID_FIELD_OFFSETS.has(name)) return -1;
+ if (Constructor === Float32Array || Constructor === Float64Array) return 0;
+ return 0;
+}
+
+function ensureWorldField(world, name, source) {
+ if (!source || !isCellField(source)) return null;
+ const Constructor = source.constructor;
+ const expected = world.width * world.height;
+ if (!world.fields[name] || world.fields[name].length !== expected) {
+ world.fields[name] = new Constructor(expected);
+ const fallback = defaultForField(name, Constructor);
+ if (fallback !== 0) world.fields[name].fill(fallback);
+ }
+ return world.fields[name];
+}
+
+export function clipPatchRect(rect, world) {
+ const normalized = normalizeSelectionShape(rect, world);
+ if (!normalized || !world) return null;
+ if (isPolygonSelection(normalized)) return normalized;
+ return {
+ x0: Math.min(Math.max(normalized.x0, 0), world.width),
+ y0: Math.min(Math.max(normalized.y0, 0), world.height),
+ x1: Math.min(Math.max(normalized.x1, 0), world.width),
+ y1: Math.min(Math.max(normalized.y1, 0), world.height),
+ };
+}
+
+export function validatePatchRect(rect, world) {
+ const clipped = clipPatchRect(rect, world);
+ if (!clipped) return { ok: false, rect: null, reason: "No selected area." };
+ const width = rectWidth(clipped);
+ const height = rectHeight(clipped);
+ const area = isPolygonSelection(clipped) ? Math.max(1, Math.round(clipped.areaCells || polygonAreaCells(clipped.polygon))) : width * height;
+ if (width < PATCH_MIN_WIDTH || height < PATCH_MIN_HEIGHT) {
+ const parts = [];
+ if (width < PATCH_MIN_WIDTH) parts.push(`minimum width ${PATCH_MIN_WIDTH} cells`);
+ if (height < PATCH_MIN_HEIGHT) parts.push(`minimum height ${PATCH_MIN_HEIGHT} cells`);
+ return {
+ ok: false,
+ rect: clipped,
+ width,
+ height,
+ area,
+ reason: `Selection is too small: ${parts.join(", ")} required. Current ${width} x ${height} cells, ${area.toLocaleString()} cells total.`,
+ };
+ }
+ if (area < PATCH_MIN_AREA) {
+ return {
+ ok: false,
+ rect: clipped,
+ width,
+ height,
+ area,
+ reason: `Selection area is too small: minimum area ${PATCH_MIN_AREA.toLocaleString()} cells required. Current ${area.toLocaleString()} cells.`,
+ };
+ }
+ return { ok: true, rect: clipped, width, height, area, reason: "" };
+}
+
+export function buildPatchRects(userRect, world = null) {
+ const coreRect = normalizeSelectionShape(userRect, world);
+ const width = rectWidth(coreRect);
+ const height = rectHeight(coreRect);
+ const shortSide = Math.max(1, Math.min(width, height));
+ const desiredWrite = Math.min(96, Math.max(28, Math.floor(shortSide * 0.42)));
+ const maxBySource = Math.max(0, Math.floor(Math.min((MAP_W - width) / 2, (MAP_H - height) / 2)));
+ const writeMargin = Math.max(0, Math.min(desiredWrite, maxBySource));
+ const desiredRepair = Math.min(120, writeMargin + Math.max(8, Math.floor(shortSide * 0.12)));
+ const repairMargin = Math.max(writeMargin, Math.min(desiredRepair, maxBySource));
+ const writeRect = expandRect(coreRect, writeMargin, world);
+ const repairRect = expandRect(coreRect, repairMargin, world);
+ const transportReachMargin = Math.max(
+ repairMargin + 48,
+ Math.min(260, Math.max(96, repairMargin + Math.floor(shortSide * 1.15)))
+ );
+ const transportReachRect = expandRect(coreRect, transportReachMargin, world);
+ return {
+ coreRect,
+ writeRect,
+ repairRect,
+ contextRect: repairRect,
+ transportReachRect,
+ blendRect: coreRect,
+ userRect: writeRect,
+ selectedRect: coreRect,
+ selectionShape: isPolygonSelection(coreRect) ? coreRect : null,
+ writeMargin,
+ repairMargin,
+ transportReachMargin,
+ outerMargin: writeMargin,
+ innerMargin: 0,
+ };
+}
+
+function computePatchAlpha(x, y, rects, seed = 0) {
+ const writeRect = rects.writeRect || rects.userRect;
+ if (!insideRect(x, y, writeRect)) return 0;
+ const margin = Math.max(1, rects.writeMargin || 1);
+ const low = valueNoise(x, y, seed ^ 0x7153a9d1, 18) - 0.5;
+ const mid = valueNoise(x, y, seed ^ 0x9e3779b9, 7) - 0.5;
+ const shape = rects.selectionShape;
+ if (shape?.polygon?.length >= 3) {
+ const px = x + 0.5;
+ const py = y + 0.5;
+ const inside = pointInPolygon(px, py, shape.polygon);
+ const dist = distanceToPolygonEdge(px, py, shape.polygon);
+ const signedDist = inside ? dist : -dist;
+ const noisySigned = signedDist + low * margin * 0.28 + mid * margin * 0.10;
+ return clamp(smoothstep((noisySigned + margin) / Math.max(1e-6, margin * 2)));
+ }
+ const edge = distanceToRectEdge(x, y, writeRect);
+ const noisyEdge = edge + low * margin * 0.42 + mid * margin * 0.16;
+ const base = smoothstep(clamp(noisyEdge / margin));
+ // Keep the expanded repair band as the actual seam. The user's selected core
+ // is still dominant, but the write edge is irregular, so coastlines and land-use
+ // no longer inherit the rectangular user selection as a hard boundary.
+ return clamp(base);
+}
+
+function getPatchAlphaCache(rects, seed = 0) {
+ const writeRect = rects?.writeRect || rects?.userRect;
+ if (!writeRect) return null;
+ const width = rectWidth(writeRect);
+ const height = rectHeight(writeRect);
+ const existing = rects.patchAlphaCache;
+ if (
+ existing
+ && existing.seed === seed
+ && existing.width === width
+ && existing.height === height
+ && existing.x0 === writeRect.x0
+ && existing.y0 === writeRect.y0
+ ) return existing;
+
+ const data = new Float32Array(width * height);
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ data[y * width + x] = computePatchAlpha(writeRect.x0 + x, writeRect.y0 + y, rects, seed);
+ }
+ }
+ rects.patchAlphaCache = { seed, width, height, x0: writeRect.x0, y0: writeRect.y0, data };
+ return rects.patchAlphaCache;
+}
+
+function patchAlpha(x, y, rects, seed = 0) {
+ const writeRect = rects?.writeRect || rects?.userRect;
+ if (!writeRect || !insideRect(x, y, writeRect)) return 0;
+ const cache = rects.patchAlphaCache;
+ if (
+ cache
+ && cache.seed === seed
+ && x >= cache.x0
+ && y >= cache.y0
+ && x < cache.x0 + cache.width
+ && y < cache.y0 + cache.height
+ ) return cache.data[(y - cache.y0) * cache.width + (x - cache.x0)] || 0;
+ return computePatchAlpha(x, y, rects, seed);
+}
+
+function patchBand(x, y, rects, seed = 0) {
+ const a = patchAlpha(x, y, rects, seed);
+ if (a <= 0.18) return "preserve";
+ if (a >= 0.82) return "core";
+ return "feather";
+}
+
+function continuityReplaceThreshold(name, x, y, rects, seed = 0) {
+ const n = valueNoise(x, y, seed ^ 0x4f1bbcdc, 11) - 0.5;
+ if (ADMIN_CONTINUITY_FIELD_NAMES.has(name)) return clamp(0.82 + n * 0.12, 0.70, 0.92);
+ if (NATURAL_CONTINUITY_FIELD_NAMES.has(name)) return clamp(0.68 + n * 0.16, 0.54, 0.82);
+ return clamp(0.46 + n * 0.20, 0.28, 0.68);
+}
+
+function continuitySegmentAllowed(x, y, nx, ny, rects, seed, minAlpha = 0.42) {
+ if (!rects) return true;
+ return Math.min(patchAlpha(x, y, rects, seed), patchAlpha(nx, ny, rects, seed)) >= minAlpha;
+}
+
+function patchAffected(x, y, rects, seed = 0, minAlpha = 0.34) {
+ return insideRect(Math.round(x), Math.round(y), rects?.writeRect) && patchAlpha(Math.round(x), Math.round(y), rects, seed) >= minAlpha;
+}
+
+function segmentTouchesPatch(world, seg, rects, seed = 0, minAlpha = 0.34) {
+ if (!Array.isArray(seg) || seg.length < 2) return false;
+ const ax = tupleWorldX(world, seg[0]);
+ const ay = tupleWorldY(world, seg[0]);
+ const bx = tupleWorldX(world, seg[1]);
+ const by = tupleWorldY(world, seg[1]);
+ const mx = (ax + bx) * 0.5;
+ const my = (ay + by) * 0.5;
+ return patchAffected(ax, ay, rects, seed, minAlpha) || patchAffected(bx, by, rects, seed, minAlpha) || patchAffected(mx, my, rects, seed, minAlpha);
+}
+
+
+function sourceWindowForRects(rects) {
+ const cx = (rects.coreRect.x0 + rects.coreRect.x1 - 1) / 2;
+ const cy = (rects.coreRect.y0 + rects.coreRect.y1 - 1) / 2;
+ return {
+ worldCenterX: cx,
+ worldCenterY: cy,
+ sourceCenterX: (MAP_W - 1) / 2,
+ sourceCenterY: (MAP_H - 1) / 2,
+ };
+}
+
+function sourceCoordForWorld(window, x, y) {
+ return {
+ x: Math.round(x - window.worldCenterX + window.sourceCenterX),
+ y: Math.round(y - window.worldCenterY + window.sourceCenterY),
+ };
+}
+
+function getPatchSourceIndexCache(rects, window) {
+ const writeRect = rects?.writeRect;
+ if (!writeRect || !window) return null;
+ const width = rectWidth(writeRect);
+ const height = rectHeight(writeRect);
+ const existing = rects.patchSourceIndexCache;
+ if (
+ existing
+ && existing.width === width
+ && existing.height === height
+ && existing.x0 === writeRect.x0
+ && existing.y0 === writeRect.y0
+ && existing.worldCenterX === window.worldCenterX
+ && existing.worldCenterY === window.worldCenterY
+ && existing.sourceCenterX === window.sourceCenterX
+ && existing.sourceCenterY === window.sourceCenterY
+ ) return existing;
+
+ const data = new Int32Array(width * height);
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const sx = Math.round(writeRect.x0 + x - window.worldCenterX + window.sourceCenterX);
+ const sy = Math.round(writeRect.y0 + y - window.worldCenterY + window.sourceCenterY);
+ data[y * width + x] = sourceIndex(sx, sy);
+ }
+ }
+ rects.patchSourceIndexCache = {
+ width,
+ height,
+ x0: writeRect.x0,
+ y0: writeRect.y0,
+ worldCenterX: window.worldCenterX,
+ worldCenterY: window.worldCenterY,
+ sourceCenterX: window.sourceCenterX,
+ sourceCenterY: window.sourceCenterY,
+ data,
+ };
+ return rects.patchSourceIndexCache;
+}
+
+function sourceIndexForWorld(rects, window, x, y) {
+ const cache = rects?.patchSourceIndexCache;
+ if (
+ cache
+ && x >= cache.x0
+ && y >= cache.y0
+ && x < cache.x0 + cache.width
+ && y < cache.y0 + cache.height
+ ) return cache.data[(y - cache.y0) * cache.width + (x - cache.x0)];
+ const s = sourceCoordForWorld(window, x, y);
+ return sourceIndex(s.x, s.y);
+}
+
+function worldCoordForSource(window, sx, sy) {
+ return {
+ x: Math.round(sx - window.sourceCenterX + window.worldCenterX),
+ y: Math.round(sy - window.sourceCenterY + window.worldCenterY),
+ };
+}
+
+function fieldIdOffset(name, seed) {
+ const base = ID_FIELD_OFFSETS.get(name) || 0;
+ if (!base) return 0;
+ return base + ((seed >>> 0) % 997) * 10000;
+}
+
+function maxFieldId(field) {
+ if (!field) return -1;
+ let max = -1;
+ for (let i = 0; i < field.length; i++) {
+ const id = field[i];
+ if (Number.isFinite(id) && id > max) max = id;
+ }
+ return max;
+}
+
+function addMappingVote(votes, from, to, weight = 1) {
+ if (!Number.isFinite(from) || from < 0 || !Number.isFinite(to) || to < 0) return;
+ const key = Math.floor(from);
+ const target = Math.floor(to);
+ const bucket = votes.get(key) || new Map();
+ bucket.set(target, (bucket.get(target) || 0) + Math.max(1, weight));
+ votes.set(key, bucket);
+}
+
+function chooseVotedTarget(bucket, minVotes = 1) {
+ let best = -1;
+ let bestVotes = 0;
+ for (const [target, count] of bucket || []) {
+ if (count > bestVotes || (count === bestVotes && target < best)) {
+ best = target;
+ bestVotes = count;
+ }
+ }
+ return best >= 0 && bestVotes >= minVotes ? best : -1;
+}
+
+function collectCandidateIdsInRect(candidateField, rects, window, minAlpha = 0.20, seed = 0) {
+ const ids = new Set();
+ if (!candidateField) return ids;
+ const rect = rects.writeRect;
+ for (let y = rect.y0; y < rect.y1; y++) {
+ for (let x = rect.x0; x < rect.x1; x++) {
+ if (patchAlpha(x, y, rects, seed) < minAlpha) continue;
+ const si = sourceIndexForWorld(rects, window, x, y);
+ if (si < 0) continue;
+ const id = candidateField[si];
+ if (Number.isFinite(id) && id >= 0) ids.add(Math.floor(id));
+ }
+ }
+ return ids;
+}
+
+function buildCandidatePrefByAdmin(candidateMap, candidateAdminIds) {
+ const out = new Map();
+ const direct = candidateMap?.municipalityToPrefectureId;
+ for (const id of candidateAdminIds || []) {
+ const pref = direct?.[id];
+ if (Number.isFinite(pref) && pref >= 0) out.set(id, Math.floor(pref));
+ }
+ const adminField = candidateMap?.adminId || candidateMap?.municipalityId;
+ const prefField = candidateMap?.prefectureRegionId;
+ if (!adminField || !prefField) return out;
+ const votes = new Map();
+ for (let i = 0; i < adminField.length; i++) {
+ const admin = adminField[i];
+ const pref = prefField[i];
+ if (!Number.isFinite(admin) || admin < 0 || !Number.isFinite(pref) || pref < 0) continue;
+ if (candidateAdminIds?.size && !candidateAdminIds.has(Math.floor(admin))) continue;
+ addMappingVote(votes, admin, pref, 1);
+ }
+ for (const [admin, bucket] of votes) {
+ if (!out.has(admin)) {
+ const pref = chooseVotedTarget(bucket, 1);
+ if (pref >= 0) out.set(admin, pref);
+ }
+ }
+ return out;
+}
+
+export function buildAdminIdMapping({ candidateMap, world, writeRect, seamBand = 24, window = null, rects = null, seed = 0 } = {}) {
+ const actualWindow = window || (rects ? sourceWindowForRects(rects) : null);
+ const actualRects = rects || { writeRect, writeMargin: seamBand || 1 };
+ if (!candidateMap || !world || !writeRect || !actualWindow) {
+ return {
+ prefecture: new Map(),
+ municipality: new Map(),
+ admin: new Map(),
+ candidateAdminToPrefecture: new Map(),
+ debug: { prefecturesMappedToExisting: 0, prefecturesAllocated: 0, municipalitiesMappedToExisting: 0, municipalitiesAllocated: 0 },
+ };
+ }
+
+ const candidateAdmin = candidateMap.adminId || candidateMap.municipalityId;
+ const candidateMunicipality = candidateMap.municipalityId || candidateAdmin;
+ const candidatePrefecture = candidateMap.prefectureRegionId;
+ const worldAdmin = world.fields?.adminId || world.fields?.municipalityId;
+ const worldMunicipality = world.fields?.municipalityId || worldAdmin;
+ const worldPrefecture = world.fields?.prefectureRegionId;
+
+ const candidateAdminIds = collectCandidateIdsInRect(candidateAdmin, actualRects, actualWindow, 0.18, seed);
+ const candidateMunicipalityIds = collectCandidateIdsInRect(candidateMunicipality, actualRects, actualWindow, 0.18, seed);
+ const candidatePrefectureIds = collectCandidateIdsInRect(candidatePrefecture, actualRects, actualWindow, 0.18, seed);
+ const candidateAdminToPrefecture = buildCandidatePrefByAdmin(candidateMap, candidateAdminIds);
+
+ const adminVotes = new Map();
+ const municipalityVotes = new Map();
+ const prefectureVotes = new Map();
+ const band = Math.max(2, Math.floor(seamBand));
+ const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
+
+ for (let y = writeRect.y0; y < writeRect.y1; y++) {
+ for (let x = writeRect.x0; x < writeRect.x1; x++) {
+ const edge = distanceToRectEdge(x, y, writeRect);
+ if (edge > band) continue;
+ const s = sourceCoordForWorld(actualWindow, x, y);
+ const si = sourceIndex(s.x, s.y);
+ const wi = worldIndex(world, x, y);
+ if (si < 0 || wi < 0) continue;
+ const cAdmin = candidateAdmin?.[si] ?? -1;
+ const cMunicipality = candidateMunicipality?.[si] ?? cAdmin;
+ const cPrefecture = candidatePrefecture?.[si] ?? -1;
+ const sameCellWeight = Math.max(1, band + 1 - edge);
+ addMappingVote(adminVotes, cAdmin, worldAdmin?.[wi] ?? -1, sameCellWeight);
+ addMappingVote(municipalityVotes, cMunicipality, worldMunicipality?.[wi] ?? worldAdmin?.[wi] ?? -1, sameCellWeight);
+ addMappingVote(prefectureVotes, cPrefecture, worldPrefecture?.[wi] ?? -1, sameCellWeight);
+
+ for (const [dx, dy] of dirs) {
+ for (let step = 1; step <= 6; step++) {
+ const nx = x + dx * step;
+ const ny = y + dy * step;
+ if (insideRect(nx, ny, writeRect)) continue;
+ const ni = worldIndex(world, nx, ny);
+ if (ni < 0) break;
+ const w = Math.max(1, 7 - step) + Math.max(0, band - edge) * 0.25;
+ addMappingVote(adminVotes, cAdmin, worldAdmin?.[ni] ?? -1, w);
+ addMappingVote(municipalityVotes, cMunicipality, worldMunicipality?.[ni] ?? worldAdmin?.[ni] ?? -1, w);
+ addMappingVote(prefectureVotes, cPrefecture, worldPrefecture?.[ni] ?? -1, w);
+ break;
+ }
+ }
+ }
+ }
+
+ const prefecture = new Map();
+ let nextPrefectureId = maxFieldId(worldPrefecture) + 1;
+ let prefecturesMappedToExisting = 0;
+ let prefecturesAllocated = 0;
+ for (const id of [...candidatePrefectureIds].sort((a, b) => a - b)) {
+ const voted = chooseVotedTarget(prefectureVotes.get(id), 3);
+ if (voted >= 0) {
+ prefecture.set(id, voted);
+ prefecturesMappedToExisting++;
+ } else {
+ prefecture.set(id, nextPrefectureId++);
+ prefecturesAllocated++;
+ }
+ }
+
+ const usedAdminIds = new Set();
+ if (worldAdmin) {
+ for (let i = 0; i < worldAdmin.length; i++) if (worldAdmin[i] >= 0) usedAdminIds.add(worldAdmin[i]);
+ }
+ const municipality = new Map();
+ const admin = new Map();
+ let nextMunicipalityId = maxFieldId(worldAdmin || worldMunicipality) + 1;
+ let municipalitiesMappedToExisting = 0;
+ let municipalitiesAllocated = 0;
+ const allMunicipalityIds = new Set([...candidateAdminIds, ...candidateMunicipalityIds]);
+ for (const id of [...allMunicipalityIds].sort((a, b) => a - b)) {
+ const voted = chooseVotedTarget(municipalityVotes.get(id) || adminVotes.get(id), 4);
+ if (voted >= 0) {
+ municipality.set(id, voted);
+ admin.set(id, voted);
+ municipalitiesMappedToExisting++;
+ } else {
+ while (usedAdminIds.has(nextMunicipalityId)) nextMunicipalityId++;
+ municipality.set(id, nextMunicipalityId);
+ admin.set(id, nextMunicipalityId);
+ usedAdminIds.add(nextMunicipalityId);
+ nextMunicipalityId++;
+ municipalitiesAllocated++;
+ }
+ }
+
+ const municipalityToPrefecture = new Map();
+ for (const [candidateAdminId, worldAdminId] of admin) {
+ const candidatePrefId = candidateAdminToPrefecture.get(candidateAdminId);
+ const worldPrefId = prefecture.get(candidatePrefId);
+ if (Number.isFinite(worldAdminId) && Number.isFinite(worldPrefId)) municipalityToPrefecture.set(worldAdminId, worldPrefId);
+ }
+
+ return {
+ prefecture,
+ municipality,
+ admin,
+ candidateAdminToPrefecture,
+ municipalityToPrefecture,
+ debug: {
+ candidatePrefectureIds: candidatePrefectureIds.size,
+ candidateMunicipalityIds: allMunicipalityIds.size,
+ prefecturesMappedToExisting,
+ prefecturesAllocated,
+ municipalitiesMappedToExisting,
+ municipalitiesAllocated,
+ },
+ };
+}
+
+function remapAdminCandidateValue(name, raw, adminIdMapping) {
+ if (!Number.isFinite(raw) || raw < 0 || !adminIdMapping) return raw;
+ const id = Math.floor(raw);
+ if (name === "prefectureRegionId") return adminIdMapping.prefecture?.get(id) ?? raw;
+ if (name === "adminId") return adminIdMapping.admin?.get(id) ?? raw;
+ if (name === "municipalityId") return adminIdMapping.municipality?.get(id) ?? adminIdMapping.admin?.get(id) ?? raw;
+ return raw;
+}
+
+function numericFeatureId(point, keys) {
+ for (const key of keys) {
+ const value = point?.[key];
+ if (Number.isFinite(value) && value >= 0) return Math.floor(value);
+ }
+ return -1;
+}
+
+function summarizeIdMapping(mapping) {
+ return { ...(mapping?.debug || {}) };
+}
+
+function updateSourceAdminMetadata(sourceMap, adminIdMapping) {
+ if (!sourceMap || !adminIdMapping?.municipalityToPrefecture?.size) return 0;
+ let maxId = -1;
+ const current = sourceMap.municipalityToPrefectureId;
+ if (current && typeof current.length === "number") maxId = Math.max(maxId, current.length - 1);
+ for (const [adminId] of adminIdMapping.municipalityToPrefecture) maxId = Math.max(maxId, adminId);
+ const next = new Int32Array(Math.max(0, maxId + 1));
+ next.fill(-1);
+ if (current && typeof current.length === "number") {
+ for (let i = 0; i < current.length && i < next.length; i++) next[i] = current[i] ?? -1;
+ }
+ let updated = 0;
+ for (const [adminId, prefId] of adminIdMapping.municipalityToPrefecture) {
+ if (!Number.isFinite(adminId) || adminId < 0 || !Number.isFinite(prefId) || prefId < 0) continue;
+ if (next[adminId] !== prefId) updated++;
+ next[adminId] = prefId;
+ }
+ sourceMap.municipalityToPrefectureId = next;
+ sourceMap.patchAdminIdMappingDebug = summarizeIdMapping(adminIdMapping);
+ return updated;
+}
+
+function cloneContinuityFields(world) {
+ const out = new Map();
+ for (const name of CONTINUITY_FIELD_NAMES) {
+ const field = world?.fields?.[name];
+ if (ArrayBuffer.isView(field)) out.set(name, new field.constructor(field));
+ }
+ return out;
+}
+
+function stabilizeContinuitySeam(world, rects, oldFields, seed = 0) {
+ let restored = 0;
+ let remapped = 0;
+ const margin = Math.max(2, rects.writeMargin || 1);
+ for (const name of CONTINUITY_FIELD_NAMES) {
+ const field = world.fields?.[name];
+ const old = oldFields?.get(name);
+ if (!field || !old) continue;
+ const isPrefecture = name === "prefectureRegionId";
+ const isAdmin = name === "adminId" || name === "municipalityId";
+ const preserveAlpha = isPrefecture ? 0.94 : isAdmin ? 0.90 : 0.74;
+ const preserveEdge = isPrefecture ? margin * 1.25 : isAdmin ? margin : margin * 0.72;
+
+ // First preserve the old IDs in the transition band. This prevents the
+ // writeRect edge from becoming a prefecture/municipal border.
+ for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
+ for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0 || old[i] < 0) continue;
+ const edge = distanceToRectEdge(x, y, rects.writeRect);
+ const a = patchAlpha(x, y, rects, seed);
+ if (edge <= preserveEdge || a < preserveAlpha) {
+ if (field[i] !== old[i]) { field[i] = old[i]; restored++; }
+ }
+ }
+ }
+
+ // Then map candidate IDs that contact an outside ID back to that outside ID.
+ // This lets prefectures/municipalities cross the generated-area seam instead
+ // of creating a new border exactly on the seam.
+ const contacts = new Map();
+ const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
+ for (let y = rects.writeRect.y0 + 1; y < rects.writeRect.y1 - 1; y++) {
+ for (let x = rects.writeRect.x0 + 1; x < rects.writeRect.x1 - 1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0 || field[i] < 0 || old[i] === field[i]) continue;
+ const a = patchAlpha(x, y, rects, seed);
+ if (a < 0.98 && !isPrefecture) continue;
+ for (const [dx, dy] of dirs) {
+ const ni = worldIndex(world, x + dx, y + dy);
+ if (ni < 0 || old[ni] < 0 || old[ni] === field[i]) continue;
+ if (field[ni] === old[ni] || patchAlpha(x + dx, y + dy, rects, seed) < preserveAlpha) {
+ const key = field[i];
+ const bucket = contacts.get(key) || new Map();
+ bucket.set(old[ni], (bucket.get(old[ni]) || 0) + 1);
+ contacts.set(key, bucket);
+ }
+ }
+ }
+ }
+ const mapping = new Map();
+ for (const [from, bucket] of contacts) {
+ let best = -1, bestCount = 0;
+ for (const [to, count] of bucket) if (count > bestCount) { best = to; bestCount = count; }
+ if (best >= 0 && bestCount >= (isPrefecture ? 2 : 3)) mapping.set(from, best);
+ }
+ if (mapping.size) {
+ for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
+ for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i >= 0 && mapping.has(field[i])) { field[i] = mapping.get(field[i]); remapped++; }
+ }
+ }
+ }
+ }
+ return { continuityCellsRestored: restored, continuityCellsRemapped: remapped };
+}
+
+function chooseSeamOwnerValue(world, fieldName, oldField, candidateValue, x, y, rects, seed) {
+ const a = patchAlpha(x, y, rects, seed);
+ const i = worldIndex(world, x, y);
+ const oldValue = oldField?.[i] ?? -1;
+ if (oldValue < 0 || candidateValue < 0) return candidateValue >= 0 ? candidateValue : oldValue;
+ if (a <= 0.24) return oldValue;
+ if (a >= 0.82) return candidateValue;
+
+ const field = world.fields?.[fieldName];
+ const pref = world.fields?.prefectureRegionId;
+ const naturalBarrier = world.fields?.naturalBarrierScore || world.fields?.ridgeField;
+ let oldScore = (1 - a) * 3.0;
+ let candidateScore = a * 3.0;
+ for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
+ const ni = worldIndex(world, x + dx, y + dy);
+ if (ni < 0) continue;
+ const neighbor = field?.[ni] ?? -1;
+ if (neighbor === oldValue) oldScore += 1.2;
+ if (neighbor === candidateValue) candidateScore += 1.2;
+ if (fieldName !== "prefectureRegionId" && pref && pref[ni] >= 0) {
+ if (pref[ni] === pref[i] && candidateValue !== oldValue) oldScore += 0.18;
+ }
+ }
+ const barrierBonus = naturalBarrier?.[i] || 0;
+ if (barrierBonus > 0.48 && Math.abs(a - 0.5) < 0.24) {
+ if (a < 0.5) oldScore += barrierBonus * 0.9;
+ else candidateScore += barrierBonus * 0.9;
+ }
+ return candidateScore > oldScore ? candidateValue : oldValue;
+}
+
+function repairDiscreteSeamOwnership(world, rects, oldFields, seed = 0) {
+ let adminSeamCellsResolved = 0;
+ let prefectureSeamCellsResolved = 0;
+ for (const name of ["prefectureRegionId", "adminId", "municipalityId"]) {
+ const field = world.fields?.[name];
+ const old = oldFields?.get(name);
+ if (!field || !old) continue;
+ for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
+ for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0 || world.fields.sea?.[i]) continue;
+ if (patchBand(x, y, rects, seed) !== "feather") continue;
+ const before = field[i];
+ const next = chooseSeamOwnerValue(world, name, old, before, x, y, rects, seed);
+ if (next !== before) {
+ field[i] = next;
+ if (name === "prefectureRegionId") prefectureSeamCellsResolved++;
+ else adminSeamCellsResolved++;
+ }
+ }
+ }
+ }
+ if (world.fields.adminId && world.fields.municipalityId) {
+ for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
+ for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i >= 0 && !world.fields.sea?.[i]) world.fields.municipalityId[i] = world.fields.adminId[i];
+ }
+ }
+ }
+ return { adminSeamCellsResolved, prefectureSeamCellsResolved };
+}
+
+function copyFullPipelineFields(world, candidate, rects, seed) {
+ const window = sourceWindowForRects(rects);
+ const oldSea = world.fields.sea ? new Uint8Array(world.fields.sea) : null;
+ const oldContinuityFields = cloneContinuityFields(world);
+ const adminIdMapping = buildAdminIdMapping({
+ candidateMap: candidate,
+ world,
+ writeRect: rects.writeRect,
+ seamBand: Math.max(8, Math.floor(rects.writeMargin || 24)),
+ window,
+ rects,
+ seed,
+ });
+ let updatedCells = 0;
+ let coastCellsChanged = 0;
+ let terrainCellsFullyReplaced = 0;
+ let naturalRegionsUpdated = 0;
+ let adminCellsReassigned = 0;
+ let landUseCellsUpdated = 0;
+
+ for (const [name, source] of Object.entries(candidate || {})) {
+ if (SKIP_CELL_FIELDS.has(name) || !isCellField(source)) continue;
+ const dest = ensureWorldField(world, name, source);
+ if (!dest) continue;
+ const isFloat = source.constructor === Float32Array || source.constructor === Float64Array;
+ const isDiscrete = DISCRETE_FIELD_NAMES.has(name) || !isFloat;
+ const idOffset = fieldIdOffset(name, seed);
+
+ for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
+ for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
+ const wi = worldIndex(world, x, y);
+ if (wi < 0) continue;
+ const si = sourceIndexForWorld(rects, window, x, y);
+ if (si < 0) continue;
+ const alpha = patchAlpha(x, y, rects, seed);
+ if (alpha <= 0.005) continue;
+
+ if (isDiscrete) {
+ const threshold = continuityReplaceThreshold(name, x, y, rects, seed);
+ if (alpha >= threshold) {
+ const raw = source[si];
+ const mapped = remapAdminCandidateValue(name, raw, adminIdMapping);
+ const value = (name === "adminId" || name === "municipalityId" || name === "prefectureRegionId")
+ ? mapped
+ : idOffset && raw >= 0 ? raw + idOffset : raw;
+ if (name === "sea" && oldSea && dest[wi] !== value) coastCellsChanged++;
+ if ((name === "naturalCompartmentId" || name === "watershedId" || name === "regionId") && dest[wi] !== value) naturalRegionsUpdated++;
+ if ((name === "adminId" || name === "municipalityId") && dest[wi] !== value) adminCellsReassigned++;
+ if (name === "landuse" && dest[wi] !== value) landUseCellsUpdated++;
+ dest[wi] = value;
+ }
+ } else {
+ const before = dest[wi] || 0;
+ dest[wi] = lerp(before, source[si] || 0, alpha);
+ }
+
+ if (name === "elevation") {
+ updatedCells++;
+ if (alpha > 0.94) terrainCellsFullyReplaced++;
+ }
+ }
+ }
+ }
+
+ // The legacy full pipeline uses `adminId` as the municipality raster and
+ // assigns municipality metadata on `adminCenters`; it does not expose a
+ // separate municipalityId cell field. If an old experimental field exists,
+ // keep it synchronized with the canonical legacy adminId instead of leaving
+ // stale numeric/one-municipality data in regenerated patches.
+ if (world.fields.adminId && !candidate?.municipalityId) {
+ const expected = world.width * world.height;
+ if (!world.fields.municipalityId || world.fields.municipalityId.length !== expected) {
+ world.fields.municipalityId = new Int32Array(expected);
+ world.fields.municipalityId.fill(-1);
+ }
+ const municipalityId = world.fields.municipalityId;
+ const adminId = world.fields.adminId;
+ for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
+ for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
+ const wi = worldIndex(world, x, y);
+ if (wi >= 0) municipalityId[wi] = adminId[wi];
+ }
+ }
+ }
+
+ // Keep water fields coherent after all continuous fields have been blended.
+ const sea = world.fields.sea;
+ const ocean = world.fields.ocean;
+ const lake = world.fields.lake;
+ const landuse = world.fields.landuse;
+ if (sea) {
+ for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
+ for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0) continue;
+ if (sea[i]) {
+ if (ocean) ocean[i] = 1;
+ if (lake) lake[i] = 0;
+ if (landuse) landuse[i] = LANDUSE.WATER || 0;
+ } else {
+ if (ocean) ocean[i] = 0;
+ if (lake) lake[i] = 0;
+ }
+ }
+ }
+ }
+
+ const continuityDebug = stabilizeContinuitySeam(world, rects, oldContinuityFields, seed);
+ const seamOwnershipDebug = repairDiscreteSeamOwnership(world, rects, oldContinuityFields, seed);
+ if (world.fields.adminId && world.fields.municipalityId && !candidate?.municipalityId) {
+ for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
+ for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
+ const wi = worldIndex(world, x, y);
+ if (wi >= 0) world.fields.municipalityId[wi] = world.fields.adminId[wi];
+ }
+ }
+ }
+
+ return {
+ window,
+ updatedCells,
+ terrainCellsFullyReplaced,
+ coastCellsChanged,
+ naturalRegionsUpdated,
+ adminCellsReassigned,
+ landUseCellsUpdated,
+ adminIdMapping,
+ adminIdMappingDebug: summarizeIdMapping(adminIdMapping),
+ ...continuityDebug,
+ ...seamOwnershipDebug,
+ };
+}
+
+
+function repairDisplayMasks(world, rects, seed = 0) {
+ const expected = world.width * world.height;
+ if (!world.fields.prefectureMask || world.fields.prefectureMask.length !== expected) world.fields.prefectureMask = new Uint8Array(expected);
+ if (!world.fields.landMask || world.fields.landMask.length !== expected) world.fields.landMask = new Uint8Array(expected);
+ if (!world.fields.humanRegionMask || world.fields.humanRegionMask.length !== expected) world.fields.humanRegionMask = new Uint8Array(expected);
+ const coverage = world.fields.prefectureMask;
+ const landMask = world.fields.landMask;
+ const humanMask = world.fields.humanRegionMask;
+ const sea = world.fields.sea;
+ let displayMaskUpdated = 0;
+ for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
+ for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0) continue;
+ const a = patchAlpha(x, y, rects, seed);
+ if (a <= 0.08) continue;
+ if (!coverage[i]) displayMaskUpdated++;
+ coverage[i] = 1;
+ const isSea = Boolean(sea?.[i]);
+ humanMask[i] = isSea ? 0 : 1;
+ landMask[i] = isSea ? 0 : 1;
+ }
+ }
+ return { displayMaskUpdated };
+}
+
+function featherTerrainSeam(world, rects, seed = 0) {
+ const fields = world.fields || {};
+ const smoothKeys = [
+ "elevation", "moisture", "ridgeField", "valleyField", "visibleRavineField",
+ "basinField", "coastalLowland", "plain", "agriculture", "erosionField",
+ "depositionField", "depositionalLowland", "alluvialFanField", "deltaField",
+ "naturalBarrierScore", "settlementScore", "populationDensity",
+ ];
+ let terrainFeatherCells = 0;
+ let terrainFeatherValues = 0;
+ for (const key of smoothKeys) {
+ const field = fields[key];
+ if (!field || !ArrayBuffer.isView(field)) continue;
+ const old = new field.constructor(field);
+ for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
+ for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
+ if (patchBand(x, y, rects, seed) !== "feather") continue;
+ const i = worldIndex(world, x, y);
+ if (i < 0 || fields.sea?.[i]) continue;
+ let sum = 0;
+ let count = 0;
+ for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
+ const ni = worldIndex(world, x + dx, y + dy);
+ if (ni >= 0 && !fields.sea?.[ni]) {
+ sum += old[ni] || 0;
+ count++;
+ }
+ }
+ if (!count) continue;
+ const a = patchAlpha(x, y, rects, seed);
+ const neighborMean = sum / count;
+ const seamWeight = 0.34 * (1 - Math.abs(a - 0.5) * 1.2);
+ field[i] = lerp(field[i] || 0, neighborMean, clamp(seamWeight, 0.08, 0.34));
+ terrainFeatherValues++;
+ if (key === "elevation") terrainFeatherCells++;
+ }
+ }
+ }
+ return { terrainFeatherCells, terrainFeatherValues };
+}
+
+
+function nearestLandFieldValue(world, x, y, fieldName, rect, options = {}) {
+ const field = world.fields?.[fieldName];
+ const sea = world.fields?.sea;
+ if (!field) return -1;
+ const maxRadius = Math.max(1, Math.floor(options.maxRadius || 18));
+ const requiredPref = Number.isFinite(options.requiredPref) ? Math.floor(options.requiredPref) : null;
+ const prefField = world.fields?.prefectureRegionId;
+ for (let r = 1; r <= maxRadius; r++) {
+ let best = -1;
+ let bestD = Infinity;
+ const y0 = Math.max(0, y - r);
+ const y1 = Math.min(world.height - 1, y + r);
+ const x0 = Math.max(0, x - r);
+ const x1 = Math.min(world.width - 1, x + r);
+ for (let yy = y0; yy <= y1; yy++) {
+ for (let xx = x0; xx <= x1; xx++) {
+ if (Math.max(Math.abs(xx - x), Math.abs(yy - y)) !== r) continue;
+ if (rect && !insideRect(xx, yy, rect)) continue;
+ const i = worldIndex(world, xx, yy);
+ if (i < 0 || sea?.[i]) continue;
+ if (requiredPref !== null && prefField?.[i] !== requiredPref) continue;
+ const id = field[i];
+ if (!Number.isFinite(id) || id < 0) continue;
+ const d = Math.hypot(xx - x, yy - y);
+ if (d < bestD) { best = Math.floor(id); bestD = d; }
+ }
+ }
+ if (best >= 0) return best;
+ }
+ return -1;
+}
+
+function lookupPrefectureForAdmin(sourceMap, adminIdMapping, adminId) {
+ if (!Number.isFinite(adminId) || adminId < 0) return -1;
+ const id = Math.floor(adminId);
+ const mapped = adminIdMapping?.municipalityToPrefecture?.get(id);
+ if (Number.isFinite(mapped) && mapped >= 0) return Math.floor(mapped);
+ const table = sourceMap?.municipalityToPrefectureId;
+ if (table && id >= 0 && id < table.length && Number.isFinite(table[id]) && table[id] >= 0) return Math.floor(table[id]);
+ return -1;
+}
+
+function repairAdminCoverage(world, sourceMap, rects, adminIdMapping = null, seed = 0) {
+ const admin = world.fields?.adminId;
+ if (!admin) return { seaAdminCellsCleared: 0, landAdminCellsFilled: 0, prefectureCellsFilled: 0, adminPrefectureCellsAligned: 0 };
+ const expected = world.width * world.height;
+ if (!world.fields.municipalityId || world.fields.municipalityId.length !== expected) {
+ world.fields.municipalityId = new Int32Array(expected);
+ world.fields.municipalityId.fill(-1);
+ }
+ if (!world.fields.prefectureRegionId || world.fields.prefectureRegionId.length !== expected) {
+ world.fields.prefectureRegionId = new Int32Array(expected);
+ world.fields.prefectureRegionId.fill(-1);
+ }
+ const municipality = world.fields.municipalityId;
+ const prefecture = world.fields.prefectureRegionId;
+ const sea = world.fields.sea;
+ const coverage = world.fields.prefectureMask;
+ let seaAdminCellsCleared = 0;
+ let landAdminCellsFilled = 0;
+ let prefectureCellsFilled = 0;
+ let adminPrefectureCellsAligned = 0;
+
+ for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
+ for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0) continue;
+ if (sea?.[i]) {
+ if (admin[i] >= 0 || municipality[i] >= 0 || prefecture[i] >= 0) seaAdminCellsCleared++;
+ admin[i] = -1;
+ municipality[i] = -1;
+ prefecture[i] = -1;
+ continue;
+ }
+ const generated = patchAlpha(x, y, rects, seed) > 0.08 || (!coverage && insideRect(x, y, rects.writeRect));
+ if (!generated) continue;
+
+ if (admin[i] < 0) {
+ const preferredPref = prefecture[i] >= 0 ? prefecture[i] : null;
+ let nearest = nearestLandFieldValue(world, x, y, 'adminId', rects.repairRect || rects.writeRect, { requiredPref: preferredPref, maxRadius: 24 });
+ if (nearest < 0) nearest = nearestLandFieldValue(world, x, y, 'adminId', null, { requiredPref: preferredPref, maxRadius: 18 });
+ if (nearest >= 0) {
+ admin[i] = nearest;
+ municipality[i] = nearest;
+ landAdminCellsFilled++;
+ }
+ }
+ if (municipality[i] < 0 && admin[i] >= 0) municipality[i] = admin[i];
+ if (admin[i] >= 0 && municipality[i] !== admin[i]) municipality[i] = admin[i];
+
+ let targetPref = lookupPrefectureForAdmin(sourceMap, adminIdMapping, admin[i]);
+ if (targetPref < 0 && prefecture[i] < 0) targetPref = nearestLandFieldValue(world, x, y, 'prefectureRegionId', rects.repairRect || rects.writeRect, { maxRadius: 28 });
+ if (targetPref >= 0 && prefecture[i] !== targetPref) {
+ if (prefecture[i] < 0) prefectureCellsFilled++;
+ else adminPrefectureCellsAligned++;
+ prefecture[i] = targetPref;
+ } else if (prefecture[i] < 0) {
+ const nearestPref = nearestLandFieldValue(world, x, y, 'prefectureRegionId', null, { maxRadius: 22 });
+ if (nearestPref >= 0) { prefecture[i] = nearestPref; prefectureCellsFilled++; }
+ }
+ }
+ }
+
+ const current = sourceMap?.municipalityToPrefectureId;
+ let maxId = current?.length ? current.length - 1 : -1;
+ for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
+ for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i >= 0 && admin[i] >= 0 && prefecture[i] >= 0) maxId = Math.max(maxId, admin[i]);
+ }
+ }
+ if (sourceMap && maxId >= 0) {
+ const next = new Int32Array(maxId + 1);
+ next.fill(-1);
+ if (current) for (let i = 0; i < current.length && i < next.length; i++) next[i] = current[i] ?? -1;
+ for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
+ for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i >= 0 && admin[i] >= 0 && prefecture[i] >= 0) next[admin[i]] = prefecture[i];
+ }
+ }
+ sourceMap.municipalityToPrefectureId = next;
+ }
+
+ return { seaAdminCellsCleared, landAdminCellsFilled, prefectureCellsFilled, adminPrefectureCellsAligned };
+}
+
+function smoothWaterTopology(world, rect, seaLevel = 0.30, rects = null, seed = 0) {
+ const sea = world.fields.sea;
+ const ocean = world.fields.ocean;
+ const lake = world.fields.lake;
+ const elevation = world.fields.elevation;
+ if (!sea || !elevation) return { coastCellsChanged: 0 };
+ let changed = 0;
+ for (let pass = 0; pass < 3; pass++) {
+ const flips = [];
+ for (let y = rect.y0 + 1; y < rect.y1 - 1; y++) {
+ for (let x = rect.x0 + 1; x < rect.x1 - 1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0) continue;
+ let seaN = 0;
+ let landN = 0;
+ for (let dy = -1; dy <= 1; dy++) {
+ for (let dx = -1; dx <= 1; dx++) {
+ if (!dx && !dy) continue;
+ const ni = worldIndex(world, x + dx, y + dy);
+ if (ni < 0) continue;
+ if (sea[ni]) seaN++; else landN++;
+ }
+ }
+ const a = rects ? patchAlpha(x, y, rects, seed) : 1;
+ if (a < 0.24) continue;
+ const strongOnly = a < 0.42;
+ if (sea[i] && seaN <= (strongOnly ? 0 : 1) && elevation[i] > seaLevel - 0.035) flips.push([i, 0]);
+ else if (!sea[i] && seaN >= (strongOnly ? 8 : 7) && elevation[i] < seaLevel + 0.055) flips.push([i, 1]);
+ }
+ }
+ for (const [i, nextSea] of flips) {
+ if (sea[i] === nextSea) continue;
+ sea[i] = nextSea;
+ if (ocean) ocean[i] = nextSea;
+ if (lake) lake[i] = 0;
+ if (nextSea) elevation[i] = Math.min(elevation[i], seaLevel - 0.004);
+ else elevation[i] = Math.max(elevation[i], seaLevel + 0.006);
+ changed++;
+ }
+ }
+ return { coastCellsChanged: changed };
+}
+
+function recomputeSlopeAndWaterDependentFields(world, rect, seaLevel = 0.30) {
+ const fields = world.fields;
+ const { elevation, sea } = fields;
+ if (!elevation || !sea) return;
+ if (!fields.slope) fields.slope = new Float32Array(world.width * world.height);
+ for (let y = rect.y0; y < rect.y1; y++) {
+ for (let x = rect.x0; x < rect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0) continue;
+ if (sea[i]) {
+ for (const key of ["slope", "river", "floodplain", "plain", "agriculture", "ridgeField", "valleyField", "coastalLowland", "naturalBarrierScore", "populationDensity", "settlementScore", "roadInfluence", "railInfluence2", "stationInfluence", "villageInfluence"]) {
+ if (fields[key]) fields[key][i] = 0;
+ }
+ continue;
+ }
+ if (x > 0 && y > 0 && x < world.width - 1 && y < world.height - 1) {
+ const gx = elevation[worldIndex(world, x + 1, y)] - elevation[worldIndex(world, x - 1, y)];
+ const gy = elevation[worldIndex(world, x, y + 1)] - elevation[worldIndex(world, x, y - 1)];
+ fields.slope[i] = clamp(Math.hypot(gx, gy) * 8.2);
+ }
+ if (fields.plain) fields.plain[i] = clamp((fields.plain[i] || 0) * 0.75 + (1 - (fields.slope[i] || 0)) * clamp((0.62 - elevation[i]) * 1.8) * 0.25);
+ if (fields.agriculture && fields.plain) fields.agriculture[i] = clamp((fields.agriculture[i] || 0) * 0.72 + fields.plain[i] * 0.28);
+ }
+ }
+}
+
+function seaNeighbors(world, x, y, radius = 1) {
+ let count = 0;
+ for (let dy = -radius; dy <= radius; dy++) {
+ for (let dx = -radius; dx <= radius; dx++) {
+ if (!dx && !dy) continue;
+ const i = worldIndex(world, x + dx, y + dy);
+ if (i >= 0 && world.fields.sea?.[i]) count++;
+ }
+ }
+ return count;
+}
+
+function isLand(world, x, y) {
+ const i = worldIndex(world, x, y);
+ return i >= 0 && !world.fields.sea?.[i];
+}
+
+function nearestLand(world, x, y, rect, radius = 10) {
+ if (insideRect(x, y, rect) && isLand(world, x, y)) return { x, y };
+ for (let r = 1; r <= radius; r++) {
+ let best = null;
+ let bestScore = Infinity;
+ for (let yy = y - r; yy <= y + r; yy++) {
+ for (let xx = x - r; xx <= x + r; xx++) {
+ if (Math.abs(xx - x) !== r && Math.abs(yy - y) !== r) continue;
+ if (!insideRect(xx, yy, rect) || !isLand(world, xx, yy)) continue;
+ const i = worldIndex(world, xx, yy);
+ const score = Math.hypot(xx - x, yy - y) + (world.fields.slope?.[i] || 0) * 3;
+ if (score < bestScore) { bestScore = score; best = { x: xx, y: yy }; }
+ }
+ }
+ if (best) return best;
+ }
+ return null;
+}
+
+function pointWorldX(world, p) {
+ if (Number.isFinite(p?.worldX)) return p.worldX;
+ return (p?.x || 0) + (world?.originX || 0);
+}
+
+function pointWorldY(world, p) {
+ if (Number.isFinite(p?.worldY)) return p.worldY;
+ return (p?.y || 0) + (world?.originY || 0);
+}
+
+function tupleWorldX(world, tuple) {
+ return (tuple?.[0] || 0) + (world?.originX || 0);
+}
+
+function tupleWorldY(world, tuple) {
+ return (tuple?.[1] || 0) + (world?.originY || 0);
+}
+
+function sourcePointFromWorld(world, point) {
+ return { ...point, x: point.x - world.originX, y: point.y - world.originY, worldX: point.x, worldY: point.y, patchGenerated: true };
+}
+
+function sourcePathFromWorld(world, path) {
+ return path.map(([x, y]) => [Math.round(x - world.originX), Math.round(y - world.originY)]);
+}
+
+function offsetPointNumericFields(point, fields, offset) {
+ for (const field of fields) if (Number.isFinite(point[field])) point[field] += offset;
+}
+
+function normalizeGeneratedPointIds(point, key, seed = 0, adminIdMapping = null) {
+ const rawAdminId = numericFeatureId(point, ["adminId", "municipalityId", "adminNumericId"]);
+ if (rawAdminId >= 0) {
+ const mappedAdminId = adminIdMapping?.admin?.get(rawAdminId) ?? adminIdMapping?.municipality?.get(rawAdminId);
+ if (Number.isFinite(mappedAdminId)) {
+ point.sourceAdminId = rawAdminId;
+ point.adminId = mappedAdminId;
+ point.adminNumericId = mappedAdminId;
+ point.municipalityId = mappedAdminId;
+ } else {
+ offsetPointNumericFields(point, ["adminId", "adminNumericId", "municipalityId"], fieldIdOffset("adminId", seed));
+ if (!Number.isFinite(point.adminId) && Number.isFinite(point.municipalityId)) point.adminId = point.municipalityId;
+ if (!Number.isFinite(point.municipalityId) && Number.isFinite(point.adminId)) point.municipalityId = point.adminId;
+ }
+ }
+
+ const rawPrefectureId = numericFeatureId(point, key === "prefectureRegions" ? ["prefectureRegionId", "id"] : ["prefectureRegionId"]);
+ if (rawPrefectureId >= 0) {
+ const mappedPrefectureId = adminIdMapping?.prefecture?.get(rawPrefectureId);
+ if (Number.isFinite(mappedPrefectureId)) {
+ point.sourcePrefectureRegionId = rawPrefectureId;
+ point.prefectureRegionId = mappedPrefectureId;
+ if (key === "prefectureRegions") point.id = mappedPrefectureId;
+ } else {
+ const offset = fieldIdOffset("prefectureRegionId", seed);
+ if (Number.isFinite(point.prefectureRegionId)) point.prefectureRegionId += offset;
+ if (key === "prefectureRegions" && Number.isFinite(point.id)) point.id += offset;
+ }
+ }
+
+ if (Number.isFinite(point.adminId) && !Number.isFinite(point.municipalityId)) point.municipalityId = point.adminId;
+ if (Number.isFinite(point.municipalityId) && !Number.isFinite(point.adminId)) point.adminId = point.municipalityId;
+ return point;
+}
+
+function transformCandidatePoint(world, window, p, key, seed = 0, adminIdMapping = null) {
+ if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y)) return null;
+ const w = worldCoordForSource(window, p.x, p.y);
+ if (key === "ports") {
+ const land = nearestLand(world, w.x, w.y, { x0: 0, y0: 0, x1: world.width, y1: world.height }, 8);
+ if (!land || seaNeighbors(world, land.x, land.y, 2) < 2) return null;
+ w.x = land.x; w.y = land.y;
+ } else if (!["crossings", "passes", "externalGateways", "prefectureRegions"].includes(key) && !isLand(world, w.x, w.y)) {
+ const land = nearestLand(world, w.x, w.y, { x0: 0, y0: 0, x1: world.width, y1: world.height }, 5);
+ if (!land) return null;
+ w.x = land.x; w.y = land.y;
+ }
+ const out = sourcePointFromWorld(world, { ...p, x: w.x, y: w.y });
+ normalizeGeneratedPointIds(out, key, seed, adminIdMapping);
+ if (key === "adminCenters") {
+ if (Number.isFinite(out.sourceAdminId)) {
+ const candidatePrefId = adminIdMapping?.candidateAdminToPrefecture?.get(out.sourceAdminId);
+ const mappedPrefId = adminIdMapping?.prefecture?.get(candidatePrefId);
+ if (Number.isFinite(mappedPrefId)) out.prefectureRegionId = mappedPrefId;
+ }
+ }
+ if (key === "logisticsParks") sanitizeLogisticsPark(out);
+ return out;
+}
+
+function sanitizeLogisticsPark(p) {
+ if (!p) return p;
+ p.name = null;
+ p.labelName = null;
+ p.facilityLabel = p.facilityLabel || "Logistics Park";
+ p.labelStyle = "facility";
+ p.suppressSettlementLabel = true;
+ p.kind = "Logistics Park";
+ p.population = 0;
+ return p;
+}
+
+function sanitizeExistingLogistics(sourceMap) {
+ let migrated = 0;
+ if (!Array.isArray(sourceMap.logisticsParks)) return 0;
+ for (const p of sourceMap.logisticsParks) {
+ if (!p) continue;
+ if (p.name || p.labelName || !p.suppressSettlementLabel) migrated++;
+ sanitizeLogisticsPark(p);
+ }
+ for (const key of ["villages", "markets", "modernCities", "satelliteCities", "newTowns", "adminCenters"]) {
+ const arr = sourceMap[key];
+ if (!Array.isArray(arr)) continue;
+ for (const p of arr) {
+ if (!p?.name || !/\bLogistics\b/i.test(String(p.name))) continue;
+ p.name = String(p.name).replace(/\s*Logistics\b/ig, "").trim() || null;
+ p.labelName = p.name;
+ migrated++;
+ }
+ }
+ return migrated;
+}
+
+function transformCandidatePath(window, path) {
+ const out = [];
+ for (const tuple of path || []) {
+ if (!Array.isArray(tuple) || tuple.length < 2) continue;
+ const p = worldCoordForSource(window, tuple[0], tuple[1]);
+ out.push([p.x, p.y]);
+ }
+ return out;
+}
+
+function splitWorldPathByPredicate(path, predicate, keepWhenTrue) {
+ const chunks = [];
+ let current = [];
+ for (const p of path || []) {
+ const matches = predicate(Math.round(p[0]), Math.round(p[1]));
+ if (matches === keepWhenTrue) current.push([Math.round(p[0]), Math.round(p[1])]);
+ else {
+ if (current.length >= 2) chunks.push(current);
+ current = [];
+ }
+ }
+ if (current.length >= 2) chunks.push(current);
+ return chunks;
+}
+
+function splitWorldPathByRect(path, rect, keepInside) {
+ return splitWorldPathByPredicate(path, (x, y) => insideRect(x, y, rect), keepInside);
+}
+
+function splitWorldPathByPatch(path, rects, seed, keepAffected, minAlpha = 0.34) {
+ return splitWorldPathByPredicate(path, (x, y) => patchAffected(x, y, rects, seed, minAlpha), keepAffected);
+}
+
+function pruneOldPathLayer(world, paths, rects, seed, mode) {
+ const kept = [];
+ const anchors = [];
+ let clipped = 0;
+ for (const path of paths || []) {
+ const worldPath = (path || []).map((tuple) => [Math.round(tupleWorldX(world, tuple)), Math.round(tupleWorldY(world, tuple))]);
+ const touches = worldPath.some(([x, y]) => patchAffected(x, y, rects, seed, 0.34));
+ if (!touches) {
+ kept.push(path);
+ continue;
+ }
+ clipped++;
+ let lastOutside = null;
+ let wasInside = false;
+ for (const [x, y] of worldPath) {
+ const inside = patchAffected(x, y, rects, seed, 0.34);
+ if (!inside) {
+ if (wasInside) anchors.push({ x, y, mode });
+ lastOutside = { x, y, mode };
+ } else if (lastOutside && !wasInside) {
+ anchors.push(lastOutside);
+ }
+ wasInside = inside;
+ }
+ for (const chunk of splitWorldPathByPatch(worldPath, rects, seed, false, 0.34)) kept.push(sourcePathFromWorld(world, chunk));
+ }
+ return { kept, anchors, clipped };
+}
+
+function pathCost(world, x, y, mode) {
+ const i = worldIndex(world, x, y);
+ if (i < 0 || world.fields.sea?.[i]) return Infinity;
+ const slope = world.fields.slope?.[i] || 0;
+ const river = world.fields.river?.[i] || 0;
+ const plain = world.fields.plain?.[i] || 0;
+ const roadInfluence = world.fields.roadInfluence?.[i] || 0;
+ const pop = world.fields.populationDensity?.[i] || 0;
+ const slopeMult = mode === "rail" ? 15 : 7;
+ return 1 + slope * slopeMult - plain * 0.35 - roadInfluence * 0.28 - pop * 0.18 + river * 0.18;
+}
+
+function localPathfind(world, start, goal, rect, mode = "road", maxExpanded = 24000) {
+ const sx = Math.round(start.x), sy = Math.round(start.y), gx = Math.round(goal.x), gy = Math.round(goal.y);
+ if (!insideRect(sx, sy, rect) || !insideRect(gx, gy, rect)) return null;
+ if (!isLand(world, sx, sy) || !isLand(world, gx, gy)) return null;
+ const w = rectWidth(rect);
+ const h = rectHeight(rect);
+ const n = w * h;
+ const dist = new Float64Array(n); dist.fill(Infinity);
+ const prev = new Int32Array(n); prev.fill(-1);
+ const local = (x, y) => (y - rect.y0) * w + (x - rect.x0);
+ const heap = new MinHeap();
+ const startId = local(sx, sy);
+ dist[startId] = 0;
+ heap.push({ x: sx, y: sy, f: Math.hypot(sx - gx, sy - gy), id: startId });
+ let expanded = 0;
+ let found = -1;
+ const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
+ while (heap.items.length && expanded < maxExpanded) {
+ const cur = heap.pop();
+ if (!cur) break;
+ if (cur.x === gx && cur.y === gy) { found = cur.id; break; }
+ expanded++;
+ for (const [dx, dy] of dirs) {
+ const nx = cur.x + dx, ny = cur.y + dy;
+ if (!insideRect(nx, ny, rect)) continue;
+ const nid = local(nx, ny);
+ const c = pathCost(world, nx, ny, mode);
+ if (!Number.isFinite(c)) continue;
+ const step = (dx && dy ? 1.42 : 1) * c;
+ const nd = dist[cur.id] + step;
+ if (nd >= dist[nid]) continue;
+ dist[nid] = nd;
+ prev[nid] = cur.id;
+ heap.push({ x: nx, y: ny, id: nid, f: nd + Math.hypot(nx - gx, ny - gy) * 1.05 });
+ }
+ }
+ if (found < 0) return null;
+ const rev = [];
+ let at = found;
+ while (at >= 0) {
+ const x = rect.x0 + (at % w);
+ const y = rect.y0 + Math.floor(at / w);
+ rev.push([x, y]);
+ at = prev[at];
+ }
+ return rev.reverse();
+}
+
+function simplifyPath(path, keepEvery = 2) {
+ if (!path || path.length <= 2) return path || [];
+ const out = [path[0]];
+ for (let i = 1; i < path.length - 1; i++) if (i % keepEvery === 0) out.push(path[i]);
+ out.push(path[path.length - 1]);
+ return out;
+}
+
+function collectInternalNetworkPoints(world, sourceMap, keys, rect, mode = "road") {
+ const points = [];
+ const seen = new Set();
+ const add = (x, y, key, weight = 1) => {
+ x = Math.round(x); y = Math.round(y);
+ if (!insideRect(x, y, rect) || !isLand(world, x, y)) return;
+ const sig = `${x},${y},${key}`;
+ if (seen.has(sig)) return;
+ seen.add(sig);
+ points.push({ x, y, key, weight });
+ };
+ for (const key of keys) {
+ for (const path of sourceMap[key] || []) {
+ for (let i = 0; i < path.length; i += 4) {
+ add(tupleWorldX(world, path[i]), tupleWorldY(world, path[i]), key, 1.1);
+ }
+ }
+ }
+ const featureKeys = mode === "rail"
+ ? ["modernCities", "stations", "ports", "adminCenters", "industrialZones", "newTowns"]
+ : ["modernCities", "ports", "markets", "villages", "adminCenters", "industrialZones", "logisticsParks", "newTowns"];
+ for (const key of featureKeys) {
+ for (const p of sourceMap[key] || []) {
+ add(pointWorldX(world, p), pointWorldY(world, p), key, key === "adminCenters" || key === "modernCities" ? 1.8 : 1.25);
+ }
+ }
+ return points;
+}
+
+function rectDistance(x, y, rect) {
+ if (insideRect(x, y, rect)) return 0;
+ const dx = x < rect.x0 ? rect.x0 - x : x >= rect.x1 ? x - rect.x1 + 1 : 0;
+ const dy = y < rect.y0 ? rect.y0 - y : y >= rect.y1 ? y - rect.y1 + 1 : 0;
+ return Math.hypot(dx, dy);
+}
+
+function collectExternalNetworkAnchors(world, sourceMap, keys, writeRect, reachRect, mode = "road") {
+ const candidates = [];
+ const seen = new Set();
+ const step = mode === "rail" ? 6 : 4;
+ for (const key of keys) {
+ for (const path of sourceMap[key] || []) {
+ for (let i = 0; i < path.length; i += step) {
+ const x = Math.round(tupleWorldX(world, path[i]));
+ const y = Math.round(tupleWorldY(world, path[i]));
+ if (!insideRect(x, y, reachRect) || insideRect(x, y, writeRect) || !isLand(world, x, y)) continue;
+ const d = rectDistance(x, y, writeRect);
+ if (d < 4 || d > (mode === "rail" ? 380 : 420)) continue;
+ const sig = `${x},${y},${mode}`;
+ if (seen.has(sig)) continue;
+ seen.add(sig);
+ candidates.push({ x, y, mode, external: true, d });
+ }
+ }
+ }
+ candidates.sort((a, b) => a.d - b.d);
+ return candidates.slice(0, mode === "rail" ? 18 : 28);
+}
+
+function connectAnchors(world, sourceMap, anchors, mode, rect, preferredTargetRect = null) {
+ const keys = mode === "rail" ? ["railways", "branchRailways"] : ["nationalRoads", "minorRoads", "premodernRoads"];
+ const allTargets = collectInternalNetworkPoints(world, sourceMap, keys, rect, mode);
+ const preferredTargets = preferredTargetRect ? allTargets.filter((p) => insideRect(p.x, p.y, preferredTargetRect)) : [];
+ const targets = preferredTargets.length ? preferredTargets : allTargets;
+ if (!targets.length) return { connectors: 0, disconnected: anchors.length, skippedConnectorAnchors: 0, connectorAttempts: 0 };
+ let connectors = 0;
+ let disconnected = 0;
+ let skippedConnectorAnchors = 0;
+ let connectorAttempts = 0;
+ const layer = mode === "rail" ? "branchRailways" : "minorRoads";
+ sourceMap[layer] ||= [];
+ const seen = new Set();
+ const maxRange = mode === "rail" ? 220 : 260;
+ const maxAnchors = mode === "rail" ? 10 : 18;
+ const maxTargets = mode === "rail" ? 3 : 3;
+ const searchRect = expandRect(rect, 16, world);
+ const orderedAnchors = (anchors || [])
+ .map((p) => ({ ...p, patchDistance: rectDistance(p.x, p.y, preferredTargetRect || rect) }))
+ .sort((a, b) => a.patchDistance - b.patchDistance)
+ .slice(0, maxAnchors);
+ skippedConnectorAnchors = Math.max(0, (anchors?.length || 0) - orderedAnchors.length);
+ for (const raw of orderedAnchors) {
+ const anchorLand = nearestLand(world, raw.x, raw.y, rect, 18);
+ if (!anchorLand) { disconnected++; continue; }
+ const targetList = targets
+ .map((p) => ({ ...p, d: Math.hypot(p.x - anchorLand.x, p.y - anchorLand.y) }))
+ .filter((p) => p.d <= maxRange && p.d >= 6)
+ .sort((a, b) => (a.d / Math.max(0.6, a.weight || 1)) - (b.d / Math.max(0.6, b.weight || 1)))
+ .slice(0, maxTargets);
+ if (!targetList.length) { disconnected++; continue; }
+ let made = false;
+ for (const target of targetList) {
+ const sig = `${anchorLand.x},${anchorLand.y}:${target.x},${target.y}:${mode}`;
+ if (seen.has(sig)) continue;
+ connectorAttempts++;
+ const path = localPathfind(world, anchorLand, target, searchRect, mode, mode === "rail" ? 36000 : 44000);
+ if (!path || path.length < 2) continue;
+ seen.add(sig);
+ sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(path, mode === "rail" ? 3 : 2)));
+ connectors++;
+ made = true;
+ break;
+ }
+ if (!made) disconnected++;
+ }
+ return { connectors, disconnected, skippedConnectorAnchors, connectorAttempts };
+}
+
+function nearestNetworkPoint(world, sourceMap, keys, point, rect, maxDistance = 80) {
+ let best = null;
+ let bestD = maxDistance;
+ for (const key of keys) {
+ for (const path of sourceMap[key] || []) {
+ for (let i = 0; i < path.length; i += 5) {
+ const x = Math.round(tupleWorldX(world, path[i]));
+ const y = Math.round(tupleWorldY(world, path[i]));
+ if (!insideRect(x, y, rect) || !isLand(world, x, y)) continue;
+ const d = Math.hypot(point.x - x, point.y - y);
+ if (d < bestD) { bestD = d; best = { x, y, key }; }
+ }
+ }
+ }
+ return best;
+}
+
+function ensureSettlementRoadCoverage(world, sourceMap, rect) {
+ const keys = ["nationalRoads", "minorRoads", "premodernRoads"];
+ const featureKeys = ["modernCities", "ports", "markets", "adminCenters", "villages"];
+ sourceMap.minorRoads ||= [];
+ let connectors = 0;
+ let skippedServedSettlements = 0;
+ let checked = 0;
+ const seen = new Set();
+ for (const key of featureKeys) {
+ const limit = key === "villages" ? 30 : 18;
+ const items = (sourceMap[key] || [])
+ .map((p) => ({ p, d: rectDistance(pointWorldX(world, p), pointWorldY(world, p), rect) }))
+ .filter((row) => row.d <= (key === "villages" ? 80 : 150))
+ .sort((a, b) => a.d - b.d)
+ .slice(0, limit);
+ for (const { p } of items) {
+ checked++;
+ const start = nearestLand(world, pointWorldX(world, p), pointWorldY(world, p), rect, 10);
+ if (!start || !insideRect(start.x, start.y, rect)) continue;
+ const si = worldIndex(world, start.x, start.y);
+ if ((world.fields.roadInfluence?.[si] || 0) > (key === "villages" ? 0.18 : 0.12)) {
+ skippedServedSettlements++;
+ continue;
+ }
+ const target = nearestNetworkPoint(world, sourceMap, keys, start, rect, key === "villages" ? 72 : 132);
+ if (!target || Math.hypot(target.x - start.x, target.y - start.y) < 5) continue;
+ const sig = `${start.x},${start.y}:${target.x},${target.y}`;
+ if (seen.has(sig)) continue;
+ seen.add(sig);
+ const path = localPathfind(world, start, target, rect, "road", 36000);
+ if (!path || path.length < 2) continue;
+ sourceMap.minorRoads.push(sourcePathFromWorld(world, simplifyPath(path, 2)));
+ connectors++;
+ }
+ }
+ return { connectors, skippedServedSettlements, checkedSettlementCoverage: checked };
+}
+
+function dedupeAdminCentersByWorldId(kept, generated) {
+ const out = [...kept];
+ const seen = new Set();
+ for (const p of kept) {
+ const id = numericFeatureId(p, ["adminId", "municipalityId", "adminNumericId"]);
+ if (id >= 0) seen.add(id);
+ }
+ for (const p of generated) {
+ const id = numericFeatureId(p, ["adminId", "municipalityId", "adminNumericId"]);
+ if (id >= 0 && seen.has(id)) continue;
+ if (id >= 0) seen.add(id);
+ out.push(p);
+ }
+ return out;
+}
+
+function dedupePrefectureRegionsByWorldId(kept, generated) {
+ const out = [...kept];
+ const seen = new Set();
+ for (const p of kept) {
+ const id = numericFeatureId(p, ["prefectureRegionId", "id"]);
+ if (id >= 0) seen.add(id);
+ }
+ for (const p of generated) {
+ const id = numericFeatureId(p, ["prefectureRegionId", "id"]);
+ if (id >= 0 && seen.has(id)) continue;
+ if (id >= 0) seen.add(id);
+ out.push(p);
+ }
+ return out;
+}
+
+function mergePointLayers(world, sourceMap, candidate, rects, window, seed, adminIdMapping = null) {
+ let preservedExternalEntities = 0;
+ let regeneratedInternalEntities = 0;
+ let invalidPortsRemoved = 0;
+ for (const key of POINT_LAYER_KEYS) {
+ const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : [];
+ const kept = [];
+ for (const p of oldArr) {
+ if (!p) continue;
+ const wx = Math.round(pointWorldX(world, p));
+ const wy = Math.round(pointWorldY(world, p));
+ const inWrite = insideRect(wx, wy, rects.writeRect);
+ const alpha = inWrite ? patchAlpha(wx, wy, rects, seed) : 0;
+ if (!inWrite || alpha < 0.34) {
+ kept.push(p);
+ if (!inWrite) preservedExternalEntities++;
+ } else if (key === "ports" && (!isLand(world, wx, wy) || seaNeighbors(world, wx, wy, 2) < 2)) {
+ invalidPortsRemoved++;
+ continue;
+ }
+ }
+ const generated = [];
+ for (const p of candidate[key] || []) {
+ const q = transformCandidatePoint(world, window, p, key, seed, adminIdMapping);
+ if (!q) continue;
+ const wx = Math.round(pointWorldX(world, q));
+ const wy = Math.round(pointWorldY(world, q));
+ if (!insideRect(wx, wy, rects.writeRect)) continue;
+ if (patchAlpha(wx, wy, rects, seed) < 0.42) continue;
+ generated.push(q);
+ }
+ if (key === "adminCenters") sourceMap[key] = dedupeAdminCentersByWorldId(kept, generated);
+ else if (key === "prefectureRegions") sourceMap[key] = dedupePrefectureRegionsByWorldId(kept, generated);
+ else sourceMap[key] = [...kept, ...generated];
+ regeneratedInternalEntities += Math.max(0, sourceMap[key].length - kept.length);
+ }
+ return { preservedExternalEntities, regeneratedInternalEntities, invalidPortsRemoved };
+}
+
+function mergePathLayers(world, sourceMap, candidate, rects, window, seed) {
+ let roadAnchors = [];
+ let railAnchors = [];
+ let roadsClipped = 0;
+ let railsClipped = 0;
+ let regeneratedPaths = 0;
+ for (const key of PATH_LAYER_KEYS) {
+ const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : [];
+ const mode = RAIL_LAYER_KEYS.has(key) ? "rail" : ROAD_LAYER_KEYS.has(key) ? "road" : RIVER_LAYER_KEYS.has(key) ? "river" : "path";
+ const pruned = pruneOldPathLayer(world, oldArr, rects, seed, mode);
+ if (mode === "rail") { railAnchors = railAnchors.concat(pruned.anchors); railsClipped += pruned.clipped; }
+ else if (mode === "road") { roadAnchors = roadAnchors.concat(pruned.anchors); roadsClipped += pruned.clipped; }
+ const next = [...pruned.kept];
+ for (const path of candidate[key] || []) {
+ const worldPath = transformCandidatePath(window, path);
+ const chunks = splitWorldPathByPatch(worldPath, rects, seed, true, 0.40)
+ .map((chunk) => chunk.filter(([x, y]) => mode === "river" || isLand(world, x, y) || patchAlpha(x, y, rects, seed) > 0.90))
+ .filter((chunk) => chunk.length >= 2);
+ for (const chunk of chunks) {
+ if (chunk.some(([x, y]) => patchAlpha(x, y, rects, seed) >= 0.40)) {
+ next.push(sourcePathFromWorld(world, simplifyPath(chunk, mode === "rail" ? 3 : 2)));
+ regeneratedPaths++;
+ }
+ }
+ }
+ sourceMap[key] = next;
+ }
+ const transportRect = rects.transportReachRect || rects.repairRect || rects.writeRect;
+ const externalRoadAnchors = collectExternalNetworkAnchors(world, sourceMap, ["nationalRoads", "minorRoads", "premodernRoads", "externalRoads"], rects.writeRect, transportRect, "road");
+ const externalRailAnchors = collectExternalNetworkAnchors(world, sourceMap, ["railways", "branchRailways", "externalRailways"], rects.writeRect, transportRect, "rail");
+ roadAnchors = roadAnchors.concat(externalRoadAnchors);
+ railAnchors = railAnchors.concat(externalRailAnchors);
+ const roadConn = connectAnchors(world, sourceMap, roadAnchors, "road", transportRect, rects.writeRect);
+ const railConn = connectAnchors(world, sourceMap, railAnchors, "rail", transportRect, rects.writeRect);
+ const settlementRoadConnectors = ensureSettlementRoadCoverage(world, sourceMap, transportRect);
+ return {
+ roadsClipped,
+ railsClipped,
+ regeneratedPaths,
+ roadConnectorsCreated: roadConn.connectors + settlementRoadConnectors.connectors,
+ railwayConnectorsCreated: railConn.connectors,
+ disconnectedRoadComponents: roadConn.disconnected,
+ disconnectedRailComponents: railConn.disconnected,
+ skippedConnectorAnchors: (roadConn.skippedConnectorAnchors || 0) + (railConn.skippedConnectorAnchors || 0),
+ connectorAttempts: (roadConn.connectorAttempts || 0) + (railConn.connectorAttempts || 0),
+ skippedServedSettlements: settlementRoadConnectors.skippedServedSettlements || 0,
+ checkedSettlementCoverage: settlementRoadConnectors.checkedSettlementCoverage || 0,
+ externalRoadAnchors: externalRoadAnchors.length,
+ externalRailAnchors: externalRailAnchors.length,
+ };
+}
+
+function buildBoundarySegmentsFromField(world, fieldName, rect, options = {}) {
+ const field = world.fields[fieldName];
+ const sea = world.fields.sea;
+ const rects = options.rects || null;
+ const seed = options.seed || 0;
+ const minAlpha = Number.isFinite(options.minAlpha) ? options.minAlpha : 0;
+ if (!field) return [];
+ const out = [];
+ for (let y = rect.y0; y < rect.y1; y++) {
+ for (let x = rect.x0; x < rect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0 || sea?.[i]) continue;
+ const id = field[i];
+ if (id < 0) continue;
+ const right = worldIndex(world, x + 1, y);
+ if (x + 1 < rect.x1 && right >= 0 && !sea?.[right] && field[right] >= 0 && field[right] !== id && continuitySegmentAllowed(x, y, x + 1, y, rects, seed, minAlpha)) {
+ out.push([[x + 0.5 - world.originX, y - world.originY], [x + 0.5 - world.originX, y + 1 - world.originY]]);
+ }
+ const down = worldIndex(world, x, y + 1);
+ if (y + 1 < rect.y1 && down >= 0 && !sea?.[down] && field[down] >= 0 && field[down] !== id && continuitySegmentAllowed(x, y, x, y + 1, rects, seed, minAlpha)) {
+ out.push([[x - world.originX, y + 0.5 - world.originY], [x + 1 - world.originX, y + 0.5 - world.originY]]);
+ }
+ }
+ }
+ return out;
+}
+
+function mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rects, window, seed = 0) {
+ const debug = sourceMap.adminDebug || {};
+ debug.compartmentBorders ||= [];
+ let added = 0;
+ for (const seg of candidate?.adminDebug?.compartmentBorders || []) {
+ if (!Array.isArray(seg) || seg.length < 2) continue;
+ const a = worldCoordForSource(window, seg[0]?.[0], seg[0]?.[1]);
+ const b = worldCoordForSource(window, seg[1]?.[0], seg[1]?.[1]);
+ const mx = (a.x + b.x) * 0.5;
+ const my = (a.y + b.y) * 0.5;
+ if (!insideRect(mx, my, rects.writeRect) || patchAlpha(mx, my, rects, seed) < 0.08) continue;
+ debug.compartmentBorders.push(sourcePathFromWorld(world, [[a.x, a.y], [b.x, b.y]]));
+ added++;
+ }
+ sourceMap.adminDebug = debug;
+ return added;
+}
+
+function mergeSegmentLayers(world, sourceMap, rects, seed = 0) {
+ for (const key of SEGMENT_LAYER_KEYS) {
+ const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : [];
+ sourceMap[key] = oldArr.filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, 0.34));
+ }
+ sourceMap.adminBorders ||= [];
+ sourceMap.adminBorders.push(...buildBoundarySegmentsFromField(world, "adminId", rects.writeRect, { rects, seed, minAlpha: 0.58 }));
+ sourceMap.regionalPrefectureBorders ||= [];
+ sourceMap.regionalPrefectureBorders.push(...buildBoundarySegmentsFromField(world, "prefectureRegionId", rects.writeRect, { rects, seed, minAlpha: 0.72 }));
+ sourceMap.prefectureBorder ||= [];
+
+ const debug = sourceMap.adminDebug || {};
+ // Use the legacy full-pipeline compartment debug segments for regenerated
+ // areas. Rebuilding directly from the raster field made patch compartments
+ // look denser/smaller than the initial map. Candidate segments are merged
+ // just after this function.
+ debug.compartmentBorders = (debug.compartmentBorders || []).filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, 0.34));
+ sourceMap.adminDebug = debug;
+ return {
+ adminBordersRebuilt: sourceMap.adminBorders.length,
+ prefectureBordersRebuilt: sourceMap.regionalPrefectureBorders.length,
+ compartmentBordersRebuilt: debug.compartmentBorders.length,
+ };
+}
+
+function repairLanduseAndPopulation(world, rects) {
+ const landuse = world.fields.landuse;
+ if (!landuse) return { landUseCellsUpdated: 0 };
+ let updated = 0;
+ for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
+ for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0) continue;
+ if (world.fields.sea?.[i]) {
+ if (landuse[i] !== (LANDUSE.WATER || 0)) updated++;
+ landuse[i] = LANDUSE.WATER || 0;
+ continue;
+ }
+ if (landuse[i] === (LANDUSE.WATER || 0)) {
+ const slope = world.fields.slope?.[i] || 0;
+ const ag = world.fields.agriculture?.[i] || 0;
+ landuse[i] = slope > 0.42 ? LANDUSE.FOREST : ag > 0.28 ? LANDUSE.FARMLAND : LANDUSE.RURAL;
+ updated++;
+ }
+ }
+ }
+ return { landUseCellsUpdated: updated };
+}
+
+function ensureWorldFloatField(world, name) {
+ const expected = world.width * world.height;
+ if (!world.fields[name] || world.fields[name].length !== expected) world.fields[name] = new Float32Array(expected);
+ return world.fields[name];
+}
+
+function clearFieldRect(world, field, rect) {
+ for (let y = rect.y0; y < rect.y1; y++) {
+ for (let x = rect.x0; x < rect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i >= 0) field[i] = 0;
+ }
+ }
+}
+
+function paintInfluenceDisk(world, field, cx, cy, radius, strength, rect) {
+ const sea = world.fields.sea;
+ const r = Math.max(1, 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 x = Math.round(cx + dx);
+ const y = Math.round(cy + dy);
+ if (!insideRect(x, y, rect)) continue;
+ const i = worldIndex(world, x, y);
+ if (i < 0 || sea?.[i]) continue;
+ const d = Math.hypot(dx, dy);
+ const value = strength * Math.pow(1 - d / Math.max(1, radius), 1.35);
+ if (value > field[i]) field[i] = clamp(value);
+ }
+ }
+}
+
+function refreshPatchInfluenceFields(world, sourceMap, rects) {
+ const rect = rects.repairRect || rects.writeRect;
+ const roadInfluence = ensureWorldFloatField(world, "roadInfluence");
+ const railInfluence2 = ensureWorldFloatField(world, "railInfluence2");
+ const stationInfluence = ensureWorldFloatField(world, "stationInfluence");
+ const villageInfluence = ensureWorldFloatField(world, "villageInfluence");
+ for (const field of [roadInfluence, railInfluence2, stationInfluence, villageInfluence]) clearFieldRect(world, field, rect);
+
+ let roadCellsPainted = 0;
+ let railCellsPainted = 0;
+ let stationCellsPainted = 0;
+ let villageCellsPainted = 0;
+ const paintPathLayer = (keys, field, radius, strength, counterName) => {
+ let painted = 0;
+ for (const key of keys) {
+ for (const path of sourceMap[key] || []) {
+ for (const tuple of path || []) {
+ const x = tupleWorldX(world, tuple);
+ const y = tupleWorldY(world, tuple);
+ if (rectDistance(x, y, rect) > radius + 1) continue;
+ paintInfluenceDisk(world, field, x, y, radius, strength, rect);
+ painted++;
+ }
+ }
+ }
+ if (counterName === "road") roadCellsPainted += painted;
+ if (counterName === "rail") railCellsPainted += painted;
+ };
+
+ paintPathLayer(["nationalRoads", "ringRoads", "externalRoads", "minorRoads", "premodernRoads", "icAccessRoads"], roadInfluence, 5, 1, "road");
+ paintPathLayer(["railways", "branchRailways", "ringRailways", "externalRailways"], railInfluence2, 4, 1, "rail");
+
+ for (const p of sourceMap.stations || []) {
+ const x = pointWorldX(world, p);
+ const y = pointWorldY(world, p);
+ if (rectDistance(x, y, rect) > 8) continue;
+ paintInfluenceDisk(world, stationInfluence, x, y, 5, clamp(p.score || 1), rect);
+ stationCellsPainted++;
+ }
+ for (const p of sourceMap.villages || []) {
+ const x = pointWorldX(world, p);
+ const y = pointWorldY(world, p);
+ if (rectDistance(x, y, rect) > 10) continue;
+ paintInfluenceDisk(world, villageInfluence, x, y, 6, clamp((p.population || 1800) / 4200, 0.35, 1.2), rect);
+ villageCellsPainted++;
+ }
+
+ return { roadCellsPainted, railCellsPainted, stationCellsPainted, villageCellsPainted };
+}
+
+function countSea(world, rect) {
+ let seaCount = 0;
+ let total = 0;
+ for (let y = rect.y0; y < rect.y1; y++) {
+ for (let x = rect.x0; x < rect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0) continue;
+ total++;
+ if (world.fields.sea?.[i]) seaCount++;
+ }
+ }
+ return { seaCount, total, seaRatio: total ? seaCount / total : 0 };
+}
+
+function terrainLabel(candidate, fallback) {
+ return candidate?.terrainTemplate?.terrainTypeLabel || candidate?.terrainDebug?.terrainTypeLabel || fallback;
+}
+
+function terrainId(candidate, fallback) {
+ return candidate?.terrainTemplate?.terrainType || candidate?.terrainDebug?.terrainType || fallback;
+}
+
+function rectKey(rect) {
+ return rect ? `${rect.x0},${rect.y0},${rect.x1},${rect.y1}` : "-";
+}
+
+function patchCandidateCacheKey({ seed, terrainType, variant, candidateOriginX, candidateOriginY, contextRect, serial = 0 }) {
+ return [serial, seed >>> 0, terrainType || "auto", variant >>> 0, candidateOriginX | 0, candidateOriginY | 0, rectKey(contextRect)].join("|");
+}
+
+function getPatchCandidateCache(world) {
+ if (!world.patchCandidateCache) world.patchCandidateCache = new Map();
+ return world.patchCandidateCache;
+}
+
+function rememberPatchCandidate(world, key, candidate) {
+ const cache = getPatchCandidateCache(world);
+ if (cache.has(key)) cache.delete(key);
+ cache.set(key, candidate);
+ while (cache.size > PATCH_CANDIDATE_CACHE_LIMIT) cache.delete(cache.keys().next().value);
+}
+
+function getOrGeneratePatchCandidate(world, key, create) {
+ const cache = getPatchCandidateCache(world);
+ if (cache.has(key)) {
+ const candidate = cache.get(key);
+ cache.delete(key);
+ cache.set(key, candidate);
+ return { candidate, cacheHit: true, cacheSize: cache.size };
+ }
+ const candidate = create();
+ rememberPatchCandidate(world, key, candidate);
+ return { candidate, cacheHit: false, cacheSize: getPatchCandidateCache(world).size };
+}
+
+export function generatePatch(world, userRectInput, options = {}) {
+ const validation = validatePatchRect(userRectInput, world);
+ if (!validation.ok) return { ok: false, ...validation };
+
+ const rects = buildPatchRects(validation.rect, world);
+ const terrainType = options.terrainType || "auto";
+ const seed = Number.isFinite(options.seed) ? options.seed >>> 0 : ((world?.seed || 0) + 1013904223) >>> 0;
+ const variant = Number.isFinite(options.variant) ? Math.max(0, Math.floor(options.variant)) >>> 0 : 0;
+ const candidateWindow = sourceWindowForRects(rects);
+ const candidateOriginX = Math.round(candidateWindow.worldCenterX - candidateWindow.sourceCenterX);
+ const candidateOriginY = Math.round(candidateWindow.worldCenterY - candidateWindow.sourceCenterY);
+ const patchTimer = createPatchTimer();
+ const patchGenerationMode = "legacy-full-pipeline";
+ const cacheKey = patchCandidateCacheKey({
+ seed,
+ terrainType,
+ variant,
+ candidateOriginX,
+ candidateOriginY,
+ contextRect: rects.contextRect,
+ serial: world.patchGenerationSerial || 0,
+ });
+ const { candidate, cacheHit, cacheSize } = getOrGeneratePatchCandidate(world, cacheKey, () => generateMap(seed, {
+ terrainType,
+ legacyTerrain: true,
+ worldNative: true,
+ variant,
+ originX: candidateOriginX,
+ originY: candidateOriginY,
+ width: MAP_W,
+ height: MAP_H,
+ contextRect: rects.contextRect,
+ boundaryWorld: world,
+ onProgress: () => {},
+ }));
+ patchTimer.mark("candidate", cacheHit ? "Full candidate generation (cached)" : "Full candidate generation");
+ getPatchAlphaCache(rects, seed);
+ getPatchSourceIndexCache(rects, candidateWindow);
+ const sourceMap = world.sourceMap || (world.sourceMap = {});
+ const logisticsLabelsMigrated = sanitizeExistingLogistics(sourceMap);
+
+ const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed);
+ patchTimer.mark("fields", "Field copy and alpha blend");
+ const terrainSeamDebug = featherTerrainSeam(world, rects, seed);
+ const waterDebug = smoothWaterTopology(world, rects.writeRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30, rects, seed);
+ const maskDebug = repairDisplayMasks(world, rects, seed);
+ recomputeSlopeAndWaterDependentFields(world, rects.repairRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30);
+ patchTimer.mark("terrainRepair", "Water, masks, and terrain repair");
+ const pointDebug = mergePointLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed, fieldDebug.adminIdMapping);
+ patchTimer.mark("points", "Point merge");
+ const pathDebug = mergePathLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed);
+ patchTimer.mark("paths", "Path merge and connector repair");
+ const influenceDebug = refreshPatchInfluenceFields(world, sourceMap, rects);
+ patchTimer.mark("influence", "Influence refresh");
+ const landDebug = repairLanduseAndPopulation(world, rects);
+ const finalAdminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, fieldDebug.adminIdMapping, seed);
+ const sourceAdminMetadataUpdated = updateSourceAdminMetadata(sourceMap, fieldDebug.adminIdMapping);
+ const municipalCoherence = reconcileMunicipalMetadata({
+ adminId: world.fields.adminId,
+ municipalityId: world.fields.municipalityId,
+ prefectureRegionId: world.fields.prefectureRegionId,
+ sea: world.fields.sea,
+ adminCenters: sourceMap.adminCenters || [],
+ municipalityToPrefectureId: sourceMap.municipalityToPrefectureId,
+ fields: world.fields,
+ width: world.width,
+ height: world.height,
+ pointOffsetX: world.originX || 0,
+ pointOffsetY: world.originY || 0,
+ seed,
+ });
+ sourceMap.adminCenters = municipalCoherence.adminCenters;
+ sourceMap.municipalityToPrefectureId = municipalCoherence.municipalityToPrefectureId;
+ const prefectureCoherence = refreshPrefectureRegionsMetadata({
+ prefectureRegionId: world.fields.prefectureRegionId,
+ sea: world.fields.sea,
+ existing: sourceMap.prefectureRegions || [],
+ fields: world.fields,
+ width: world.width,
+ height: world.height,
+ pointOffsetX: world.originX || 0,
+ pointOffsetY: world.originY || 0,
+ });
+ sourceMap.prefectureRegions = prefectureCoherence.prefectureRegions;
+ sourceMap.adminDebug = {
+ ...(sourceMap.adminDebug || {}),
+ municipalCoherence: municipalCoherence.debug,
+ prefectureMetadataCoherence: prefectureCoherence.debug,
+ };
+ const segmentDebug = mergeSegmentLayers(world, sourceMap, rects, seed);
+ const candidateCompartmentSegmentsAdded = mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rects, fieldDebug.window, seed);
+ patchTimer.mark("segments", "Boundary and debug segment merge");
+ sanitizeExistingLogistics(sourceMap);
+ patchTimer.mark("cleanup", "Land-use, admin, and label cleanup");
+ const patchTimings = patchTimer.timings;
+
+ const seaStats = countSea(world, rects.coreRect);
+ const label = terrainLabel(candidate, terrainType);
+ const id = terrainId(candidate, terrainType);
+ const humanGeography = {
+ ok: true,
+ modernCities: (sourceMap.modernCities || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length,
+ ports: (sourceMap.ports || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length,
+ villages: (sourceMap.villages || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length,
+ ...pointDebug,
+ ...pathDebug,
+ ...influenceDebug,
+ adminCellsReassigned: fieldDebug.adminCellsReassigned,
+ adminIdMapping: fieldDebug.adminIdMappingDebug,
+ sourceAdminMetadataUpdated,
+ ...finalAdminCoverageDebug,
+ finalSeaAdminCellsCleared: finalAdminCoverageDebug.seaAdminCellsCleared || 0,
+ finalLandAdminCellsFilled: finalAdminCoverageDebug.landAdminCellsFilled || 0,
+ finalPrefectureCellsFilled: finalAdminCoverageDebug.prefectureCellsFilled || 0,
+ finalAdminPrefectureCellsAligned: finalAdminCoverageDebug.adminPrefectureCellsAligned || 0,
+ municipalCoherence: municipalCoherence.debug,
+ prefectureMetadataCoherence: prefectureCoherence.debug,
+ continuityCellsRestored: fieldDebug.continuityCellsRestored || 0,
+ continuityCellsRemapped: fieldDebug.continuityCellsRemapped || 0,
+ adminSeamCellsResolved: fieldDebug.adminSeamCellsResolved || 0,
+ prefectureSeamCellsResolved: fieldDebug.prefectureSeamCellsResolved || 0,
+ ...terrainSeamDebug,
+ landUseCellsUpdated: fieldDebug.landUseCellsUpdated + landDebug.landUseCellsUpdated,
+ displayMaskUpdated: maskDebug.displayMaskUpdated || 0,
+ logisticsLabelsMigrated,
+ candidateCacheHit: cacheHit,
+ candidateCacheSize: cacheSize,
+ ...segmentDebug,
+ candidateCompartmentSegmentsAdded,
+ };
+
+ const record = {
+ ...rects.coreRect,
+ coreRect: { ...rects.coreRect },
+ selectionShape: rects.selectionShape ? {
+ kind: rects.selectionShape.kind || 'lasso',
+ areaCells: rects.selectionShape.areaCells || 0,
+ polygon: rects.selectionShape.polygon.map((p) => ({ x: p.x, y: p.y })),
+ } : null,
+ writeRect: { ...rects.writeRect },
+ repairRect: { ...rects.repairRect },
+ contextRect: { ...rects.contextRect },
+ blendRect: { ...rects.blendRect },
+ terrainType: id,
+ label,
+ seed,
+ variant,
+ candidateOriginX,
+ candidateOriginY,
+ patchGenerationMode,
+ patchTimings,
+ updatedCells: fieldDebug.updatedCells,
+ terrainCellsFullyReplaced: fieldDebug.terrainCellsFullyReplaced,
+ coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged,
+ naturalRegionsUpdated: fieldDebug.naturalRegionsUpdated,
+ naturalRegionFragmentsMerged: 0,
+ adminIdMapping: fieldDebug.adminIdMappingDebug,
+ seaRatio: seaStats.seaRatio,
+ humanGeography,
+ createdAt: Date.now(),
+ };
+ world.generatedRects = [...(world.generatedRects || []), record];
+ world.invalidatedRects = [...(world.invalidatedRects || []), { ...rects.writeRect }];
+ world.lastPatchResult = record;
+ world.patchGenerationSerial = (world.patchGenerationSerial || 0) + 1;
+
+ return {
+ ok: true,
+ validation,
+ rects: {
+ ...rects,
+ selectionShape: rects.selectionShape ? {
+ kind: rects.selectionShape.kind || 'lasso',
+ areaCells: rects.selectionShape.areaCells || 0,
+ polygon: rects.selectionShape.polygon.map((p) => ({ x: p.x, y: p.y })),
+ } : null,
+ },
+ terrainType: id,
+ label,
+ seed,
+ variant,
+ candidateOriginX,
+ candidateOriginY,
+ patchGenerationMode,
+ patchTimings,
+ updatedCells: record.updatedCells,
+ terrainCellsFullyReplaced: record.terrainCellsFullyReplaced,
+ coastCellsChanged: record.coastCellsChanged,
+ naturalRegionsUpdated: record.naturalRegionsUpdated,
+ naturalRegionFragmentsMerged: 0,
+ adminIdMapping: fieldDebug.adminIdMappingDebug,
+ seaRatio: seaStats.seaRatio,
+ humanGeography,
+ };
+}
diff --git a/mapPipeline.js b/mapPipeline.js
index 45aa2b4..0aed38d 100644
--- a/mapPipeline.js
+++ b/mapPipeline.js
@@ -8,6 +8,93 @@ import { finalizeAdminAwareTransport } from "./mapPostAdminTransport.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() {
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 = {}) {
- 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: () => {} };
const generationTimings = [];
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 {
elevation,
slope,
@@ -71,10 +160,10 @@ export function generateMap(seedInput = 114514, options = {}) {
naturalCompartments,
} = terrain;
- const geographyBasis = stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain));
- const terrainWithGeography = { ...terrain, geography: geographyBasis };
+ const geographyBasis = stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain, options));
+ 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 {
settlementScore,
villages,
@@ -94,10 +183,10 @@ export function generateMap(seedInput = 114514, options = {}) {
villageInfluence,
} = 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({
- 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,
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
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({
seed,
@@ -124,17 +213,22 @@ export function generateMap(seedInput = 114514, options = {}) {
}));
output.generationTimings = generationTimings;
output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0);
+ output.baseSeed = options.baseSeed;
+ output.effectiveSeed = seed;
+ output.generationContext = { ...options.generationContext };
return output;
}
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: () => {} };
const generationTimings = [];
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 {
elevation,
slope,
@@ -154,10 +248,10 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
naturalCompartments,
} = terrain;
- const geographyBasis = await stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain));
- const terrainWithGeography = { ...terrain, geography: geographyBasis };
+ const geographyBasis = await stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain, options));
+ 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 {
settlementScore,
villages,
@@ -177,10 +271,10 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
villageInfluence,
} = 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({
- 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,
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
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({
seed,
@@ -207,5 +301,8 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
}));
output.generationTimings = generationTimings;
output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0);
+ output.baseSeed = options.baseSeed;
+ output.effectiveSeed = seed;
+ output.generationContext = { ...options.generationContext };
return output;
}
diff --git a/mapPostAdminTransport.js b/mapPostAdminTransport.js
index ca18d4d..070947a 100644
--- a/mapPostAdminTransport.js
+++ b/mapPostAdminTransport.js
@@ -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";
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 naturalBarrierScore = terrain?.naturalBarrierScore;
let seaRun = 0, maxSeaRun = 0, tunnelRun = 0, maxTunnelRun = 0;
- for (const [x, y] of path || []) {
- if (!inside(x, y)) continue;
+ let sampled = 0;
+ const visit = (x, y) => {
+ if (!inside(x, y)) {
+ seaRun++;
+ maxSeaRun = Math.max(maxSeaRun, seaRun);
+ tunnelRun = 0;
+ sampled++;
+ return;
+ }
const i = indexOf(x, y);
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 (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 = {}) {
@@ -50,6 +73,70 @@ function directPath(a, b, options = {}) {
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) {
let best = null;
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.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.
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) {
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) {
const trunk = nearestPointOnPaths([...nationalRoads, ...externalRoads], p, maxDistance);
if (trunk) return trunk;
@@ -225,30 +350,41 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge
let townsCovered = 0;
while (uncovered.length) {
const start = uncovered.shift();
- const chain = buildTownChain(start, uncovered, 7);
+ let chain = buildTownChain(start, uncovered, 7);
uncovered = uncovered.filter((town) => !chain.includes(town));
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) {
- 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);
}
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 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);
}
- const after = chain.length >= 2 ? nearestTrunkOrHub(chain[chain.length - 1], 80) : null;
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);
}
let path = concatPaths(parts);
if (path.length < 2) {
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);
chainsAdded++;
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.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.
for (let i = 0; i < expressways.length; i++) {
const smoothed = smoothPath(expressways[i], 2);
if (smoothed.length >= 2) {
- expressways[i] = smoothed;
- debug.expresswaysSmoothed++;
+ const runs = pathTerrainRuns(smoothed, terrain);
+ if (runs.maxTunnelRun <= 10 && runs.maxSeaRun <= 20) {
+ expressways[i] = smoothed;
+ debug.expresswaysSmoothed++;
+ }
}
}
- for (const path of [...expressways, ...externalExpressways]) {
- if (!path || path.length < 2) continue;
- const a = path[0];
- const b = path[path.length - 1];
- if (addInterchange(interchanges, a[0], a[1])) debug.expresswayEndpointInterchangesAdded++;
- if (addInterchange(interchanges, b[0], b[1])) debug.expresswayEndpointInterchangesAdded++;
+ const expresswayBeforeTerrainPrune = expressways.length;
+ for (let i = expressways.length - 1; i >= 0; i--) {
+ const runs = pathTerrainRuns(expressways[i], terrain);
+ if (runs.maxTunnelRun > 10 || runs.maxSeaRun > 20) expressways.splice(i, 1);
}
+ 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.nationalRoads = dedupePaths(nationalRoads, 1);
features.externalRoads = dedupePaths(externalRoads, 1);
features.expressways = dedupePaths(expressways, 2);
features.externalExpressways = dedupePaths(externalExpressways, 2);
+ features.railways = dedupePaths(features.railways || [], 2);
features.interchanges = interchanges;
// Final invariant: after all dedupe passes, every municipal office cell has at least a local road cell on it.
diff --git a/mapPrefectureStage.js b/mapPrefectureStage.js
index ce1c31e..986e887 100644
--- a/mapPrefectureStage.js
+++ b/mapPrefectureStage.js
@@ -748,6 +748,98 @@ export function splitOversizedPrefecturesByMunicipalityCount(nodes, owner, maxCo
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) {
if (!field) return 0;
let sum = 0;
@@ -803,6 +895,14 @@ export function generatePrefecturesFromMunicipalities(context, adminResult) {
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 10);
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 municipalityToPrefectureId = new Int16Array(maxAdminId + 1);
municipalityToPrefectureId.fill(-1);
@@ -846,6 +946,8 @@ export function generatePrefecturesFromMunicipalities(context, adminResult) {
prefectureMunicipalityCountRebalanceChangedMunicipalities: (changedForMunicipalityCountRebalance || 0) + (changedForPostMergeMunicipalityCountRebalance || 0),
prefectureTinyMunicipalityCountMergeChangedMunicipalities: changedForTinyPrefectureCountMerge || 0,
prefectureOversizedCountSplitChangedMunicipalities: changedForOversizedPrefectureSplit || 0,
+ prefectureCapitalNeighborLockChangedMunicipalities: changedForCapitalNeighborLock || 0,
+ prefectureBoundaryShareRelaxationChangedMunicipalities: changedForBoundaryShareRelaxation || 0,
finalRegionalMaxMunicipalityCount: Math.max(...prefectureMunicipalityCounts(owner).values()),
finalRegionalMunicipalityCountCap: 88,
finalRegionalMinMunicipalityCount: Math.min(...prefectureMunicipalityCounts(owner).values()),
diff --git a/mapTerrain.js b/mapTerrain.js
index e0e4db5..497084e 100644
--- a/mapTerrain.js
+++ b/mapTerrain.js
@@ -5,6 +5,7 @@ import {
neighbors8,
} from "./mapGeneratorHelpers.js";
import { buildNaturalCompartments } from "./adminRegions.js";
+import { createRectContext, createRectTerrainFields, rectIndexOf, rectInside, rectNeighbors8, rectQuantile } from "./rectContext.js";
const ASPECT = MAP_W / MAP_H;
const SQRT2 = Math.SQRT2;
@@ -168,13 +169,13 @@ const TERRAIN_TYPES = [
mountainOffsetRange: [0.47, 0.53],
baseHeightRange: [0.56, 0.82],
primaryLengthRange: [0.76, 0.96],
- primaryWidthRange: [0.13, 0.22],
- systemCountRange: [12, 16],
- beltCountRange: [3, 4],
- angleSpread: 0.14,
- crossSpread: 0.38,
+ primaryWidthRange: [0.18, 0.30],
+ systemCountRange: [14, 18],
+ beltCountRange: [4, 5],
+ angleSpread: 0.18,
+ crossSpread: 0.58,
lengthScale: 1.22,
- widthScale: 0.92,
+ widthScale: 1.16,
heightScale: 0.86,
coastStrength: 0.90,
plainBiasRange: [0.16, 0.34],
@@ -206,6 +207,31 @@ const TERRAIN_TYPES = [
riverRichnessRange: [0.74, 1.14],
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",
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
// templates still contain their own parameter ranges, but there is no
// terrain-type appearance weighting.
@@ -299,8 +330,8 @@ function rangeInt(seed, salt, [lo, hi]) {
return Math.round(lo + rand(seed, salt) * (hi - lo));
}
-export function buildTerrainTemplate(seed) {
- const terrainType = pickTerrainType(seed);
+export function buildTerrainTemplate(seed, options = {}) {
+ const terrainType = pickTerrainType(seed, options.terrainType || options.generationType || "auto");
const mountainMode = terrainType.mountainMode === "mixed"
? (rand(seed, 12) < 0.42 ? "range" : rand(seed, 13) < 0.72 ? "mixed" : "massif")
: 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;
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();
fields.visibleRavineField = new Float32Array(SIZE);
fields.surfaceTextureField = new Float32Array(SIZE);
@@ -1145,16 +1833,18 @@ export function generateTerrainAndRivers(seed) {
passSuitability,
} = fields;
- const terrainTemplate = buildTerrainTemplate(seed);
+ const terrainTemplate = buildTerrainTemplate(seed, options);
const systems = buildMountainSystems(terrainTemplate, seed);
const allRidges = systems.flatMap((system, id) => buildScratchRidges(system, seed, id));
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
+ const wx = originX + x;
+ const wy = originY + y;
const { px, py } = normalizeCoord(x, y);
- const terrainLarge = (fbm(x * 0.65, y * 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 terrainLarge = (fbm(wx * 0.65, wy * 0.65, seed + 1) - 0.5) * 0.23;
+ const terrainRegional = (valueNoise(wx * 0.8, wy * 0.8, seed + 2, 42) - 0.5) * 0.16;
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 mountainMaskMax = 0;
@@ -1179,7 +1869,7 @@ export function generateTerrainAndRivers(seed) {
const dx = (px - 0.5) * ASPECT;
const dy = py - 0.5;
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;
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);
@@ -1187,10 +1877,10 @@ export function generateTerrainAndRivers(seed) {
e += lateralBranch * mountainMaskMax * 0.022;
e -= passBreak * mountainMaskMax * 0.052;
} else {
- macro = (fbm(x * terrainTemplate.macroNoiseScale * 48, y * terrainTemplate.macroNoiseScale * 48, seed + 500) - 0.5) * 2;
- scratch = (fbm(x * 2.2, y * 2.2, seed + 502) - 0.5) * 2;
+ macro = (fbm(wx * terrainTemplate.macroNoiseScale * 48, wy * terrainTemplate.macroNoiseScale * 48, seed + 500) - 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 += global * 0.020;
e += scratch * terrainTemplate.scratchNoiseStrength * mountainMaskMax;
@@ -1203,7 +1893,7 @@ export function generateTerrainAndRivers(seed) {
if (terrainTemplate.terrainType === "setouchi_inland_sea") {
// Setouchi maps should have many low hills and island backbones rather
// 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);
e += lowHillMask * 0.145;
// 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);
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;
- const softCapMax = terrainTemplate.terrainType === "tohoku_spine" ? 0.96 : terrainTemplate.terrainType === "setouchi_inland_sea" ? 0.92 : 1.08;
+ if (terrainTemplate.terrainType === "oceanic_archipelago") {
+ // 海洋型は大きな山塊ではなく、島列を読むための低い起伏を点在させる。
+ 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);
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);
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),
plainRatio: plainCount / Math.max(1, landCount),
mountainSystemCount: systems.length,
+ originX,
+ originY,
+ width: MAP_W,
+ height: MAP_H,
+ variant,
+ worldNative,
};
return {
+ originX,
+ originY,
+ width: MAP_W,
+ height: MAP_H,
+ generationContext: { ...generationContext, originX, originY, width: MAP_W, height: MAP_H, variant, worldNative },
terrainTemplate,
seaLevel,
elevation,
diff --git a/mapTransport.js b/mapTransport.js
index 068224d..887bd6f 100644
--- a/mapTransport.js
+++ b/mapTransport.js
@@ -4,12 +4,21 @@ import {
assessExpresswayRoute,
assessMountainRoute,
countReason,
+ createIncrementalPathInfluence,
fieldBackbonePolicy,
+ markPathInfluence as markPathInfluenceBase,
makeSpatialIndex,
makeUnionFind,
+ meanFieldAround as meanFieldAroundBase,
+ nearestNetworkPoint,
packDebugField,
+ pathCumulativeLengths,
pathAverageField,
+ pathEndpoints,
pathLengthCells,
+ pointAtPathDistance,
+ sampledNetworkCells as sampledNetworkCellsBase,
+ splitPathToValidCells as splitPathToValidCellsBase,
squaredDistance,
TRANSPORT_ROUTE_POLICIES,
} from "./mapTransportUtils.js";
@@ -147,6 +156,83 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
}
const routePolicies = TRANSPORT_ROUTE_POLICIES;
+ const ROAD_MODE = {
+ expressway: {
+ debugBucket: "expresswayCorridors",
+ costField: () => expresswayCorridorCost,
+ potentialField: () => transportFields.expresswayPotential,
+ routeOptions: {
+ curvePenalty: 0.125,
+ penaltyStrength: 7.20,
+ terrainFlowBias: 0.08,
+ surfaceGrain: 0.006,
+ relaxRadius: 1,
+ relaxLineWeight: 0.19,
+ snapRadius: 3.5,
+ heuristicWeight: 0.72,
+ },
+ backbone: { minDistance: 38, maxDistance: 190, maxDegree: 2, maxExtra: 0, parallelRadius: 84, penaltyStrengthMark: 16.50 },
+ fieldPolicyMode: "expressway",
+ mountainMode: "expresswayMountainOnly",
+ connector: { curvePenalty: 0.095, terrainFlowBias: 0.12 },
+ },
+ national: {
+ debugBucket: "nationalCorridors",
+ costField: () => transportFields.national,
+ potentialField: () => transportFields.nationalPotential,
+ routeOptions: {
+ curvePenalty: 0.065,
+ penaltyStrength: 1.05,
+ terrainFlowBias: 0.24,
+ surfaceGrain: 0.030,
+ relaxRadius: 2,
+ relaxLineWeight: 0.28,
+ snapRadius: 2.5,
+ heuristicWeight: 0.50,
+ },
+ backbone: { minDistance: 12, maxDistance: 130, maxDegree: 4, maxExtra: 8, parallelRadius: 6, penaltyStrengthMark: 0.32 },
+ fieldPolicyMode: "national",
+ mountainMode: "national",
+ connector: { curvePenalty: 0.045, terrainFlowBias: 0.22 },
+ },
+ local: {
+ debugBucket: "localCorridors",
+ costField: () => transportFields.local,
+ potentialField: () => transportFields.localPotential,
+ routeOptions: {
+ curvePenalty: 0.035,
+ penaltyStrength: 0.90,
+ terrainFlowBias: 0.26,
+ surfaceGrain: 0.042,
+ relaxRadius: 2,
+ relaxLineWeight: 0.30,
+ snapRadius: 0.5,
+ heuristicWeight: 0.30,
+ },
+ connector: { curvePenalty: 0.035, terrainFlowBias: 0.28 },
+ },
+ };
+
+ function roadMode(mode) {
+ return ROAD_MODE[mode] || ROAD_MODE.national;
+ }
+
+ function recordSkipReason(stats, bucket, reason) {
+ stats[bucket]++;
+ countReason(stats, `${bucket}Reasons`, reason);
+ }
+
+ function recordCorridor(debug, mode, from, to, path, extra = {}) {
+ const bucket = roadMode(mode).debugBucket;
+ if (!debug[bucket]) debug[bucket] = [];
+ debug[bucket].push({
+ from,
+ to,
+ length: Math.round(pathLengthCells(path)),
+ ...extra,
+ path,
+ });
+ }
function mountainRouteAssessment(path, mode = "road") {
return assessMountainRoute(path, mode, pathTerrainRisk, routePolicies);
@@ -255,463 +341,6 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
return dedupePointCandidates(points.filter((p) => p && inside(p.x, p.y) && !sea[indexOf(p.x, p.y)]), minDistance);
}
- function buildExpresswayODCorridors(debug) {
- const majorSuburbs = dedupeAnchors(
- modernCities
- .filter((c) => (c.population || 0) >= 100000)
- .map(majorCitySuburbanAnchor),
- 10
- );
- debug.majorCitySuburbanAnchors = majorSuburbs.map((p) => ({ x: p.x, y: p.y, city: p.city?.name, population: p.population }));
-
- const majorCityRefs = majorSuburbs.filter(Boolean);
- const externalRefs = externalGateways.map((g) => ({ ...g, score: 0.92, role: "external-gateway", population: 90000 }));
- const portRefs = commercialPorts
- .filter((p) => p.portClass === "major" || (p.population || 0) >= 18000)
- .map((p) => ({ ...p, score: 0.78 + (p.portClass === "major" ? 0.30 : 0), role: "port-logistics", population: p.population || 45000 }));
- const remoteRefs = modernCities
- .filter((c) => (c.population || 0) >= 65000 && !majorSuburbs.some((m) => m.city === c))
- .map((c) => {
- const nearestMajor = majorSuburbs.reduce((best, m) => {
- const d = Math.hypot(m.x - c.x, m.y - c.y);
- return !best || d < best.d ? { m, d } : best;
- }, null);
- const anchor = majorCitySuburbanAnchor(c) || { x: c.x, y: c.y, score: 0.3, city: c, population: c.population };
- return { ...anchor, role: "remote-city", score: (anchor.score || 0.3) + Math.min(1.0, (nearestMajor?.d || 0) / 95) * 0.55, remoteDistance: nearestMajor?.d || 0, population: c.population || 0 };
- })
- .filter((p) => p.remoteDistance >= 52)
- .sort((a, b) => b.score - a.score)
- .slice(0, 8);
-
- const nodes = dedupeAnchors([...majorCityRefs, ...portRefs, ...externalRefs, ...remoteRefs], 9);
- const pairs = [];
- for (let a = 0; a < nodes.length; a++) {
- for (let b = a + 1; b < nodes.length; b++) {
- const A = nodes[a];
- const B = nodes[b];
- const d = Math.hypot(A.x - B.x, A.y - B.y);
- if (d < 48 || d > 176) continue;
- const lineCost = approximateLineCost(A, B, expresswayCorridorCost);
- if (!Number.isFinite(lineCost) || lineCost >= INF) continue;
- const demand = Math.sqrt(Math.max(25000, A.population || 50000) * Math.max(25000, B.population || 50000)) / 100000;
- const longDistanceNeed = clamp((d - 48) / 70);
- const externalNeed = A.role === "external-gateway" || B.role === "external-gateway" ? 0.55 : 0;
- const logisticsNeed = A.role === "port-logistics" || B.role === "port-logistics" ? 0.38 : 0;
- const remoteNeed = A.role === "remote-city" || B.role === "remote-city" ? 0.42 : 0;
- const score = (demand * 0.70 + longDistanceNeed * 0.90 + externalNeed + logisticsNeed + remoteNeed) / Math.max(0.9, lineCost) + hash2(A.x + B.x, A.y + B.y, seed + 18131) * 0.025;
- pairs.push({ a: A, b: B, d, score });
- }
- }
- pairs.sort((x, y) => y.score - x.score);
-
- const penalty = new Float32Array(SIZE);
- const degree = new Map();
- const maxCorridors = Math.min(6, Math.max(3, Math.ceil(majorSuburbs.length / 2.4)));
- for (const pair of pairs) {
- if (expressways.length >= maxCorridors) break;
- const aid = `${pair.a.x},${pair.a.y}`;
- const bid = `${pair.b.x},${pair.b.y}`;
- if ((degree.get(aid) || 0) >= 2 || (degree.get(bid) || 0) >= 2) continue;
- const path = routeBetweenTrafficCandidates(pair.a, pair.b, "expressway", expresswayCorridorCost, penalty, {
- curvePenalty: 0.11,
- penaltyStrength: 2.2,
- terrainFlowBias: 0.10,
- surfaceGrain: 0.006,
- relaxRadius: 1,
- relaxLineWeight: 0.24,
- maxPathLength: pair.d * 2.25 + 42,
- });
- const len = pathLengthCells(path);
- if (len < 34 || len > pair.d * 2.25 + 48) continue;
- if (routeTooStraightMountainOnly(path)) continue;
- if (!expresswayRouteAcceptable(path)) continue;
- expressways.push(path);
- addCorridorInfluencePenalty(penalty, path, 24, 1.80);
- degree.set(aid, (degree.get(aid) || 0) + 1);
- degree.set(bid, (degree.get(bid) || 0) + 1);
- debug.expresswayCorridors.push({ from: pair.a.role, to: pair.b.role, distance: Math.round(pair.d), length: Math.round(len), score: Math.round(pair.score * 100) / 100, path });
- }
-
- // Guarantee at least one expressway approach for every very large city. The
- // path still uses suburban anchors and expresswayCorridorCost, so it should
- // bypass the CBD and village cores instead of cutting through them.
- const expressInfluence = cachedInfluenceFromPaths(expressways, 18, "expressway:major-city-coverage");
- const allCandidateTargets = dedupeAnchors([...majorCityRefs, ...portRefs, ...externalRefs, ...remoteRefs], 8);
- for (const anchor of majorCityRefs.sort((a, b) => (b.population || 0) - (a.population || 0))) {
- if ((anchor.population || 0) < 100000) continue;
- const ai = indexOf(anchor.x, anchor.y);
- if ((expressInfluence[ai] || 0) > 0.20) continue;
- const options = allCandidateTargets
- .filter((q) => q !== anchor)
- .map((q) => ({ q, d: Math.hypot(q.x - anchor.x, q.y - anchor.y), c: approximateLineCost(anchor, q, expresswayCorridorCost) }))
- .filter((e) => e.d >= 38 && e.d <= 170 && Number.isFinite(e.c) && e.c < INF)
- .sort((a, b) => (a.d * a.c) - (b.d * b.c));
- for (const opt of options.slice(0, 8)) {
- const path = routeBetweenTrafficCandidates(anchor, opt.q, "expressway", expresswayCorridorCost, penalty, {
- curvePenalty: 0.11,
- penaltyStrength: 2.6,
- terrainFlowBias: 0.10,
- surfaceGrain: 0.006,
- relaxRadius: 1,
- relaxLineWeight: 0.22,
- maxPathLength: opt.d * 2.75 + 78,
- });
- const len = pathLengthCells(path);
- if (len >= 24 && len <= opt.d * 2.95 + 96 && !routeTooStraightMountainOnly(path) && expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) {
- expressways.push(path);
- addCorridorInfluencePenalty(penalty, path, 58, 8.80);
- debug.expresswayCorridors.push({ from: "major-city-guarantee", city: anchor.city?.name, to: opt.q.role, distance: Math.round(opt.d), length: Math.round(len), score: 0, path });
- break;
- }
- }
- const refreshed = cachedInfluenceFromPaths(expressways, 9, `expressway:coverage:${anchor.x},${anchor.y}`);
- if ((refreshed[ai] || 0) <= 0.20) {
- // Last resort: create a short suburban approach to the nearest low-cost
- // through corridor cell, still outside the urban core. This avoids the
- // pathological case where a large isolated city receives no motorway at all.
- const fallbackTargets = [];
- const searchR = 56;
- for (let dy = -searchR; dy <= searchR; dy += 3) {
- for (let dx = -searchR; dx <= searchR; dx += 3) {
- const x = anchor.x + dx;
- const y = anchor.y + dy;
- if (!inside(x, y)) continue;
- const i = indexOf(x, y);
- const d = Math.hypot(dx, dy);
- if (d < 20 || d > searchR || sea[i] || expresswayCorridorCost[i] >= INF) continue;
- if (settlementDemand[i] > 0.44 || preliminaryTownInfluence[i] > 0.34 || preliminaryVillageInfluence[i] > 0.30) continue;
- fallbackTargets.push({ x, y, d, role: "suburban-fallback", population: anchor.population, score: expresswayCorridorCost[i] + d * 0.018 });
- }
- }
- fallbackTargets.sort((a, b) => a.score - b.score);
- const target = fallbackTargets[0];
- if (target) {
- const path = routeBetweenTrafficCandidates(anchor, target, "expressway", expresswayCorridorCost, penalty, {
- curvePenalty: 0.11,
- penaltyStrength: 2.2,
- terrainFlowBias: 0.10,
- surfaceGrain: 0.006,
- relaxRadius: 1,
- relaxLineWeight: 0.16,
- maxPathLength: target.d * 2.8 + 34,
- });
- if (pathLengthCells(path) >= 12 && expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) {
- expressways.push(path);
- addCorridorInfluencePenalty(penalty, path, 10, 0.74);
- debug.expresswayCorridors.push({ from: "major-city-fallback-approach", city: anchor.city?.name, to: target.role, distance: Math.round(target.d), length: Math.round(pathLengthCells(path)), score: 0, path });
- }
- }
- }
- }
-
- // Final city-level coverage pass. The anchor-level influence check can miss
- // paired urban centers whose suburban anchors were deduplicated into the
- // neighboring city. Check distance from each major city center to the
- // motorway layer, then connect its own suburban anchor to the nearest
- // existing motorway cell or create a short outward suburban approach.
- function expresswayCells() {
- const cells = [];
- for (const path of expressways) {
- for (const [x, y] of path) if (inside(x, y) && !sea[indexOf(x, y)]) cells.push({ x, y, role: "existing-expressway", population: 0 });
- }
- return cells;
- }
- function minDistanceToExpressways(x, y) {
- let best = Infinity;
- for (const c of expresswayCells()) best = Math.min(best, Math.hypot(c.x - x, c.y - y));
- return best;
- }
- for (const city of modernCities.filter((c) => (c.population || 0) >= 100000).sort((a, b) => (b.population || 0) - (a.population || 0))) {
- const coverLimit = Math.max(25, (city.urbanRadius || 13) * 1.75);
- if (minDistanceToExpressways(city.x, city.y) <= coverLimit) continue;
- const anchor = majorCitySuburbanAnchor(city);
- if (!anchor) continue;
- let addedForCity = false;
- const cells = expresswayCells()
- .map((q) => ({ q, d: Math.hypot(q.x - anchor.x, q.y - anchor.y), c: approximateLineCost(anchor, q, expresswayCorridorCost) }))
- .filter((e) => e.d >= 8 && e.d <= 105 && Number.isFinite(e.c) && e.c < INF)
- .sort((a, b) => (a.d * a.c) - (b.d * b.c));
- for (const opt of cells.slice(0, 8)) {
- const path = routeBetweenTrafficCandidates(anchor, opt.q, "expressway", expresswayCorridorCost, penalty, {
- curvePenalty: 0.11,
- penaltyStrength: 2.6,
- terrainFlowBias: 0.10,
- surfaceGrain: 0.006,
- relaxRadius: 1,
- relaxLineWeight: 0.18,
- maxPathLength: opt.d * 2.85 + 42,
- });
- const len = pathLengthCells(path);
- if (len >= 8 && len <= opt.d * 3.0 + 58 && !routeTooStraightMountainOnly(path) && expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) {
- expressways.push(path);
- addCorridorInfluencePenalty(penalty, path, 10, 0.72);
- debug.expresswayCorridors.push({ from: "major-city-center-coverage", city: city.name, to: opt.q.role, distance: Math.round(opt.d), length: Math.round(len), score: 0, path });
- addedForCity = true;
- break;
- }
- }
- if (!addedForCity) {
- const searchR = 48;
- const fallbackTargets = [];
- for (let dy = -searchR; dy <= searchR; dy += 3) {
- for (let dx = -searchR; dx <= searchR; dx += 3) {
- const x = anchor.x + dx;
- const y = anchor.y + dy;
- if (!inside(x, y)) continue;
- const i = indexOf(x, y);
- const d = Math.hypot(dx, dy);
- if (d < 16 || d > searchR || sea[i] || expresswayCorridorCost[i] >= INF) continue;
- if (settlementDemand[i] > 0.46 || preliminaryTownInfluence[i] > 0.36 || preliminaryVillageInfluence[i] > 0.31) continue;
- fallbackTargets.push({ x, y, d, role: "city-coverage-fallback", population: city.population, score: expresswayCorridorCost[i] + d * 0.016 });
- }
- }
- fallbackTargets.sort((a, b) => a.score - b.score);
- for (const target of fallbackTargets.slice(0, 4)) {
- const path = routeBetweenTrafficCandidates(anchor, target, "expressway", expresswayCorridorCost, penalty, {
- curvePenalty: 0.11,
- penaltyStrength: 2.2,
- terrainFlowBias: 0.10,
- surfaceGrain: 0.006,
- relaxRadius: 1,
- relaxLineWeight: 0.16,
- maxPathLength: target.d * 2.9 + 36,
- });
- if (pathLengthCells(path) >= 10 && expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) {
- expressways.push(path);
- addCorridorInfluencePenalty(penalty, path, 10, 0.64);
- debug.expresswayCorridors.push({ from: "major-city-center-fallback", city: city.name, to: target.role, distance: Math.round(target.d), length: Math.round(pathLengthCells(path)), score: 0, path });
- break;
- }
- }
- }
- }
-
- // Inter-city backbone pass. The previous city-coverage fallback produced
- // short suburban motorway approaches, but did not necessarily connect those
- // approaches into a through network. Treat high-capacity roads as OD
- // corridors: connect major-city suburb anchors, ports and external gates by
- // a small Kruskal-style backbone over low-cost terrain.
- const backboneAnchors = dedupeAnchors([...majorSuburbs, ...portRefs, ...externalRefs], 10)
- .filter((p) => p && inside(p.x, p.y) && !sea[indexOf(p.x, p.y)]);
- const keyOf = (p) => `${p.x},${p.y}`;
- const { find, unite } = makeUnionFind(backboneAnchors, keyOf);
- const backbonePairs = [];
- for (let a = 0; a < backboneAnchors.length; a++) {
- for (let b = a + 1; b < backboneAnchors.length; b++) {
- const A = backboneAnchors[a];
- const B = backboneAnchors[b];
- const d = Math.hypot(A.x - B.x, A.y - B.y);
- if (d < 44 || d > 190) continue;
- const c = approximateLineCost(A, B, expresswayCorridorCost);
- if (!Number.isFinite(c) || c >= INF) continue;
- const demand = Math.sqrt(Math.max(50000, A.population || 70000) * Math.max(50000, B.population || 70000)) / 120000;
- const gatewayBonus = A.role === "external-gateway" || B.role === "external-gateway" ? 0.28 : 0;
- const portBonus = A.role === "port-logistics" || B.role === "port-logistics" ? 0.18 : 0;
- backbonePairs.push({ A, B, d, score: d * c / Math.max(0.55, demand + gatewayBonus + portBonus) });
- }
- }
- backbonePairs.sort((a, b) => a.score - b.score);
- let backboneAdded = 0;
- for (const pair of backbonePairs) {
- const ak = keyOf(pair.A);
- const bk = keyOf(pair.B);
- if (find(ak) === find(bk)) continue;
- if (backboneAdded >= Math.min(9, Math.max(3, backboneAnchors.length - 1))) break;
- const path = routeBetweenTrafficCandidates(pair.A, pair.B, "expressway", expresswayCorridorCost, penalty, {
- curvePenalty: 0.105,
- penaltyStrength: 2.10,
- terrainFlowBias: 0.12,
- surfaceGrain: 0.007,
- relaxRadius: 1,
- relaxLineWeight: 0.20,
- maxPathLength: pair.d * 3.05 + 96,
- snapRadius: 5,
- });
- const len = pathLengthCells(path);
- if (len < 34 || len > pair.d * 3.15 + 116) continue;
- if (routeTooStraightMountainOnly(path)) continue;
- if (!expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) continue;
- expressways.push(path);
- addCorridorInfluencePenalty(penalty, path, 24, 1.70);
- unite(ak, bk);
- backboneAdded++;
- debug.expresswayCorridors.push({ from: "expressway-backbone", to: `${pair.A.role}-${pair.B.role}`, distance: Math.round(pair.d), length: Math.round(len), score: Math.round(pair.score * 100) / 100, path });
- }
- }
-
-
- function buildNationalCorridorNetwork(debug) {
- const baseNodes = [
- ...modernCities.map((c) => ({ ...c, score: 1.2 + Math.sqrt(c.population || 50000) / 430 + ((c.population || 0) >= 100000 ? 0.55 : 0) + ((c.population || 0) >= 500000 ? 1.10 : 0), role: "city", population: c.population || 0 })),
- ...markets.filter((m) => (m.population || 0) >= 3000).map((m) => ({ ...m, score: 0.72 + (m.population || 6000) / 42000, role: "market", population: m.population || 0 })),
- ...ports.map((p) => ({ ...p, score: 0.76 + (p.portClass === "major" ? 0.45 : 0), role: "port", population: p.population || 12000 })),
- ...externalGateways.map((g) => ({ ...g, score: 0.92, role: "external-gateway", population: 42000 })),
- ...villages.filter((v) => (v.population || 0) >= 2200).map((v) => ({ ...v, score: 0.38 + (v.population || 0) / 18000, role: "large-village", population: v.population || 0 })),
- ];
- const nodes = dedupeAnchors(baseNodes.sort((a, b) => b.score - a.score), 6).slice(0, 48);
- if (nodes.length < 2) return;
- const penalty = cachedInfluenceFromPaths([...expressways, ...externalRoads], 8, "national-corridor:base");
- const connected = [nodes[0]];
- const remaining = nodes.slice(1);
- const maxMain = Math.min(24, Math.max(14, Math.ceil(nodes.length * 0.42)));
-
- while (remaining.length && nationalRoads.length < maxMain) {
- let best = null;
- for (const node of remaining) {
- const candidates = connected
- .map((q) => {
- const d = Math.hypot(node.x - q.x, node.y - q.y);
- if (d < 10 || d > 112) return null;
- const lineCost = approximateLineCost(node, q, transportFields.national);
- if (!Number.isFinite(lineCost) || lineCost >= INF) return null;
- const demand = Math.sqrt(Math.max(3000, node.population || 6000) * Math.max(3000, q.population || 6000)) / 65000;
- const score = d * lineCost / Math.max(0.35, demand + node.score * 0.25 + q.score * 0.25);
- return { q, d, score };
- })
- .filter(Boolean)
- .sort((a, b) => a.score - b.score);
- if (!candidates.length) continue;
- const cand = candidates[0];
- if (!best || cand.score < best.score) best = { node, target: cand.q, d: cand.d, score: cand.score };
- }
- if (!best) break;
- const path = routeBetweenTrafficCandidates(best.node, best.target, "national", transportFields.national, penalty, {
- curvePenalty: 0.050,
- penaltyStrength: 0.92,
- terrainFlowBias: 0.26,
- surfaceGrain: 0.034,
- relaxRadius: 2,
- relaxLineWeight: 0.32,
- maxPathLength: best.d * 2.55 + 38,
- });
- const len = pathLengthCells(path);
- if (len >= 6 && len <= best.d * 2.65 + 42 && !routeTooStraightAcrossMountains(path, "national") && transportRouteAcceptable(path, "national", transportFields.nationalPotential, penalty, { minLength: 6, maxLength: best.d * 2.65 + 42, maxHighElevationShare: 0.34, maxSteepShare: 0.46 })) {
- nationalRoads.push(path);
- debug.nationalCorridors.push({ from: best.node.role, to: best.target.role, length: Math.round(len), path });
- addCorridorInfluencePenalty(penalty, path, 6, 0.30);
- }
- connected.push(best.node);
- remaining.splice(remaining.indexOf(best.node), 1);
- }
-
- const extraPairs = [];
- for (let a = 0; a < Math.min(nodes.length, 32); a++) {
- for (let b = a + 1; b < Math.min(nodes.length, 32); b++) {
- const A = nodes[a];
- const B = nodes[b];
- const d = Math.hypot(A.x - B.x, A.y - B.y);
- if (d < 22 || d > 86) continue;
- const regional = A.regionId !== B.regionId ? 0.22 : 0;
- const need = (A.score + B.score) * 0.5 + regional;
- extraPairs.push({ A, B, d, score: d / Math.max(0.5, need) + hash2(A.x + B.x, A.y + B.y, seed + 18161) * 0.06 });
- }
- }
- extraPairs.sort((a, b) => a.score - b.score);
- let addedExtra = 0;
- for (const pair of extraPairs) {
- if (addedExtra >= 5 || nationalRoads.length >= 29) break;
- const path = routeBetweenTrafficCandidates(pair.A, pair.B, "national", transportFields.national, penalty, {
- curvePenalty: 0.050,
- penaltyStrength: 1.05,
- terrainFlowBias: 0.25,
- surfaceGrain: 0.034,
- relaxRadius: 2,
- relaxLineWeight: 0.32,
- maxPathLength: pair.d * 2.35 + 32,
- });
- const len = pathLengthCells(path);
- if (len < 8 || len > pair.d * 2.35 + 32 || routeTooStraightAcrossMountains(path, "national")) continue;
- if (pathAverageField(path, penalty) > 0.42 && pathAverageField(path, transportFields.nationalPotential) < 0.37) continue;
- nationalRoads.push(path);
- addedExtra++;
- debug.nationalCorridors.push({ from: `${pair.A.role}-extra`, to: `${pair.B.role}-extra`, length: Math.round(len), path });
- addCorridorInfluencePenalty(penalty, path, 6, 0.32);
- }
-
- const nationalInfluence = cachedInfluenceFromPaths([...nationalRoads, ...externalRoads], 7, "national:major-city-coverage");
- const importantCities = modernCities
- .filter((c) => (c.population || 0) >= 100000)
- .sort((a, b) => (b.population || 0) - (a.population || 0));
- const nationalTargets = dedupeAnchors([...nodes, ...externalGateways.map((g) => ({ ...g, role: "external-gateway", population: 42000, score: 0.92 }))], 5);
- for (const city of importantCities) {
- const ci = indexOf(city.x, city.y);
- if ((nationalInfluence[ci] || 0) > 0.20) continue;
- const target = nationalTargets
- .filter((q) => Math.hypot(q.x - city.x, q.y - city.y) > 4)
- .map((q) => ({ q, d: Math.hypot(q.x - city.x, q.y - city.y), c: approximateLineCost(city, q, transportFields.national) }))
- .filter((e) => e.d <= 86 && Number.isFinite(e.c) && e.c < INF)
- .sort((a, b) => (a.d * a.c) - (b.d * b.c))[0];
- if (!target) continue;
- const path = routeBetweenTrafficCandidates(city, target.q, "national", transportFields.national, penalty, {
- curvePenalty: 0.052,
- penaltyStrength: 0.86,
- terrainFlowBias: 0.27,
- surfaceGrain: 0.034,
- relaxRadius: 2,
- relaxLineWeight: 0.28,
- maxPathLength: target.d * 2.5 + 34,
- });
- const len = pathLengthCells(path);
- if (len >= 4 && len <= target.d * 2.55 + 38 && !routeTooStraightAcrossMountains(path, "national")) {
- nationalRoads.push(path);
- debug.nationalCorridors.push({ from: "major-city-guarantee", city: city.name, to: target.q.role, length: Math.round(len), path });
- addCorridorInfluencePenalty(penalty, path, 6, 0.30);
- }
- }
-
- // National roads should form regional corridors, not a set of short roads
- // terminating around each town. Add a sparse backbone over cities, ports
- // and external gates after the initial MST/extra pass, using the same
- // terrain cost field but a larger distance envelope.
- const backboneNodes = dedupeAnchors([
- ...modernCities.filter((c) => (c.population || 0) >= 65000).map((c) => ({ ...c, role: "city-backbone", score: 1.0 + Math.sqrt(c.population || 70000) / 420, population: c.population || 0 })),
- ...ports.filter((p) => p.portClass === "major" || (p.population || 0) >= 9000).map((p) => ({ ...p, role: "port-backbone", score: 1.05, population: p.population || 20000 })),
- ...externalGateways.map((g) => ({ ...g, role: "external-backbone", score: 1.0, population: 42000 })),
- ].sort((a, b) => b.score - a.score), 7).slice(0, 34);
- const keyOf = (p) => `${p.x},${p.y}`;
- const { find, unite } = makeUnionFind(backboneNodes, keyOf);
- const pairs = [];
- for (let a = 0; a < backboneNodes.length; a++) {
- for (let b = a + 1; b < backboneNodes.length; b++) {
- const A = backboneNodes[a];
- const B = backboneNodes[b];
- const d = Math.hypot(A.x - B.x, A.y - B.y);
- if (d < 16 || d > 132) continue;
- const c = approximateLineCost(A, B, transportFields.national);
- if (!Number.isFinite(c) || c >= INF) continue;
- const demand = Math.sqrt(Math.max(9000, A.population || 12000) * Math.max(9000, B.population || 12000)) / 82000;
- const regional = A.regionId !== B.regionId ? 0.28 : 0;
- pairs.push({ A, B, d, score: d * c / Math.max(0.42, demand + regional + (A.score + B.score) * 0.18) });
- }
- }
- pairs.sort((a, b) => a.score - b.score);
- let backboneAdded = 0;
- for (const pair of pairs) {
- if (backboneAdded >= Math.min(22, Math.max(8, backboneNodes.length - 1))) break;
- const ak = keyOf(pair.A);
- const bk = keyOf(pair.B);
- if (find(ak) === find(bk)) continue;
- const path = routeBetweenTrafficCandidates(pair.A, pair.B, "national", transportFields.national, penalty, {
- curvePenalty: 0.052,
- penaltyStrength: 0.78,
- terrainFlowBias: 0.28,
- surfaceGrain: 0.036,
- relaxRadius: 2,
- relaxLineWeight: 0.28,
- maxPathLength: pair.d * 3.05 + 62,
- snapRadius: 4,
- });
- const len = pathLengthCells(path);
- if (len < 6 || len > pair.d * 3.15 + 78) continue;
- if (routeTooStraightAcrossMountains(path, "national")) continue;
- nationalRoads.push(path);
- addCorridorInfluencePenalty(penalty, path, 6, 0.27);
- unite(ak, bk);
- backboneAdded++;
- debug.nationalCorridors.push({ from: "national-backbone", to: `${pair.A.role}-${pair.B.role}`, length: Math.round(len), path });
- }
- }
-
// -------------------------------------------------------------------------
// Density-flow transport system
// -------------------------------------------------------------------------
@@ -747,23 +376,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
);
}
- function markPathInfluence(field, path, radius = 5, strength = 1) {
- 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;
- }
- }
- }
- }
+ const markPathInfluence = (field, path, radius = 5, strength = 1) => markPathInfluenceBase(field, path, radius, strength, sea);
function fieldAdjustedCost(baseCost, mode, flowField = null) {
const out = new Float32Array(SIZE);
@@ -914,17 +527,17 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
function roadAnchorsForMode(mode = "national") {
if (mode === "expressway") {
const urbanPortals = modernCities
- // Expressways are intercity corridors. Do not give every medium city an
- // urban-expressway-like fringe anchor; medium cities are handled by the
- // national-road layer unless they are a capital.
- .filter((c) => (c.population || 0) >= 150000 || c.isRegionalCapital || c.isPrefecturalCapital)
+ // Expressways are intercity corridors, but cities over 100k should still
+ // have a suburban motorway contact point. The portal search stays outside
+ // the dense core, so the rendered line does not snap to the city dot.
+ .filter((c) => (c.population || 0) >= 100000 || c.isRegionalCapital || c.isPrefecturalCapital)
.flatMap((c) => cityPortalAnchors(c, "expressway"));
const portPortals = commercialPorts
.filter((p) => p.portClass === "major" || p.portClass === "regional")
.map((p) => portalSearchAroundPoint(p, "expressway", "port-fringe", { inner: 4, outer: 14 }) || { ...p, role: "port-fringe", population: p.population || 55000, score: 0.9 });
const gatewayPortals = externalGateways.map((g) => ({ ...g, role: "external-gateway", population: 90000, score: 0.92 }));
- const fieldPortals = densityFieldAnchors("expressway", 16);
- return dedupeAnchors([...urbanPortals, ...portPortals, ...gatewayPortals, ...fieldPortals].sort((a, b) => b.score - a.score), 11).slice(0, 38);
+ const fieldPortals = densityFieldAnchors("expressway", 11);
+ return dedupeAnchors([...urbanPortals, ...portPortals, ...gatewayPortals, ...fieldPortals].sort((a, b) => b.score - a.score), 11).slice(0, 30);
}
const cityPortals = modernCities.flatMap((c) => cityPortalAnchors(c, "national"));
@@ -937,13 +550,13 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
const portPortals = ports.map((p) => ({ ...p, role: "port", score: 0.72 + (p.portClass === "major" ? 0.48 : p.portClass === "regional" ? 0.28 : 0), population: p.population || 18000 }));
const passPortals = passes.map((p) => ({ ...p, role: "pass", score: 0.46 + (passSuitability?.[indexOf(p.x, p.y)] || 0), population: 8000 }));
const gatewayPortals = externalGateways.map((g) => ({ ...g, role: "external-gateway", population: 42000, score: 0.92 }));
- const fieldPortals = densityFieldAnchors("national", 54);
- return dedupeAnchors([...cityPortals, ...marketPortals, ...villagePortals, ...portPortals, ...passPortals, ...gatewayPortals, ...fieldPortals].sort((a, b) => b.score - a.score), 5.5).slice(0, 88);
+ const fieldPortals = densityFieldAnchors("national", 40);
+ return dedupeAnchors([...cityPortals, ...marketPortals, ...villagePortals, ...portPortals, ...passPortals, ...gatewayPortals, ...fieldPortals].sort((a, b) => b.score - a.score), 5.5).slice(0, 68);
}
function buildTrafficFlowField(mode, anchors, baseCost, maxRoutes = 34) {
const flow = new Float32Array(SIZE);
- const nodes = anchors.slice(0, mode === "expressway" ? 24 : 56);
+ const nodes = anchors.slice(0, mode === "expressway" ? 18 : 42);
const pairs = [];
for (let a = 0; a < nodes.length; a++) {
for (let b = a + 1; b < nodes.length; b++) {
@@ -1159,14 +772,102 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
return { before, after: expressways.length, loopTrimmed, urbanPruned, shortPruned };
}
+ function routeScoredPairs({
+ pairs,
+ mode,
+ outPaths,
+ costField,
+ potentialField,
+ penalty,
+ accepted,
+ debug,
+ options = {},
+ skip,
+ keyOf,
+ degree,
+ find,
+ unite,
+ }) {
+ const config = roadMode(mode);
+ const policy = fieldBackbonePolicy(config.fieldPolicyMode || mode);
+ let connectedAdds = 0;
+ let extraAdds = 0;
+ const maxAdded = options.maxAdded ?? (mode === "expressway" ? Math.min(7, Math.max(3, Math.ceil((options.nodeCount || 1) / 5))) : Math.min(26, Math.max(12, Math.ceil((options.nodeCount || 1) * 0.36))));
+ const maxExtra = options.maxExtra ?? config.backbone?.maxExtra ?? 0;
+ const maxDegree = options.maxDegree ?? config.backbone?.maxDegree ?? 3;
+ for (const pair of pairs) {
+ if (outPaths.length >= maxAdded) break;
+ const ak = keyOf(pair.A);
+ const bk = keyOf(pair.B);
+ const connects = find(ak) !== find(bk);
+ if (!connects && extraAdds >= maxExtra) { skip.degree++; continue; }
+ if ((degree.get(ak) || 0) >= maxDegree || (degree.get(bk) || 0) >= maxDegree) { skip.degree++; continue; }
+ const routeOptions = {
+ ...config.routeOptions,
+ maxPathLength: pair.d * (mode === "expressway" ? 2.45 : 2.28) + (mode === "expressway" ? 66 : 32),
+ ...options.routeOptions,
+ };
+ const path = routeBetweenTrafficCandidates(pair.A, pair.B, mode, costField, penalty, routeOptions);
+ const len = pathLengthCells(path);
+ if (!path.length) { skip.noPath++; continue; }
+ if (len < policy.minLength || len > pair.d * policy.maxLengthMultiplier + policy.maxLengthAdd) { skip.length++; continue; }
+ if (mode === "expressway") {
+ const mountainCheck = mountainRouteAssessment(path, config.mountainMode);
+ if (!mountainCheck.ok) {
+ recordSkipReason(skip, "mountain", mountainCheck.reason);
+ continue;
+ }
+ const acceptCheck = routeAcceptableForMode(path, mode, potentialField, penalty, {}, { allowFallback: true, allowApproach: true });
+ if (!acceptCheck.ok) {
+ recordSkipReason(skip, "acceptable", acceptCheck.reason);
+ continue;
+ }
+ } else {
+ const mountainCheck = mountainRouteAssessment(path, config.mountainMode);
+ if (!mountainCheck.ok) {
+ recordSkipReason(skip, "mountain", mountainCheck.reason);
+ continue;
+ }
+ const acceptCheck = routeAcceptableForMode(path, "national", potentialField, penalty, {
+ minLength: policy.minLength,
+ maxLength: pair.d * policy.maxLengthMultiplier + policy.maxLengthAdd,
+ maxHighElevationShare: policy.maxHighElevationShare,
+ maxSteepShare: policy.maxSteepShare,
+ });
+ if (!acceptCheck.ok) {
+ recordSkipReason(skip, "acceptable", acceptCheck.reason);
+ continue;
+ }
+ }
+ const parallel = existingParallelShare(path, accepted, mode === "expressway" ? 0.010 : 0.18);
+ if (parallel > (connects ? policy.parallelConnected : policy.parallelExtra)) { skip.parallel++; continue; }
+ outPaths.push(path);
+ markPathInfluence(accepted, path, options.parallelRadius ?? config.backbone?.parallelRadius ?? 6, 1);
+ addCorridorInfluencePenalty(penalty, path, options.parallelRadius ?? config.backbone?.parallelRadius ?? 6, options.penaltyStrengthMark ?? config.backbone?.penaltyStrengthMark ?? 0.30);
+ degree.set(ak, (degree.get(ak) || 0) + 1);
+ degree.set(bk, (degree.get(bk) || 0) + 1);
+ if (connects) {
+ unite(ak, bk);
+ connectedAdds++;
+ } else {
+ extraAdds++;
+ }
+ skip.added++;
+ recordCorridor(debug, mode, pair.A.role, pair.B.role, path, {
+ distance: Math.round(pair.d),
+ score: Math.round(pair.score * 100) / 100,
+ });
+ }
+ return { connectedAdds, extraAdds };
+ }
+
function buildFieldBackbone(debug, mode, outPaths, anchors, costField, potentialField, options = {}) {
- const nodes = anchors.slice(0, options.maxNodes ?? (mode === "expressway" ? 38 : 76));
+ const config = roadMode(mode);
+ const nodes = anchors.slice(0, options.maxNodes ?? (mode === "expressway" ? 30 : 58));
if (nodes.length < 2) return;
- const policy = fieldBackbonePolicy(mode);
- const label = mode === "expressway" ? "expresswayCorridors" : "nationalCorridors";
const penalty = new Float32Array(SIZE);
const accepted = new Float32Array(SIZE);
- for (const path of outPaths) markPathInfluence(accepted, path, options.parallelRadius ?? (mode === "expressway" ? 44 : 6), 1);
+ for (const path of outPaths) markPathInfluence(accepted, path, options.parallelRadius ?? config.backbone?.parallelRadius ?? 6, 1);
if (mode === "national") {
for (const path of expressways) markPathInfluence(penalty, path, 8, 0.16);
for (const path of externalRoads) markPathInfluence(accepted, path, 5, 0.55);
@@ -1198,80 +899,22 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
pairs.sort((a, b) => a.score - b.score);
const skip = { pairs: pairs.length, degree: 0, noPath: 0, length: 0, mountain: 0, mountainReasons: {}, acceptable: 0, acceptableReasons: {}, parallel: 0, added: 0 };
- let connectedAdds = 0;
- let extraAdds = 0;
- const maxAdded = options.maxAdded ?? (mode === "expressway" ? Math.min(10, Math.max(4, Math.ceil(nodes.length / 4))) : Math.min(34, Math.max(16, Math.ceil(nodes.length * 0.46))));
- const maxExtra = options.maxExtra ?? (mode === "expressway" ? 2 : 7);
- const maxDegree = options.maxDegree ?? (mode === "expressway" ? 2 : 4);
- for (const pair of pairs) {
- if (outPaths.length >= maxAdded) break;
- const ak = keyOf(pair.A);
- const bk = keyOf(pair.B);
- const connects = find(ak) !== find(bk);
- if (!connects && extraAdds >= maxExtra) { skip.degree++; continue; }
- if ((degree.get(ak) || 0) >= maxDegree || (degree.get(bk) || 0) >= maxDegree) { skip.degree++; continue; }
- const path = routeBetweenTrafficCandidates(pair.A, pair.B, mode, costField, penalty, {
- curvePenalty: mode === "expressway" ? 0.125 : 0.065,
- penaltyStrength: mode === "expressway" ? 7.20 : 1.05,
- terrainFlowBias: mode === "expressway" ? 0.08 : 0.24,
- surfaceGrain: mode === "expressway" ? 0.006 : 0.030,
- relaxRadius: mode === "expressway" ? 1 : 2,
- relaxLineWeight: mode === "expressway" ? 0.19 : 0.28,
- maxPathLength: pair.d * (mode === "expressway" ? 2.75 : 2.55) + (mode === "expressway" ? 84 : 42),
- snapRadius: mode === "expressway" ? 3.5 : 2.5,
- heuristicWeight: mode === "expressway" ? 0.72 : 0.50,
- });
- const len = pathLengthCells(path);
- if (!path.length) { skip.noPath++; continue; }
- if (len < policy.minLength || len > pair.d * policy.maxLengthMultiplier + policy.maxLengthAdd) { skip.length++; continue; }
- if (mode === "expressway") {
- const mountainCheck = mountainRouteAssessment(path, "expresswayMountainOnly");
- if (!mountainCheck.ok) {
- skip.mountain++;
- countReason(skip, "mountainReasons", mountainCheck.reason);
- continue;
- }
- const acceptCheck = routeAcceptableForMode(path, mode, potentialField, penalty, {}, { allowFallback: true, allowApproach: true });
- if (!acceptCheck.ok) {
- skip.acceptable++;
- countReason(skip, "acceptableReasons", acceptCheck.reason);
- continue;
- }
- } else {
- const mountainCheck = mountainRouteAssessment(path, "national");
- if (!mountainCheck.ok) {
- skip.mountain++;
- countReason(skip, "mountainReasons", mountainCheck.reason);
- continue;
- }
- const acceptCheck = routeAcceptableForMode(path, "national", potentialField, penalty, {
- minLength: policy.minLength,
- maxLength: pair.d * policy.maxLengthMultiplier + policy.maxLengthAdd,
- maxHighElevationShare: policy.maxHighElevationShare,
- maxSteepShare: policy.maxSteepShare,
- });
- if (!acceptCheck.ok) {
- skip.acceptable++;
- countReason(skip, "acceptableReasons", acceptCheck.reason);
- continue;
- }
- }
- const parallel = existingParallelShare(path, accepted, mode === "expressway" ? 0.010 : 0.18);
- if (parallel > (connects ? policy.parallelConnected : policy.parallelExtra)) { skip.parallel++; continue; }
- outPaths.push(path);
- markPathInfluence(accepted, path, options.parallelRadius ?? (mode === "expressway" ? 44 : 6), 1);
- addCorridorInfluencePenalty(penalty, path, options.parallelRadius ?? (mode === "expressway" ? 44 : 6), options.penaltyStrengthMark ?? (mode === "expressway" ? 2.10 : 0.30));
- degree.set(ak, (degree.get(ak) || 0) + 1);
- degree.set(bk, (degree.get(bk) || 0) + 1);
- if (connects) {
- unite(ak, bk);
- connectedAdds++;
- } else {
- extraAdds++;
- }
- skip.added++;
- debug[label].push({ from: pair.A.role, to: pair.B.role, length: Math.round(len), distance: Math.round(pair.d), score: Math.round(pair.score * 100) / 100, path });
- }
+ const { connectedAdds, extraAdds } = routeScoredPairs({
+ pairs,
+ mode,
+ outPaths,
+ costField,
+ potentialField,
+ penalty,
+ accepted,
+ debug,
+ options: { ...options, nodeCount: nodes.length },
+ skip,
+ keyOf,
+ degree,
+ find,
+ unite,
+ });
debug[`${mode}AnchorCount`] = nodes.length;
debug[`${mode}ConnectedAdds`] = connectedAdds;
debug[`${mode}ExtraAdds`] = extraAdds;
@@ -1313,6 +956,82 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
return false;
}
+ function pathServesSuburbanAnchor(path, city, anchor, radius = 7.0) {
+ if (!path?.length || !city || !anchor) return false;
+ const coreAvoid = Math.max(4.5, (city.coreRadius || 3.5) + 1.5);
+ const fringeRadius = Math.max(radius, (city.urbanRadius || 12) * 0.42);
+ const exitRadius = Math.max(24, (city.urbanRadius || 12) * 1.55);
+ let nearAnchor = false;
+ let outsideCore = false;
+ let exitsEnvelope = false;
+ for (const [x, y] of path) {
+ const da = Math.hypot(x - anchor.x, y - anchor.y);
+ const dc = Math.hypot(x - city.x, y - city.y);
+ if (da <= fringeRadius) nearAnchor = true;
+ if (dc >= coreAvoid) outsideCore = true;
+ if (dc >= exitRadius) exitsEnvelope = true;
+ if (nearAnchor && outsideCore && exitsEnvelope) return true;
+ }
+ return false;
+ }
+
+ function ensureCityOutboundConnections({
+ debug,
+ mode,
+ cities,
+ startForCity,
+ targetAnchors,
+ costField,
+ penalty,
+ paths,
+ acceptedPaths = [],
+ maxTargets = 5,
+ minDistance = 14,
+ maxDistance = 126,
+ routeOptions = {},
+ acceptable,
+ recordFrom,
+ counterKey,
+ }) {
+ let added = 0;
+ const config = roadMode(mode);
+ const accepted = new Float32Array(SIZE);
+ for (const path of paths) markPathInfluence(accepted, path, config.backbone?.parallelRadius ?? 6, 1.0);
+ for (const path of acceptedPaths) markPathInfluence(accepted, path, config.backbone?.parallelRadius ?? 6, mode === "national" ? 0.55 : 1.0);
+ for (const city of cities) {
+ const start = startForCity(city);
+ if (!start) continue;
+ if (paths.some((path) => mode === "expressway" ? pathServesSuburbanAnchor(path, city, start, 7.0) : pathServesCityCenter(path, city, mode))) continue;
+ const candidates = targetAnchors
+ .filter((q) => q !== start && q.city !== city && q.source !== city)
+ .map((q) => ({ q, d: Math.hypot(q.x - start.x, q.y - start.y), c: approximateLineCost(start, q, costField) }))
+ .filter((e) => e.d >= minDistance && e.d <= maxDistance && Number.isFinite(e.c) && e.c < INF)
+ .sort((a, b) => {
+ const aCity = a.q.role === (mode === "expressway" ? "urban-fringe-ic" : "urban-portal") ? -10 : 0;
+ const bCity = b.q.role === (mode === "expressway" ? "urban-fringe-ic" : "urban-portal") ? -10 : 0;
+ return (a.d * a.c + aCity) - (b.d * b.c + bCity);
+ });
+ for (const opt of candidates.slice(0, maxTargets)) {
+ const path = routeBetweenTrafficCandidates(start, opt.q, mode, costField, penalty, {
+ ...config.routeOptions,
+ ...routeOptions,
+ maxPathLength: typeof routeOptions.maxPathLength === "function" ? routeOptions.maxPathLength(opt.d) : routeOptions.maxPathLength ?? opt.d * 3.05 + 74,
+ searchPad: typeof routeOptions.searchPad === "function" ? routeOptions.searchPad(opt.d) : routeOptions.searchPad,
+ });
+ const len = pathLengthCells(path);
+ if (!acceptable(path, len, opt, city, start, accepted)) continue;
+ paths.push(path);
+ markPathInfluence(accepted, path, config.backbone?.parallelRadius ?? 6, 1.0);
+ addCorridorInfluencePenalty(penalty, path, config.backbone?.parallelRadius ?? 6, mode === "expressway" ? 1.10 : 0.38);
+ recordCorridor(debug, mode, recordFrom, opt.q.city?.name || opt.q.source?.name || opt.q.role, path, { city: city.name });
+ added++;
+ break;
+ }
+ }
+ if (counterKey) debug[counterKey] = (debug[counterKey] || 0) + added;
+ return added;
+ }
+
function ensureExpresswayCityIntercity(debug, expressAnchors, expressCost) {
const forceCost = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
@@ -1346,7 +1065,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
return best;
}
const eligibleCities = modernCities
- .filter((c) => (c.population || 0) >= 150000 || c.isRegionalCapital || c.isPrefecturalCapital)
+ .filter((c) => (c.population || 0) >= 100000 || c.isRegionalCapital || c.isPrefecturalCapital)
.sort((a, b) => (b.population || 0) - (a.population || 0));
const cityAnchors = eligibleCities
.map((city) => {
@@ -1370,7 +1089,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
const stats = { cities: cityAnchors.length, covered: 0, noCandidates: 0, tried: 0, noPath: 0, length: 0, notOutbound: 0, mountain: 0, mountainReasons: {}, unacceptable: 0, unacceptableReasons: {}, parallel: 0 };
for (const anchor of cityAnchors) {
const city = anchor.city;
- if (expressways.some((path) => pathServesCityCenter(path, city, "expressway"))) { stats.covered++; continue; }
+ if (expressways.some((path) => pathServesSuburbanAnchor(path, city, anchor, 7.0))) { stats.covered++; continue; }
const candidates = otherTargets
.filter((q) => q !== anchor && q.city !== city)
.map((q) => {
@@ -1386,7 +1105,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
return (a.d * a.c + aCity) - (b.d * b.c + bCity);
});
if (!candidates.length) stats.noCandidates++;
- for (const opt of candidates.slice(0, 10)) {
+ for (const opt of candidates.slice(0, 6)) {
stats.tried++;
const path = routeBetweenTrafficCandidates(anchor, opt.q, "expressway", forceCost, penalty, {
curvePenalty: 0.13,
@@ -1424,7 +1143,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
expressways.push(path);
markPathInfluence(accepted, path, 86, 1.0);
addCorridorInfluencePenalty(penalty, path, 82, 12.40);
- debug.expresswayCorridors.push({ from: "forced-city-intercity", city: city.name, to: opt.q.city?.name || opt.q.role, distance: Math.round(opt.d), length: Math.round(len), path });
+ recordCorridor(debug, "expressway", "forced-city-intercity", opt.q.city?.name || opt.q.role, path, { city: city.name, distance: Math.round(opt.d) });
added++;
break;
}
@@ -1438,58 +1157,49 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
.filter((p) => p && ["urban-portal", "market-portal", "port", "external-gateway", "large-village"].includes(p.role))
.sort((a, b) => (b.score || 0) - (a.score || 0));
const penalty = new Float32Array(SIZE);
- const accepted = new Float32Array(SIZE);
for (const path of nationalRoads) {
markPathInfluence(penalty, path, 6, 0.42);
- markPathInfluence(accepted, path, 6, 1.0);
}
- for (const path of externalRoads) markPathInfluence(accepted, path, 5, 0.55);
- let added = 0;
const cities = modernCities
.filter((c) => (c.population || 0) >= 52000 || c.isRegionalCapital || c.isPrefecturalCapital)
.sort((a, b) => (b.population || 0) - (a.population || 0));
- for (const city of cities) {
- const start = cityPortalAnchors(city, "national")[0] || portalSearchAroundPoint(city, "national", "urban-portal");
- if (!start) continue;
- if (nationalRoads.some((path) => pathServesCityCenter(path, city, "national"))) continue;
- const candidates = targetAnchors
- .filter((q) => q !== start && q.city !== city && q.source !== city)
- .map((q) => ({ q, d: Math.hypot(q.x - start.x, q.y - start.y), c: approximateLineCost(start, q, nationalCost) }))
- .filter((e) => e.d >= 14 && e.d <= 126 && Number.isFinite(e.c) && e.c < INF)
- .sort((a, b) => {
- const aCity = a.q.role === "urban-portal" ? -10 : 0;
- const bCity = b.q.role === "urban-portal" ? -10 : 0;
- return (a.d * a.c + aCity) - (b.d * b.c + bCity);
- });
- for (const opt of candidates.slice(0, 8)) {
- const path = routeBetweenTrafficCandidates(start, opt.q, "national", nationalCost, penalty, {
+ ensureCityOutboundConnections({
+ debug,
+ mode: "national",
+ cities,
+ startForCity: (city) => cityPortalAnchors(city, "national")[0] || portalSearchAroundPoint(city, "national", "urban-portal"),
+ targetAnchors,
+ costField: nationalCost,
+ penalty,
+ paths: nationalRoads,
+ acceptedPaths: externalRoads,
+ minDistance: 14,
+ maxDistance: 126,
+ maxTargets: 8,
+ routeOptions: {
curvePenalty: 0.060,
penaltyStrength: 1.04,
terrainFlowBias: 0.26,
surfaceGrain: 0.030,
relaxRadius: 2,
relaxLineWeight: 0.28,
- maxPathLength: opt.d * 3.05 + 74,
+ maxPathLength: (d) => d * 3.05 + 74,
snapRadius: 2.0,
heuristicWeight: 0.60,
- searchPad: Math.ceil(Math.max(42, Math.min(110, opt.d * 0.62))),
- });
- const len = pathLengthCells(path);
- if (len < 5 || len > opt.d * 3.15 + 82) continue;
- if (!pathComesOutOfCity(path, city, start, "national")) continue;
- if (routeTooStraightAcrossMountains(path, "national")) continue;
- if (!transportRouteAcceptable(path, "national", transportFields.nationalPotential, penalty, { minLength: 5, maxLength: opt.d * 2.85 + 58, maxHighElevationShare: 0.36, maxSteepShare: 0.50 })) continue;
+ searchPad: (d) => Math.ceil(Math.max(42, Math.min(110, d * 0.62))),
+ },
+ acceptable: (path, len, opt, city, start, accepted) => {
+ if (len < 5 || len > opt.d * 3.15 + 82) return false;
+ if (!pathComesOutOfCity(path, city, start, "national")) return false;
+ if (routeTooStraightAcrossMountains(path, "national")) return false;
+ if (!transportRouteAcceptable(path, "national", transportFields.nationalPotential, penalty, { minLength: 5, maxLength: opt.d * 2.85 + 58, maxHighElevationShare: 0.36, maxSteepShare: 0.50 })) return false;
const parallel = existingParallelShare(path, accepted, 0.18);
- if (parallel > 0.58) continue;
- nationalRoads.push(path);
- markPathInfluence(accepted, path, 6, 1.0);
- addCorridorInfluencePenalty(penalty, path, 6, 0.38);
- debug.nationalCorridors.push({ from: "forced-city-outbound", city: city.name, to: opt.q.city?.name || opt.q.source?.name || opt.q.role, length: Math.round(len), path });
- added++;
- break;
- }
- }
- debug.forcedNationalCityConnections = (debug.forcedNationalCityConnections || 0) + added;
+ if (parallel > 0.58) return false;
+ return true;
+ },
+ recordFrom: "forced-city-outbound",
+ counterKey: "forcedNationalCityConnections",
+ });
}
function buildDensityFlowRoadSystem(debug) {
@@ -1499,34 +1209,24 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
.filter((p) => p.role === "urban-fringe-ic")
.map((p) => ({ x: p.x, y: p.y, city: p.city?.name, population: p.population }));
- const expressFlow = buildTrafficFlowField("expressway", expressAnchors, expresswayCorridorCost, 14);
+ const expressFlow = buildTrafficFlowField("expressway", expressAnchors, expresswayCorridorCost, 10);
const expressCost = fieldAdjustedCost(expresswayCorridorCost, "expressway", expressFlow);
buildFieldBackbone(debug, "expressway", expressways, expressAnchors, expressCost, transportFields.expresswayPotential, {
- maxNodes: 48,
- maxAdded: Math.min(4, Math.max(2, Math.ceil(expressAnchors.length / 12))),
- maxExtra: 0,
- minDistance: 38,
- maxDistance: 190,
- maxDegree: 2,
- parallelRadius: 84,
- penaltyStrengthMark: 16.50,
+ ...roadMode("expressway").backbone,
+ maxNodes: 34,
+ maxAdded: Math.min(3, Math.max(2, Math.ceil(expressAnchors.length / 14))),
});
ensureExpresswayCityIntercity(debug, expressAnchors, expressCost);
// Recompute national flow with expressways already present. National roads
// are allowed to cross/approach motorways but are discouraged from becoming
// a duplicate motorway frontage road for long distances.
- const nationalFlow = buildTrafficFlowField("national", nationalAnchors, transportFields.national, 50);
+ const nationalFlow = buildTrafficFlowField("national", nationalAnchors, transportFields.national, 34);
const nationalCost = fieldAdjustedCost(transportFields.national, "national", nationalFlow);
buildFieldBackbone(debug, "national", nationalRoads, nationalAnchors, nationalCost, transportFields.nationalPotential, {
- maxNodes: 78,
- maxAdded: Math.min(38, Math.max(18, Math.ceil(nationalAnchors.length * 0.45))),
- maxExtra: 8,
- minDistance: 12,
- maxDistance: 130,
- maxDegree: 4,
- parallelRadius: 6,
- penaltyStrengthMark: 0.32,
+ ...roadMode("national").backbone,
+ maxNodes: 58,
+ maxAdded: Math.min(27, Math.max(13, Math.ceil(nationalAnchors.length * 0.34))),
});
// Guarantee light national access to large urban areas whose portals were
@@ -1558,7 +1258,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
const len = pathLengthCells(path);
if (len >= 5 && len <= target.d * 2.7 + 44 && !routeTooStraightAcrossMountains(path, "national")) {
nationalRoads.push(path);
- debug.nationalCorridors.push({ from: "city-portal-guarantee", city: city.name, to: target.q.role, length: Math.round(len), path });
+ recordCorridor(debug, "national", "city-portal-guarantee", target.q.role, path, { city: city.name });
addCorridorInfluencePenalty(nationalPenalty, path, 6, 0.28);
}
}
@@ -1598,13 +1298,14 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
function addShortConnector(outPaths, a, b, mode = "local") {
const d = Math.hypot(a.x - b.x, a.y - b.y);
- const costField = mode === "expressway" ? expresswayCorridorCost : mode === "national" ? transportFields.national : transportFields.local;
+ const config = roadMode(mode);
+ const costField = config.costField();
let path = [];
if (d > 2.2) {
- path = routeBetweenTrafficCandidates(a, b, mode === "expressway" ? "expressway" : mode === "national" ? "national" : "local", costField, null, {
- curvePenalty: mode === "expressway" ? 0.095 : mode === "national" ? 0.045 : 0.035,
+ path = routeBetweenTrafficCandidates(a, b, mode, costField, null, {
+ curvePenalty: config.connector.curvePenalty,
penaltyStrength: 0.0,
- terrainFlowBias: mode === "expressway" ? 0.12 : mode === "national" ? 0.22 : 0.28,
+ terrainFlowBias: config.connector.terrainFlowBias,
surfaceGrain: 0.020,
relaxRadius: 1,
relaxLineWeight: 0.22,
@@ -1634,55 +1335,21 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
function stitchRasterNearContacts() {
const debug = { expressway: 0, national: 0, localToNational: 0, local: 0, broadNearMisses: 0 };
- function sampledCells(paths, step = 1) {
- const cells = [];
- for (let pathId = 0; pathId < (paths || []).length; pathId++) {
- const path = paths[pathId];
- for (let k = 0; k < path.length; k += step) {
- const [x, y] = path[k];
- if (inside(x, y) && !sea[indexOf(x, y)]) cells.push({ x, y, pathId });
- }
- }
- return cells;
- }
- function endpointListWithIds(paths) {
- const out = [];
- for (let pathId = 0; pathId < (paths || []).length; pathId++) {
- const p = paths[pathId];
- if (!p || p.length < 2) continue;
- out.push({ x: p[0][0], y: p[0][1], pathId });
- const q = p[p.length - 1];
- out.push({ x: q[0], y: q[1], pathId });
- }
- return out;
- }
- function nearestWithin(source, targets, radius, excludeSamePath = true) {
- let best = null;
- let bestD = radius + 1;
- for (const t of targets) {
- if (excludeSamePath && source.pathId != null && t.pathId === source.pathId) continue;
- const d = Math.hypot(source.x - t.x, source.y - t.y);
- if (d > 0.01 && d < bestD) {
- bestD = d;
- best = t;
- }
- }
- return best ? { target: best, d: bestD } : null;
- }
- function stitchEndpoints(paths, mode, targets, maxAdds, radius) {
+ function stitchEndpoints(paths, mode, targets, maxAdds, radius, probability = 1) {
let added = 0;
- const endpoints = endpointListWithIds(paths);
+ const endpoints = pathEndpoints(paths);
for (const ep of endpoints) {
if (added >= maxAdds) break;
- const near = nearestWithin(ep, targets, radius, true);
+ if (probability < 1 && hash2(ep.x, ep.y, seed + 18331 + added * 17) > probability) continue;
+ const near = nearestNetworkPoint(ep, targets, radius);
if (near && addShortConnector(paths, ep, near.target, mode)) added++;
}
return added;
}
- const expresswayCells = sampledCells(expressways, 1);
- const nationalCells = sampledCells([...nationalRoads, ...externalRoads], 1);
- const localCells = sampledCells(minorRoads, 1);
- debug.expressway += stitchEndpoints(expressways, "expressway", expresswayCells, 8, 34.0);
+ const expresswayCells = sampledNetworkCellsBase(expressways, 1, sea);
+ const nationalCells = sampledNetworkCellsBase([...nationalRoads, ...externalRoads], 1, sea);
+ const localCells = sampledNetworkCellsBase(minorRoads, 1, sea);
+ debug.expressway += stitchEndpoints(expressways, "expressway", expresswayCells, 14, 46.0, 0.62);
debug.national += stitchEndpoints(nationalRoads, "national", nationalCells, 48, 14.0);
debug.localToNational += stitchEndpoints(minorRoads, "local", nationalCells, 240, 20.0);
debug.local += stitchEndpoints(minorRoads, "local", localCells, 260, 16.0);
@@ -1691,11 +1358,11 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
// not share a raster cell. This addresses line simplification and diagonal
// near-contact cases not caught by endpoint-only repair.
const allLocalTargets = [...nationalCells, ...localCells];
- const candidates = sampledCells([...nationalRoads, ...minorRoads], 3)
+ const candidates = sampledNetworkCellsBase([...nationalRoads, ...minorRoads], 3, sea)
.sort((a, b) => hash2(a.x, a.y, seed + 18377) - hash2(b.x, b.y, seed + 18377));
for (const c of candidates) {
if (debug.broadNearMisses >= 260) break;
- const near = nearestWithin(c, allLocalTargets, 4.75, true);
+ const near = nearestNetworkPoint(c, allLocalTargets, 4.75);
if (!near) continue;
const out = c.pathId < nationalRoads.length ? nationalRoads : minorRoads;
const mode = out === nationalRoads ? "national" : "local";
@@ -1711,56 +1378,21 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
function stitchLongLocalBranches() {
const debug = { localToTrunk: 0, localToLocal: 0 };
- function cells(paths, step = 2) {
- const out = [];
- for (let pathId = 0; pathId < (paths || []).length; pathId++) {
- const path = paths[pathId];
- for (let k = 0; k < path.length; k += step) {
- const [x, y] = path[k];
- if (inside(x, y) && !sea[indexOf(x, y)]) out.push({ x, y, pathId });
- }
- }
- return out;
- }
- function endpoints(paths) {
- const out = [];
- for (let pathId = 0; pathId < (paths || []).length; pathId++) {
- const p = paths[pathId];
- if (!p || p.length < 2) continue;
- out.push({ x: p[0][0], y: p[0][1], pathId });
- const q = p[p.length - 1];
- out.push({ x: q[0], y: q[1], pathId });
- }
- return out;
- }
- function nearest(source, targets, radius, excludePath = null) {
- let best = null;
- let bestD = radius + 1;
- for (const t of targets) {
- if (excludePath != null && t.pathId === excludePath) continue;
- const d = Math.hypot(source.x - t.x, source.y - t.y);
- if (d > 0.01 && d < bestD) {
- bestD = d;
- best = t;
- }
- }
- return best ? { ...best, d: bestD } : null;
- }
- const trunkCells = cells([...nationalRoads, ...externalRoads], 2);
- const localCells = cells(minorRoads, 2);
- const eps = endpoints(minorRoads)
+ const trunkCells = sampledNetworkCellsBase([...nationalRoads, ...externalRoads], 2, sea);
+ const localCells = sampledNetworkCellsBase(minorRoads, 2, sea);
+ const eps = pathEndpoints(minorRoads)
.sort((a, b) => hash2(a.x, a.y, seed + 18491) - hash2(b.x, b.y, seed + 18491));
for (const ep of eps) {
if (debug.localToTrunk >= 160) break;
- const hit = nearest(ep, trunkCells, 20, null);
+ const hit = nearestNetworkPoint(ep, trunkCells, 20, { excludeSamePath: false });
if (!hit) continue;
if (addShortConnector(minorRoads, ep, hit, "local")) debug.localToTrunk++;
}
- const eps2 = endpoints(minorRoads)
+ const eps2 = pathEndpoints(minorRoads)
.sort((a, b) => hash2(a.x, a.y, seed + 18493) - hash2(b.x, b.y, seed + 18493));
for (const ep of eps2) {
if (debug.localToLocal >= 150) break;
- const hit = nearest(ep, localCells, 17, ep.pathId);
+ const hit = nearestNetworkPoint(ep, localCells, 17, { excludePath: ep.pathId });
if (!hit) continue;
if (addShortConnector(minorRoads, ep, hit, "local")) debug.localToLocal++;
}
@@ -1807,11 +1439,11 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
repairTransportConnectivity(expressways, "expressway", expresswayCorridorCost, transportFields.expresswayPotential, {
minImportance: 5.5,
minComponentCells: 10,
- maxComponents: 8,
- maxRepairs: 3,
- maxRepairDistance: 145,
+ maxComponents: 6,
+ maxRepairs: 2,
+ maxRepairDistance: 125,
minRepairDistance: 18,
- searchPad: 48,
+ searchPad: 38,
penaltyRadius: 18,
penaltyStrength: 4.2,
curvePenalty: 0.13,
@@ -1824,11 +1456,11 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
repairTransportConnectivity(nationalRoads, "national", transportFields.national, transportFields.nationalPotential, {
minImportance: 4.0,
minComponentCells: 7,
- maxComponents: 14,
- maxRepairs: 12,
- maxRepairDistance: 96,
+ maxComponents: 10,
+ maxRepairs: 7,
+ maxRepairDistance: 82,
minRepairDistance: 8,
- searchPad: 36,
+ searchPad: 28,
penaltyRadius: 6,
penaltyStrength: 1.15,
curvePenalty: 0.060,
@@ -1841,9 +1473,9 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
repairTransportConnectivity(railways, "rail", transportFields.rail, transportFields.railPotential, {
minImportance: 10.5,
minComponentCells: 18,
- maxComponents: 6,
- maxRepairs: 3,
- maxRepairDistance: 105,
+ maxComponents: 5,
+ maxRepairs: 2,
+ maxRepairDistance: 92,
penaltyRadius: 7,
penaltyStrength: 2.0,
curvePenalty: 0.16,
@@ -1872,9 +1504,9 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
}
const nationalEndpointRepair = repairDanglingTransportEndpoints(nationalRoads, "national", transportFields.national, [...externalRoads, ...expressways, ...railways], transportFields.nationalPotential, {
- maxAdded: 18,
- maxTargetDistance: 58,
- maxPathLength: 86,
+ maxAdded: 11,
+ maxTargetDistance: 50,
+ maxPathLength: 74,
curvePenalty: 0.060,
terrainFlowBias: 0.24,
surfaceGrain: 0.030,
@@ -1888,9 +1520,9 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
transportDebugLayers.repairedSegments.push(...nationalEndpointRepair.added);
const expressEndpointRepair = repairDanglingTransportEndpoints(expressways, "expressway", expresswayCorridorCost, [...externalExpressways, ...nationalRoads], transportFields.expresswayPotential, {
- maxAdded: 3,
- maxTargetDistance: 78,
- maxPathLength: 128,
+ maxAdded: 2,
+ maxTargetDistance: 68,
+ maxPathLength: 110,
curvePenalty: 0.13,
terrainFlowBias: 0.09,
surfaceGrain: 0.006,
@@ -1955,26 +1587,27 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
})
.filter((p) => p.score > 0.06 && trunkInfluence[indexOf(p.x, p.y)] < 0.34)
.sort((a, b) => b.score - a.score)
- .slice(0, 150);
- const localPenalty = new Float32Array(SIZE);
+ .slice(0, 92);
+ const localPenaltyAccumulator = createIncrementalPathInfluence([], 4, { sea });
+ const localPenalty = localPenaltyAccumulator.field;
const paths = [];
const served = [];
for (const start of candidates) {
- if (paths.length >= 115) break;
+ if (paths.length >= 72) break;
if (distanceToNearest(served, start.x, start.y) < 4.5) continue;
let path = traceCorridorByCost(
start,
(x, y, i) => trunkInfluence[i] > 0.18 || (paths.length > 6 && localPenalty[i] > 0.045),
transportFields.local,
localPenalty,
- { curvePenalty: 0.08, penaltyStrength: 1.00, minGoalDistance: 5, regionId: start.regionId, maxExpanded: SIZE }
+ { curvePenalty: 0.08, penaltyStrength: 1.00, minGoalDistance: 5, regionId: start.regionId, maxExpanded: Math.floor(SIZE * 0.56) }
);
if (path.length < 4 || path.length > 86) continue;
if (!localRouteAcceptableStrict(path, { maxLength: 72 })) continue;
if (!transportRouteAcceptable(path, "local", transportFields.localPotential, localPenalty, { minLength: 4, maxLength: 86, maxHighElevationShare: 0.34, maxSteepShare: 0.50 })) continue;
paths.push(path);
served.push(start);
- addCorridorInfluencePenalty(localPenalty, path, 4, 0.22);
+ localPenaltyAccumulator.add(path, 0.22, 4);
}
return paths;
}
@@ -2001,9 +1634,9 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
targetPredicate || ((x, y, i) => accessInfluence[i] > 0.16 || localPenalty[i] > 0.075),
transportFields.local,
localPenalty,
- { curvePenalty: 0.020, penaltyStrength: 0.90, minGoalDistance: 3, regionId: start.regionId, maxExpanded: Math.floor(SIZE * 0.72), terrainFlowBias: 0.26, surfaceGrain: 0.042 }
+ { curvePenalty: 0.020, penaltyStrength: 0.90, minGoalDistance: 3, regionId: start.regionId, maxExpanded: Math.floor(SIZE * 0.50), terrainFlowBias: 0.26, surfaceGrain: 0.042 }
);
- path = relaxRouteToTerrain(path, transportFields.local, { radius: 2, lineWeight: 0.25, grain: 0.048, iterations: 1 });
+ if (path.length <= 48) path = relaxRouteToTerrain(path, transportFields.local, { radius: 2, lineWeight: 0.25, grain: 0.048, iterations: 1 });
const strictMaxLength = Math.min(maxLength, 74);
const ok = path.length >= 4 && path.length <= maxLength && localRouteAcceptableStrict(path, { maxLength: strictMaxLength }) && transportRouteAcceptable(path, "local", transportFields.localPotential, localPenalty, { minLength: 4, maxLength: strictMaxLength, maxHighElevationShare: 0.34, maxSteepShare: 0.50 });
transportDebugLayers.unservedSettlements.push({ x: start.x, y: start.y, kind: start.kind || "Unserved Settlement", mode: debugMode, repaired: ok });
@@ -2013,15 +1646,15 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
added++;
transportDebugLayers.repairedSegments.push({ mode: "local", path, from, to });
addCorridorInfluencePenalty(localPenalty, path, 4, 0.18);
- if (accessInfluence) addCorridorInfluencePenalty(accessInfluence, path, 5, 0.22);
+ if (accessInfluence) markPathInfluence(accessInfluence, path, 5, 0.22);
}
return added;
}
minorRoads.push(...generateLocalRoadsForUnservedSettlements());
const localEndpointRepair = repairDanglingTransportEndpoints(minorRoads, "local", transportFields.local, [...nationalRoads, ...externalRoads, ...railways], transportFields.localPotential, {
- maxAdded: 36,
- maxTargetDistance: 34,
+ maxAdded: 22,
+ maxTargetDistance: 30,
curvePenalty: 0.055,
terrainFlowBias: 0.26,
surfaceGrain: 0.048,
@@ -2045,17 +1678,8 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
});
transportDebugLayers.parallelPruning.push(localParallelPruning);
transportDebugLayers.localSanitizationInitial = sanitizeLocalRoads();
- function sampledNetworkCells(paths, step = 2) {
- 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;
- }
+ const sampledNetworkCells = (paths, step = 2) => sampledNetworkCellsBase(paths, step, sea);
+
const nationalForIc = sampledNetworkCells([...nationalRoads, ...externalRoads], 2).map((q) => ({ ...q, roadClass: "national" }));
const generalRoadForIc = sampledNetworkCells([...nationalRoads, ...externalRoads, ...minorRoads], 2).map((q, idx) => ({ ...q, roadClass: idx < nationalForIc.length ? "national" : "local" }));
const nationalRoadIcIndex = makeSpatialIndex(nationalForIc, 16);
@@ -2077,46 +1701,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
return best ? { ...best, d: bestD } : null;
}
- 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;
- }
-
- 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 };
- }
-
- function meanFieldAround(field, x, y, radius = 8) {
- let sum = 0, 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, 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;
- }
+ const meanFieldAround = (field, x, y, radius = 8) => meanFieldAroundBase(field, x, y, radius, sea);
const icDemandCache = new Map();
function icDemandAt(p) {
@@ -2228,6 +1813,10 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
const cum = pathCumulativeLengths(path);
const total = cum[cum.length - 1] || 0;
if (total < absoluteMinGap * 1.6) continue;
+ const firstEndpoint = { x: path[0][0], y: path[0][1], s: 0, terminal: true };
+ const lastEndpoint = { x: path[path.length - 1][0], y: path[path.length - 1][1], s: total, terminal: true };
+ addInterchange(firstEndpoint, nearestRoadForIc(firstEndpoint, 38.0 + icDemandAt(firstEndpoint) * 14.0, true));
+ addInterchange(lastEndpoint, nearestRoadForIc(lastEndpoint, 38.0 + icDemandAt(lastEndpoint) * 14.0, true));
let lastS = Math.min(8, Math.max(4, total * 0.10));
while (lastS < total - absoluteMinGap) {
const current = pointAtPathDistance(path, cum, lastS) || { x: path[0][0], y: path[0][1] };
@@ -2273,33 +1862,11 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
return transportFields.local;
}
function splitPathToValidCells(path, costField, minCells = 2) {
- const chunks = [];
- let cur = [];
- function valid(x, y) {
+ return splitPathToValidCellsBase(path, (x, y) => {
if (!inside(x, y)) return false;
const i = indexOf(x, y);
return !sea[i] && !highAltitudeRoadClosed(i) && costField[i] < INF;
- }
- function pushPoint(x, y) {
- if (!valid(x, y)) {
- if (cur.length >= minCells) chunks.push(cur);
- cur = [];
- return;
- }
- if (!cur.length || cur[cur.length - 1][0] !== x || cur[cur.length - 1][1] !== y) cur.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 (cur.length >= minCells) chunks.push(cur);
- return chunks;
+ }, minCells);
}
function normalizeRoadGroups() {
const groups = roadGroups();
diff --git a/mapTransportGraph.js b/mapTransportGraph.js
new file mode 100644
index 0000000..900ef10
--- /dev/null
+++ b/mapTransportGraph.js
@@ -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 : [];
+}
diff --git a/mapTransportOD.js b/mapTransportOD.js
index 30d1002..0094fe0 100644
--- a/mapTransportOD.js
+++ b/mapTransportOD.js
@@ -40,7 +40,9 @@ export function buildUnifiedRailODNetwork(ctx) {
transportRouteAcceptable,
pruneParallelSameMode,
cachedInfluenceFromPaths,
+ speedTolerance = 1,
} = ctx;
+ const speedScale = clamp(speedTolerance, 0.75, 1);
const railways = [];
const branchRailways = [];
@@ -220,8 +222,8 @@ export function buildUnifiedRailODNetwork(ctx) {
relaxRadius: 1,
relaxLineWeight: branch ? 0.44 : 0.50,
snapRadius: branch ? 2.4 : 2.8,
- searchPad: Math.ceil(Math.max(22, Math.min(68, pair.d * 0.48))),
- maxPathLength: pair.d * (branch ? 2.28 : 2.48) + (branch ? 18 : 36),
+ searchPad: Math.ceil(Math.max(18, Math.min(56, pair.d * 0.40))),
+ maxPathLength: pair.d * (branch ? 2.12 : 2.30) + (branch ? 16 : 30),
maxSeaRun: 1,
maxSeaShare: 0.006,
});
@@ -260,18 +262,18 @@ export function buildUnifiedRailODNetwork(ctx) {
.filter(Boolean);
const anchorNodes = geographicUrbanAnchors
.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 }))
.filter(Boolean);
let trunkNodes = dedupeNodes([...regionalCityNodes, ...secondaryCityNodes, ...portNodes, ...externalNodes, ...anchorNodes], 7.5)
.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) {
trunkNodes = dedupeNodes([
...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 })),
- ], 7).slice(0, 18);
+ ], 7).slice(0, Math.max(14, Math.round(18 * speedScale)));
}
debug.nodeCounts = {
regionalCityNodes: regionalCityNodes.length,
@@ -300,7 +302,7 @@ export function buildUnifiedRailODNetwork(ctx) {
const uf = makeUnionFind(trunkNodes, keyOf);
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;
for (const pair of trunkPairs) {
if (connectedEdges >= maxTrunk) break;
@@ -322,7 +324,7 @@ export function buildUnifiedRailODNetwork(ctx) {
let loopAdded = 0;
const trunkInfluenceBeforeLoops = cachedInfluenceFromPaths(railways, 5, "rail-od:trunk-before-loops");
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 bi = indexOf(pair.b.x, pair.b.y);
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
.filter((p) => p.portClass === "regional" || (p.population || 0) >= 10000)
.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 = [];
for (const path of railways) {
@@ -361,7 +363,7 @@ export function buildUnifiedRailODNetwork(ctx) {
let branchAdded = 0;
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);
if ((railInfluence[ni] || 0) > 0.34) continue;
const options = trunkTargets
@@ -374,7 +376,7 @@ export function buildUnifiedRailODNetwork(ctx) {
})
.filter(Boolean)
.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; }
const path = routeRailPair(pair, penalty, true);
if (!path.length) continue;
diff --git a/mapTransportUtils.js b/mapTransportUtils.js
index 870beaf..580c49f 100644
--- a/mapTransportUtils.js
+++ b/mapTransportUtils.js
@@ -52,6 +52,149 @@ export function pathAverageField(path, field) {
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 = {}) {
if (!path?.length) return { length: 0, compactness: Infinity, highElevationShare: 1, steepShare: 1, waterShare: 1, avgPotential: 0, avgPenalty: 0 };
const length = pathLengthCells(path);
diff --git a/names.js b/names.js
index 2d8967e..3582bed 100644
--- a/names.js
+++ b/names.js
@@ -15,13 +15,13 @@ export const NAME_KANJI_POOLS = {
"霞", "朝", "日", "天",
"土", "砂", "石", "岩",
"卯", "辰",
- "串","駒", "来", "武", "箱", "羽", "鳴", "横", "永", "早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", "徳", "栄"
+ "串","駒", "来", "武", "箱", "羽", "鳴", "横", "永", "早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", "徳", "栄"
],
inlandTerrain: [
"山", "野", "野", "沢",
"森", "林", "岡", "丘", "坂",
- "峰", "峠", "嶺", "尾", "平", "坪", "延",
+ "峰", "嶺", "尾", "平", "坪", "延",
"窪", "久", "迫", "久保", "玖保", "佐古", "作古",
"塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", "植", "生",
"郷", "里",
@@ -85,7 +85,7 @@ export const NAME_KANJI_POOLS = {
],
archaicSuffixes: [
- "井", "羽", "江", "恵", "尾",
+ "伊", "衣", "井", "羽", "江", "恵", "尾",
"賀", "鹿", "喜", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子",
"佐", "紗", "左", "志", "路", "師", "須", "瀬", "曽", "蘇", "総",
"多", "太", "知", "津", "豆", "土", "登",
@@ -107,7 +107,7 @@ export const NAME_KANJI_POOLS = {
settlementWords: [
"里", "郷", "村", "町", "宿", "垣", "坪", "軒", "惣",
- "庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂",
+ "庄", "ノ庄", "之庄", "宮", "ノ宮", "之宮", "寺", "社", "堂",
"城", "館", "屋", "家", "所",
"市", "場", "関", "地蔵", "辻", "角", "堰", "通", "路", "道", "筋",
]
diff --git a/rectContext.js b/rectContext.js
new file mode 100644
index 0000000..c282a5a
--- /dev/null
+++ b/rectContext.js
@@ -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;
+}
diff --git a/renderer.js b/renderer.js
index 276fcae..e387f7c 100644
--- a/renderer.js
+++ b/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 pathVectorCache = new WeakMap();
const coastlineCache = new WeakMap();
+const rasterBorderCache = new WeakMap();
const baseImageCache = new WeakMap();
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) {
return `${p[0]},${p[1]}`;
}
@@ -183,16 +208,18 @@ function getCoastlineSegments(map) {
if (cached) return cached;
const segments = [];
- for (let y = 0; y < MAP_H; y++) {
- for (let x = 0; x < MAP_W; x++) {
- const i = indexOf(x, y);
+ const 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 a = Boolean(map.sea[i]);
- if (x + 1 < MAP_W) {
- const b = Boolean(map.sea[indexOf(x + 1, y)]);
+ if (x + 1 < w) {
+ const b = Boolean(map.sea[cellIndex(map, x + 1, y)]);
if (a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
}
- if (y + 1 < MAP_H) {
- const b = Boolean(map.sea[indexOf(x, y + 1)]);
+ if (y + 1 < h) {
+ const b = Boolean(map.sea[cellIndex(map, x, y + 1)]);
if (a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
}
}
@@ -218,14 +245,6 @@ function vectorPath(path) {
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) {
if (!points || points.length < 2) return;
ctx.moveTo(points[0][0], points[0][1]);
@@ -252,10 +271,10 @@ function drawVectorSegments(ctx, segments, color, width, dashed = false, vectorO
ctx.restore();
}
-function sampleCellIndex(fx, fy) {
- const x = Math.max(0, Math.min(MAP_W - 1, Math.floor(fx)));
- const y = Math.max(0, Math.min(MAP_H - 1, Math.floor(fy)));
- return indexOf(x, y);
+function sampleCellIndex(map, fx, fy) {
+ const x = Math.max(0, Math.min(mapWidth(map) - 1, Math.floor(fx)));
+ const y = Math.max(0, Math.min(mapHeight(map) - 1, Math.floor(fy)));
+ return cellIndex(map, x, y);
}
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],
];
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);
}
@@ -301,20 +320,21 @@ function blendOutside(color, isInside) {
];
}
-function fieldSample(field, fx, fy) {
- const sx = Math.max(0, Math.min(MAP_W - 1, fx));
- const sy = Math.max(0, Math.min(MAP_H - 1, fy));
+function fieldSample(map, field, fx, fy) {
+ if (!field) return 0;
+ 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 y0 = Math.floor(sy);
- const x1 = Math.max(0, Math.min(MAP_W - 1, x0 + 1));
- const y1 = Math.max(0, Math.min(MAP_H - 1, y0 + 1));
+ const x1 = Math.max(0, Math.min(mapWidth(map) - 1, x0 + 1));
+ const y1 = Math.max(0, Math.min(mapHeight(map) - 1, y0 + 1));
const tx = sx - x0;
const ty = sy - y0;
- const a = field[indexOf(x0, y0)];
- const b = field[indexOf(x1, y0)];
- const c = field[indexOf(x0, y1)];
- const d = field[indexOf(x1, y1)];
+ const a = field[cellIndex(map, x0, y0)] || 0;
+ const b = field[cellIndex(map, x1, y0)] || 0;
+ const c = field[cellIndex(map, x0, y1)] || 0;
+ const d = field[cellIndex(map, x1, y1)] || 0;
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) {
- const i = sampleCellIndex(fx, fy);
+ const i = sampleCellIndex(map, fx, fy);
const isInside = Boolean(map.prefectureMask[i]);
let color;
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)];
let landColor;
if (mode === "development") {
const dCity = distToNearest(map.modernCities, fx, fy);
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;
landColor = [
Math.round(base + density * 20),
@@ -360,7 +380,7 @@ function terrainColorContinuous(map, fx, fy, mode) {
} else {
// 地形の基底色は標高のみに従わせる。
// 谷や微地形の見え方は陰影側で制御し、谷底だけが不自然に茶色化しないようにする。
- const e = fieldSample(map.elevation, fx, fy);
+ const e = fieldSample(map, map.elevation, fx, fy);
landColor = interpolateColorStops(clamp(e), [
[0.20, [231, 236, 223]],
[0.30, [223, 231, 214]],
@@ -386,11 +406,11 @@ function terrainColorContinuous(map, fx, fy, mode) {
function terrainShadeContinuous(map, fx, fy) {
const step = 0.50;
- const eC = fieldSample(map.elevation, fx, fy);
- const eL = fieldSample(map.elevation, fx - step, fy);
- const eR = fieldSample(map.elevation, fx + step, fy);
- const eU = fieldSample(map.elevation, fx, fy - step);
- const eD = fieldSample(map.elevation, fx, fy + step);
+ const eC = fieldSample(map, map.elevation, fx, fy);
+ const eL = fieldSample(map, map.elevation, fx - step, fy);
+ const eR = fieldSample(map, map.elevation, fx + step, fy);
+ const eU = fieldSample(map, map.elevation, fx, fy - step);
+ const eD = fieldSample(map, map.elevation, fx, fy + step);
// x は東向き, y は南向き。法線は (-dz/dx, -dz/dy, 1)。
// 描画では地形生成上の差分をやや誇張し、粗いDEMでも山腹の起伏を読ませる。
@@ -406,14 +426,14 @@ function terrainShadeContinuous(map, fx, fy) {
const lz = 0.7071067811865476;
const hill = clamp((nx * lx + ny * ly + nz * lz) / nLen * 0.58 + 0.48);
- const slope = map.slope ? fieldSample(map.slope, fx, fy) : 0;
- const valley = map.valleyField ? fieldSample(map.valleyField, fx, fy) : 0;
- const ravine = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy) : 0;
- const tex = map.surfaceTextureField ? fieldSample(map.surfaceTextureField, fx, fy) : 0;
- const rvL = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx - 0.90, fy) : 0;
- const rvR = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx + 0.90, fy) : 0;
- const rvU = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy - 0.90) : 0;
- const rvD = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy + 0.90) : 0;
+ const slope = map.slope ? fieldSample(map, map.slope, fx, fy) : 0;
+ const valley = map.valleyField ? fieldSample(map, map.valleyField, fx, fy) : 0;
+ const ravine = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx, fy) : 0;
+ const tex = map.surfaceTextureField ? fieldSample(map, map.surfaceTextureField, fx, fy) : 0;
+ const rvL = map.visibleRavineField ? fieldSample(map, 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, 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 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) {
- const i = indexOf(x, y);
+ const i = cellIndex(map, x, y);
let color;
if (map.sea[i]) {
@@ -459,35 +479,39 @@ function discreteColor(map, x, y, mode) {
return blendOutside(color, Boolean(map.prefectureMask[i]));
}
-function baseCacheKey(mode, continuousTerrain) {
+function baseCacheKey(mode, continuousTerrain, renderScale = 1) {
const continuousModes = ["terrain", "development", "all"];
+ const scaleKey = Math.round((renderScale || 1) * 20) / 20;
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);
if (!cache) {
cache = new Map();
baseImageCache.set(map, cache);
}
- const key = baseCacheKey(mode, continuousTerrain);
- let image = cache.get(key);
- if (image) return image;
+ const key = baseCacheKey(mode, continuousTerrain, renderScale);
+ let canvas = cache.get(key);
+ if (canvas) return canvas;
- const width = MAP_W * CELL_SIZE;
- const height = MAP_H * CELL_SIZE;
+ const sourceWidth = mapPixelWidth(map);
+ 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 continuousModes = ["terrain", "development", "all"];
if (continuousTerrain && continuousModes.includes(mode)) {
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++) {
- 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 shade = terrainShadeContinuous(map, fx, fy);
@@ -499,8 +523,10 @@ function getCachedBaseImage(ctx, map, mode, continuousTerrain) {
}
}
} else {
- for (let y = 0; y < MAP_H; y++) {
- for (let x = 0; x < MAP_W; x++) {
+ const w = mapWidth(map);
+ 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);
for (let dy = 0; dy < CELL_SIZE; dy++) {
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);
- return img;
+ return canvas;
}
-function drawBase(ctx, map, mode, continuousTerrain) {
- ctx.putImageData(getCachedBaseImage(ctx, map, mode, continuousTerrain), 0, 0);
+function drawBase(ctx, map, mode, continuousTerrain, renderScale = 1) {
+ // 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) {
@@ -531,8 +597,8 @@ function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) {
for (let k = 0; k < path.length - 1; k++) {
const [x1, y1] = path[k];
const [x2, y2] = path[k + 1];
- const i1 = indexOf(x1, y1);
- const i2 = indexOf(x2, y2);
+ const i1 = cellIndex(map, Math.round(x1), Math.round(y1));
+ 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);
ctx.strokeStyle = color;
ctx.globalAlpha = alpha;
@@ -545,8 +611,8 @@ function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) {
ctx.restore();
}
-function drawPath(ctx, path, color, width, dashed = false, mode = "default") {
- const points = vectorPathMode(path, mode);
+function drawPath(ctx, path, color, width, dashed = false) {
+ const points = vectorPath(path);
if (points.length < 2) return;
ctx.save();
ctx.lineCap = "round";
@@ -566,7 +632,7 @@ function landOnlySubpaths(map, path, minCells = 2) {
let cur = [];
for (const p of path) {
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) {
cur.push(p);
} else if (cur.length >= minCells) {
@@ -580,97 +646,8 @@ function landOnlySubpaths(map, path, minCells = 2) {
return chunks;
}
-function drawLandPath(ctx, map, path, color, width, dashed = false, minCells = 2, mode = "default") {
- for (const chunk of landOnlySubpaths(map, path, minCells)) drawPath(ctx, chunk, color, width, dashed, mode);
-}
-
-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");
+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);
}
// 魚の骨(私鉄記号)スタイルを描画するための専用関数
@@ -757,9 +734,11 @@ function drawUrbanAreas(ctx, map, mode) {
const cbdColor = "rgba(215, 175, 172, 0.84)";
ctx.save();
- for (let y = 0; y < MAP_H; y++) {
- for (let x = 0; x < MAP_W; x++) {
- const i = indexOf(x, y);
+ 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 areaMask = map.humanRegionMask || map.prefectureMask;
if (areaMask && !areaMask[i]) continue;
const lu = map.landuse[i];
@@ -781,9 +760,11 @@ function drawUrbanAreas(ctx, map, mode) {
function drawDebugCells(ctx, map, field, color) {
if (!field) return;
ctx.save();
- for (let y = 0; y < MAP_H; y++) {
- for (let x = 0; x < MAP_W; x++) {
- const i = indexOf(x, y);
+ 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 debugMask = map.humanRegionMask || map.prefectureMask;
if (!debugMask[i] || map.sea[i]) continue;
const raw = field[i] || 0;
@@ -841,9 +822,11 @@ function drawPrefectureRegionFill(ctx, map, mode) {
const alpha = mode === "borders-debug" ? 0.34 : 0.18;
ctx.save();
ctx.globalAlpha = alpha;
- for (let y = 0; y < MAP_H; y++) {
- for (let x = 0; x < MAP_W; x++) {
- const i = indexOf(x, y);
+ 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 = ids[i];
if (map.sea[i] || id < 0) continue;
const [r, g, b] = prefectureRegionColor(id);
@@ -903,7 +886,7 @@ function labelWithCollision(ctx, p, occupied) {
const x = baseX + ox;
const y = baseY + oy;
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));
if (!overlaps.length) {
ctx.lineJoin = "round";
@@ -946,11 +929,11 @@ function drawLabels(ctx, points, limit = Infinity, occupied = null) {
return used;
}
-function drawScaleBar(ctx) {
- const kmPerCell = 1;
+function drawScaleBar(ctx, cellScreenSize = CELL_SIZE) {
+ const kmPerCell = 0.5;
const targetKm = 25;
const lengthCells = Math.max(8, Math.round(targetKm / kmPerCell));
- const lengthPx = lengthCells * CELL_SIZE;
+ const lengthPx = lengthCells * cellScreenSize;
const margin = 14;
const x = margin;
const y = margin + 18;
@@ -989,14 +972,34 @@ export function drawMap(canvas, map, options) {
const mode = options.mode || "all";
const showFeatures = options.showFeatures !== false;
const showLabels = options.showLabels !== false;
-
- const width = MAP_W * CELL_SIZE;
- const height = MAP_H * CELL_SIZE;
- canvas.width = width;
- canvas.height = height;
+ const continuousTerrain = options.continuousTerrain !== false;
+ const outputWidth = MAP_W * CELL_SIZE;
+ const outputHeight = MAP_H * CELL_SIZE;
+ const sourceWidth = mapPixelWidth(map);
+ 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
- drawBase(ctx, map, mode, true);
+ drawBase(ctx, map, mode, continuousTerrain, terrainRenderScale);
drawUrbanAreas(ctx, map, mode);
const coastSegments = getCoastlineSegments(map);
drawVectorSegments(ctx, coastSegments, "rgba(120, 175, 210, 0.22)", 2.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
@@ -1013,7 +1016,7 @@ export function drawMap(canvas, map, options) {
let tailCount = 0;
for (let k = 0; k < path.length; 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;
peak = Math.max(peak, strength);
if (k >= tailStart) {
@@ -1064,9 +1067,11 @@ export function drawMap(canvas, map, options) {
const showPrefectureRegions = ["all", "admin", "borders-debug"].includes(mode);
if (showPrefectureRegions) drawPrefectureRegionFill(ctx, map, mode);
- if (showAdmin && map.adminBorders) {
- drawVectorSegments(ctx, map.adminBorders, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
- drawVectorSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
+ const adminBorderSegments = map.adminId ? getRasterBorderSegments(map, "adminId") : map.adminBorders;
+ const prefectureBorderSegments = map.prefectureRegionId ? getRasterBorderSegments(map, "prefectureRegionId") : map.regionalPrefectureBorders;
+ 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") {
// 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 (showPrefectureRegions && map.regionalPrefectureBorders) {
- drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(255, 255, 255, 0.90)", 4.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
- drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(82, 60, 102, 0.96)", 1.8, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
+ if (showPrefectureRegions && prefectureBorderSegments?.length) {
+ 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, prefectureBorderSegments, "rgba(82, 60, 102, 0.96)", 1.8, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
}
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(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.
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);
}
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.externalExpressways) 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) drawPath(ctx, path, "rgba(105, 125, 105, 0.78)", 4.6);
}
// 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);
}
if (showRoads) {
- for (const path of generalRoadPaths) { drawBridgeOverlay(ctx, map, path, 1.55, "road"); }
- for (const path of map.nationalRoads) { drawBridgeOverlay(ctx, map, path, 2.0, "road"); }
- 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"); }
+ for (const path of map.expressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4);
+ for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4);
}
// 6. Icons & Labels
@@ -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)");
}
+ 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) {
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) {
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)");
@@ -1175,37 +1187,28 @@ export function drawMap(canvas, map, options) {
}
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") {
- drawLabels(ctx, [...prefectureLabels, ...(map.adminCenters || [])], Infinity);
- drawScaleBar(ctx);
+ const municipalLabels = (map.adminCenters || [])
+ .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;
}
if (mode === "borders-debug") {
- drawLabels(ctx, [...prefectureLabels, ...(map.adminCenters || [])], Infinity);
- drawScaleBar(ctx);
+ drawLabels(ctx, prefectureLabels, Infinity);
+ finish();
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 = [
...prefectureLabels,
...map.modernCities,
- ...(map.ports || []).map((p) => ({
- ...p,
- labelPriorityBase: p.portClass === "major" ? 170 : p.portClass === "regional" ? 120 : p.portClass === "fishing" ? 95 : 85,
- })),
- ...(map.logisticsParks || []).map((p) => ({ ...p, labelPriorityBase: 60 })),
+ ...map.ports,
...(map.satelliteCities || []),
- ...allLayerTowns.map((p) => ({ ...p, forceAllLayerTownLabel: true, labelPriorityBase: 80 })),
- ].filter((p) => p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000);
- drawLabels(ctx, important, mode === "all" ? 95 : 60);
+ ...settlementIconLabelPoints,
+ ].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" || mode === "history" ? 78 : 60);
}
- drawScaleBar(ctx);
+ finish();
}
diff --git a/styles.css b/styles.css
index e42808d..7ffa8ee 100644
--- a/styles.css
+++ b/styles.css
@@ -1,6 +1,6 @@
*{box-sizing:border-box}
body{margin:0;background:#f0f2f5;color:#2c2c2c;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
-button,input{font:inherit}
+button,input,select{font:inherit}
code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
.app{min-height:100vh;padding:16px}
.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}
.card{padding:14px}
.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: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}
@@ -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}
.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)}
.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}
.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}
diff --git a/test.js b/test.js
index fe3c036..2a54ed6 100644
--- a/test.js
+++ b/test.js
@@ -15,7 +15,7 @@ const result = document.getElementById("result");
const logLines = [];
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("./mapGenerator.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("./mapPipeline.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()),
]);
@@ -675,6 +678,23 @@ try {
const removedContextSuffixKey = "context" + "Suffixes";
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(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.sea.length === size, "sea length matches map size");
@@ -777,6 +797,14 @@ try {
assert(map.externalGateways.length > 0, "external gateways exist");
assert(map.minorRoads.length > 0, "minor roads exist");
assert(map.adminCenters.length >= 18, "municipality center count is sufficiently large");
+ 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.centerValidRatio >= 0.95, "municipality centers remain on valid assigned land cells");
assert(adminMetrics.maxComponents <= 4, "municipal topology repair prevents excessive disconnected fragments");
diff --git a/worldMap.js b/worldMap.js
new file mode 100644
index 0000000..04ac910
--- /dev/null
+++ b/worldMap.js
@@ -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);
+}
diff --git a/worldViewport.js b/worldViewport.js
new file mode 100644
index 0000000..448a1d9
--- /dev/null
+++ b/worldViewport.js
@@ -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;
+}