Compare commits

...

7 commits

Author SHA1 Message Date
27ceb6568a hm 2026-05-29 15:49:09 +09:00
f2d0306d96 hmm 2026-05-29 14:31:42 +09:00
112e6bf86b hmm? 2026-05-28 23:51:55 +09:00
ea0067dc0a infinity expantion 2026-05-28 19:37:28 +09:00
c523bf4380 stable 2026-05-28 16:45:00 +09:00
860471f805 stable 2026-05-28 15:48:42 +09:00
d2e7a80e72 splittintting 2026-05-28 02:47:33 +09:00
28 changed files with 8127 additions and 2017 deletions

View file

@ -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);

791
app.js
View file

@ -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 = `<polygon points="${pts}" />`;
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 = [
`<strong>${entity ? `${entity.name} / ${entity.kind}` : `${x}, ${y}`}</strong>`,
`Prefecture: ${prefectureNameForCell(state.map, i)}`,
`Admin: ${adminName(state.map, hoveredAdminId)}`,
`<strong>${entityTitle}</strong>`,
`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("<br>");
@ -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();
}

View file

@ -13,12 +13,14 @@
<header class="header">
<div>
<h1>Prefecture Map Generator v17</h1>
<p>Terrain, municipalities, transport, land use, and hover inspection in one generated map.</p>
<p>Terrain, municipalities, transport, viewport panning, patch terrain generation, and hover inspection in one generated map.</p>
</div>
</header>
<div class="canvas-shell">
<canvas id="mapCanvas" class="map-canvas"></canvas>
<svg id="mapSelectionSvg" class="map-selection-svg" aria-hidden="true"></svg>
<div id="mapSelection" class="map-selection" aria-hidden="true"></div>
<div id="generationProgress" class="generation-progress hidden" role="status" aria-live="polite">
<div class="progress-title">Generating map...</div>
<div id="generationProgressStage" class="progress-stage">Preparing</div>
@ -32,9 +34,43 @@
<section class="card">
<label class="label" for="seed">Seed</label>
<input id="seed" class="input" value="114514" />
<label class="label inline-label" for="generationType">Generation Type</label>
<select id="generationType" class="input">
<option value="auto">Auto</option>
<option value="tohoku_spine">Tohoku spine</option>
<option value="chubu_mountain">Chubu mountain</option>
<option value="setouchi_inland_sea">Setouchi inland sea</option>
<option value="oceanic_archipelago">Oceanic archipelago</option>
<option value="kanto_alluvial">Kanto alluvial plain</option>
<option value="mixed_archipelago">Mixed archipelago</option>
</select>
<button id="randomSeed" type="button" class="primary-button">Generate Random Seed</button>
</section>
<section class="card">
<div class="card-title">Patch Generation</div>
<label class="label" for="patchTerrainType">Patch Terrain Type</label>
<select id="patchTerrainType" class="input">
<option value="auto">Auto</option>
<option value="tohoku_spine">Tohoku spine</option>
<option value="chubu_mountain">Chubu mountain</option>
<option value="setouchi_inland_sea">Setouchi inland sea</option>
<option value="oceanic_archipelago">Oceanic archipelago</option>
<option value="kanto_alluvial">Kanto alluvial plain</option>
<option value="mixed_archipelago">Mixed archipelago</option>
</select>
<div class="patch-variant-row">
<label class="label patch-variant-label" for="patchVariant">Patch Variant</label>
<input id="patchVariant" class="input patch-variant-input" type="number" min="0" step="1" value="0" />
</div>
<div class="patch-button-row">
<button id="generatePatch" type="button" class="primary-button" disabled>Generate Selected Area</button>
<button id="alternativePatch" type="button" class="secondary-button" disabled>Alternative</button>
</div>
<p id="patchStatus" class="patch-status">Right-drag to lasso a freeform patch area.</p>
</section>
<section class="card">
<div class="card-title">Display Layers</div>
<div id="modeGrid" class="mode-grid"></div>
@ -77,7 +113,7 @@
<section class="card legend">
<div class="card-title">Notes</div>
<p>Open <code>index.html</code> with Live Server. Open <code>test.html</code> to run browser tests.</p>
<p>Open <code>index.html</code> with Live Server. Left-drag pans the viewport; right-drag draws a freeform regeneration area; use Patch Generation to write terrain into that area.</p>
<p>Add preferred reusable place names in <code>CUSTOM_NAME_LIST</code> inside <code>names.js</code>.</p>
</section>
</aside>

View file

@ -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 = [];

327
mapFeatureContext.js Normal file
View file

@ -0,0 +1,327 @@
import { INF, MAP_H, MAP_W, SIZE, clamp, fbm, hash2, indexOf, inside, pickEntities, valueNoise } from "./mapUtils.js";
export function buildFeatureContext(seed, terrain) {
const {
elevation,
slope,
sea,
river,
floodplain,
plain,
agriculture,
ridgeField,
valleyField,
basinField,
coastalLowland,
arcSpineField,
branchRidgeField,
depositionalLowland,
alluvialFanField,
deltaField,
portSuitability,
prefectureMask,
prefectureRegionId,
naturalBarrierScore,
} = terrain;
const geography = terrain.geography || {};
const geoHabitability = geography.habitability || null;
const geoAccessibility = geography.accessibility || null;
const geoNaturalCentrality = geography.naturalCentrality || geography.centrality || null;
const geoLowlandCapacity = geography.lowlandCapacity || null;
const geoValleyAccess = geography.valleyAccess || null;
const geoCoastalAccess = geography.coastalAccess || null;
const geoBarrier = geography.geographicBarrier || naturalBarrierScore || null;
const geoCorridorSuitability = geography.corridorSuitability || null;
function fieldValue(field, i, fallback = 0) {
const v = field?.[i];
return Number.isFinite(v) ? v : fallback;
}
function regionIdAt(x, y) {
if (!inside(x, y)) return -1;
const i = indexOf(x, y);
if (sea[i]) return -1;
if (prefectureMask?.[i]) return 0;
if (!prefectureRegionId) return 0;
const id = prefectureRegionId?.[i];
return id !== undefined && id >= 0 ? id : -1;
}
function inFocusedPrefecture(p) {
return Boolean(p && inside(p.x, p.y) && prefectureMask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]);
}
function localConfluenceScore(x, y) {
let arms = 0;
let strong = 0;
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) {
const nx = x + dx;
const ny = y + dy;
if (!inside(nx, ny)) continue;
const rv = river[indexOf(nx, ny)];
if (rv > 0.18) arms++;
if (rv > 0.34) strong++;
}
return clamp((arms >= 3 ? 0.22 : arms === 2 ? 0.09 : 0) + strong * 0.04);
}
// --- 1. Human context: one full raster pass -----------------------------
const developable = new Float32Array(SIZE);
const ruralSuitability = new Float32Array(SIZE);
const townSuitability = new Float32Array(SIZE);
const valleySettlement = new Float32Array(SIZE);
const coastalSettlement = new Float32Array(SIZE);
const confluenceField = new Float32Array(SIZE);
const barrierCost = new Float32Array(SIZE);
const corridorCost = new Float32Array(SIZE);
const settlementCluster = new Float32Array(SIZE);
const settlementScore = new Float32Array(SIZE);
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (sea[i]) {
barrierCost[i] = INF;
corridorCost[i] = INF;
continue;
}
const naturalBarrier = naturalBarrierScore?.[i] || 0;
const depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.62 + (deltaField?.[i] || 0) * 0.90;
const highPenalty = Math.max(0, elevation[i] - 0.56);
const lowSlope = clamp(1 - slope[i] * 2.3);
const confluence = x > 0 && y > 0 && x < MAP_W - 1 && y < MAP_H - 1 ? localConfluenceScore(x, y) : 0;
const openPlainPotential = clamp(plain[i] * 0.68 + agriculture[i] * 0.54 + basinField[i] * 0.30 + lowSlope * 0.22 - river[i] * 0.18 - valleyField[i] * 0.08 - ridgeField[i] * 0.22 - slope[i] * 0.26);
const spine = (arcSpineField?.[i] || 0) * 0.58 + (branchRidgeField?.[i] || 0) * 0.38;
confluenceField[i] = confluence;
const geoH = fieldValue(geoHabitability, i, 0);
const geoLow = fieldValue(geoLowlandCapacity, i, 0);
const geoValley = fieldValue(geoValleyAccess, i, 0);
const geoCoast = fieldValue(geoCoastalAccess, i, 0);
const geoB = fieldValue(geoBarrier, i, naturalBarrier);
const localDevelopable = clamp(
plain[i] * 0.34 +
agriculture[i] * 0.24 +
basinField[i] * 0.24 +
valleyField[i] * 0.24 +
coastalLowland[i] * 0.18 +
depositional * 0.22 +
lowSlope * 0.10 -
slope[i] * 0.82 -
ridgeField[i] * 0.52 -
spine * 0.24 -
highPenalty * 1.14 -
floodplain[i] * 0.03
);
developable[i] = clamp(localDevelopable * 0.68 + geoH * 0.34 + geoLow * 0.16 - geoB * 0.05);
valleySettlement[i] = clamp((
valleyField[i] * 0.52 +
river[i] * 0.08 +
confluence * 0.38 +
depositional * 0.20 +
basinField[i] * 0.16 +
plain[i] * 0.08 +
lowSlope * 0.12 -
slope[i] * 0.54 -
ridgeField[i] * 0.30 -
spine * 0.16 -
highPenalty * 0.70 -
floodplain[i] * 0.10
) * 0.74 + geoValley * 0.30 + geoH * 0.08 - geoB * 0.04);
coastalSettlement[i] = clamp((
coastalLowland[i] * 0.50 +
(portSuitability?.[i] || 0) * 0.30 +
(deltaField?.[i] || 0) * 0.20 +
plain[i] * 0.10 -
slope[i] * 0.52 -
ridgeField[i] * 0.24 -
spine * 0.12
) * 0.76 + geoCoast * 0.32 + geoH * 0.06 - geoB * 0.04);
const clusterNoise = 0.72 + fbm(x * 0.34 + 13, y * 0.34 - 31, seed + 7001) * 0.46 + valueNoise(x, y, seed + 7002, 8) * 0.16;
settlementCluster[i] = clamp((developable[i] * 0.42 + valleySettlement[i] * 0.12 + coastalSettlement[i] * 0.22 + agriculture[i] * 0.48 + plain[i] * 0.32 + openPlainPotential * 0.46) * clusterNoise);
ruralSuitability[i] = clamp(
agriculture[i] * 0.54 +
developable[i] * 0.30 +
valleySettlement[i] * 0.18 +
coastalSettlement[i] * 0.20 +
openPlainPotential * 0.34 +
settlementCluster[i] * 0.30 -
Math.max(0, elevation[i] - 0.64) * 0.56
);
townSuitability[i] = clamp(
developable[i] * 0.38 +
agriculture[i] * 0.18 +
valleySettlement[i] * 0.16 +
coastalSettlement[i] * 0.30 +
confluence * 0.20 +
basinField[i] * 0.18 +
plain[i] * 0.26 +
openPlainPotential * 0.44 +
settlementCluster[i] * 0.22 -
slope[i] * 0.34 -
ridgeField[i] * 0.17 -
spine * 0.10
);
settlementScore[i] = clamp(
ruralSuitability[i] * 0.48 +
townSuitability[i] * 0.30 +
confluence * 0.08 +
fieldValue(geoHabitability, i, developable[i]) * 0.18 +
fieldValue(geoNaturalCentrality, i, 0) * 0.12 -
fieldValue(geoBarrier, i, 0) * 0.06
);
barrierCost[i] = 1 + slope[i] * 6.4 + ridgeField[i] * 3.2 + spine * 2.2 + highPenalty * 5.8 + river[i] * 0.25 + naturalBarrier * 1.2 - valleyField[i] * 0.55 - plain[i] * 0.30 - coastalLowland[i] * 0.14;
corridorCost[i] = Math.max(0.25, barrierCost[i] - developable[i] * 0.42 - valleySettlement[i] * 0.36 - coastalSettlement[i] * 0.12 + hash2(x, y, seed + 7011) * 0.05);
}
}
// --- region statistics ---------------------------------------------------
const regionStats = new Map();
function ensureRegion(regionId) {
let st = regionStats.get(regionId);
if (!st) {
st = {
id: regionId,
area: 0,
developableCells: 0,
developableSum: 0,
valleyCells: 0,
coastCells: 0,
townCells: 0,
plainCells: 0,
highCentralityCells: 0,
habitabilitySum: 0,
accessibilitySum: 0,
centralitySum: 0,
lowlandCapacitySum: 0,
minX: MAP_W,
minY: MAP_H,
maxX: 0,
maxY: 0,
};
regionStats.set(regionId, st);
}
return st;
}
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (sea[i]) continue;
const regionId = regionIdAt(x, y);
if (regionId < 0) continue;
const st = ensureRegion(regionId);
st.area++;
const gHabit = fieldValue(geoHabitability, i, developable[i]);
const gAccess = fieldValue(geoAccessibility, i, 0);
const gCentral = fieldValue(geoNaturalCentrality, i, townSuitability[i]);
const gLow = fieldValue(geoLowlandCapacity, i, plain[i]);
st.developableSum += developable[i];
st.habitabilitySum += gHabit;
st.accessibilitySum += gAccess;
st.centralitySum += gCentral;
st.lowlandCapacitySum += gLow;
if (developable[i] > 0.16 || gHabit > 0.24) st.developableCells++;
if (valleySettlement[i] > 0.24 || fieldValue(geoValleyAccess, i, 0) > 0.25) st.valleyCells++;
if (coastalSettlement[i] > 0.25 || fieldValue(geoCoastalAccess, i, 0) > 0.24) st.coastCells++;
if (townSuitability[i] > 0.28 || gCentral > 0.31) st.townCells++;
if (plain[i] > 0.24 || gLow > 0.26) st.plainCells++;
if (gCentral > 0.36 && gHabit > 0.18) st.highCentralityCells++;
st.minX = Math.min(st.minX, x);
st.minY = Math.min(st.minY, y);
st.maxX = Math.max(st.maxX, x);
st.maxY = Math.max(st.maxY, y);
}
}
function visibilityFactor(regionId, st) {
if (!st || st.area <= 0) return 0;
// Treat the focused prefecture and neighboring prefectures with the same
// density curve. Only genuinely clipped map-edge slivers are downscaled.
return clamp(Math.sqrt(st.area / 1900), 0.32, 1.05);
}
function pickRegionalPoints(scoreArray, {
stride = 1,
threshold = 0.25,
minDistance = 6,
totalMax = 100,
seedOffset = 0,
quotaForRegion,
predicate = () => true,
kind = "Point",
extraScore = () => 0,
}) {
const byRegion = new Map();
for (let y = 2; y < MAP_H - 2; y += stride) {
for (let x = 2; x < MAP_W - 2; x += stride) {
const i = indexOf(x, y);
if (sea[i] || !predicate(x, y, i)) continue;
const regionId = regionIdAt(x, y);
if (regionId < 0) continue;
const score = scoreArray[i] + extraScore(x, y, i) + hash2(x, y, seed + seedOffset) * 0.055;
if (score < threshold) continue;
if (!byRegion.has(regionId)) byRegion.set(regionId, []);
byRegion.get(regionId).push({ x, y, score, kind, regionId });
}
}
const out = [];
for (const [regionId, candidates] of [...byRegion.entries()].sort((a, b) => a[0] - b[0])) {
const st = regionStats.get(regionId);
const quota = quotaForRegion ? quotaForRegion(regionId, st) : 0;
if (quota <= 0) continue;
out.push(...pickEntities(candidates, {
max: quota,
minDistance,
threshold,
seed: seed + seedOffset + regionId * 1009,
jitter: 0.04,
}));
}
return out.sort((a, b) => b.score - a.score).slice(0, totalMax);
}
function pickGlobalPoints(scoreArray, { threshold, max, minDistance, seedOffset = 0, predicate = () => true, stride = 1 }) {
const candidates = [];
for (let y = 2; y < MAP_H - 2; y += stride) {
for (let x = 2; x < MAP_W - 2; x += stride) {
const i = indexOf(x, y);
if (sea[i] || !predicate(x, y, i)) continue;
const score = scoreArray[i] + hash2(x, y, seed + seedOffset) * 0.07;
if (score >= threshold) candidates.push({ x, y, score, regionId: regionIdAt(x, y) });
}
}
return pickEntities(candidates, { max, minDistance, threshold, seed: seed + seedOffset });
}
return {
geography,
geoHabitability,
geoAccessibility,
geoNaturalCentrality,
geoLowlandCapacity,
geoValleyAccess,
geoCoastalAccess,
geoBarrier,
geoCorridorSuitability,
fieldValue,
regionIdAt,
inFocusedPrefecture,
developable,
ruralSuitability,
townSuitability,
valleySettlement,
coastalSettlement,
confluenceField,
barrierCost,
corridorCost,
settlementCluster,
settlementScore,
regionStats,
visibilityFactor,
pickRegionalPoints,
pickGlobalPoints,
};
}

213
mapFeatureLanduse.js Normal file
View file

@ -0,0 +1,213 @@
import { MAP_H, MAP_W, SIZE, clamp, hash2, indexOf, inside } from "./mapUtils.js";
import { LANDUSE } from "./landuseCodes.js";
export function buildFeatureLanduse(ctx) {
const {
seed,
elevation, slope, sea, river, floodplain, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland,
developable, ruralSuitability, valleySettlement, coastalSettlement,
modernCities, logisticsParks,
roadLanduseInfluence, roadInfluence, roadDensityInfluence, railInfluence2, stationInfluence, stationDensityInfluence, villageInfluence,
cityInfluence, coreInfluence, oldTownInfluence, townInfluence, industrialInfluence, logisticsInfluence,
} = ctx;
const populationDensity = new Float32Array(SIZE);
const landuse = new Uint8Array(SIZE);
// Re-run land-use classification after landuse allocation. The loop above is
// intentionally inside a helper to keep all thresholds in one place.
function classifyLanduse() {
landuse.fill(LANDUSE.RURAL);
let maxDensity = 0;
const baseNoiseSeed = seed + 15000;
const urbanCapacity = new Float32Array(SIZE);
const ruralDensityFloor = new Float32Array(SIZE);
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (sea[i]) continue;
const transport = Math.max(roadLanduseInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82);
const densityTransport = Math.max(roadDensityInfluence[i] * 0.95, stationDensityInfluence[i] * 1.05, railInfluence2[i] * 0.85);
const urban = cityInfluence[i] * 0.76 + stationInfluence[i] * 0.30 + roadInfluence[i] * 0.14 + railInfluence2[i] * 0.10;
const core = coreInfluence[i];
const oldTown = oldTownInfluence[i] * 0.70 + townInfluence[i] * 0.38;
const rural = villageInfluence[i] * 0.24 + ruralSuitability[i] * 0.30;
const riverUrban = clamp(river[i] * 0.12 + valleyField[i] * 0.10 + plain[i] * 0.08 + basinField[i] * 0.08 - floodplain[i] * 0.10);
urbanCapacity[i] = clamp(
developable[i] * 0.66 +
plain[i] * 0.16 +
basinField[i] * 0.16 +
valleyField[i] * 0.16 +
coastalLowland[i] * 0.12 +
roadInfluence[i] * 0.14 + roadDensityInfluence[i] * 0.16 + transport * 0.08 +
riverUrban * 0.14 -
slope[i] * 0.18 -
ridgeField[i] * 0.12 -
floodplain[i] * 0.08
);
const highPenaltyDensity = Math.max(0, elevation[i] - 0.58);
const agrarianDensity = clamp(
agriculture[i] * 0.045 +
ruralSuitability[i] * 0.035 +
developable[i] * 0.028 +
plain[i] * 0.018 +
basinField[i] * 0.014 +
valleySettlement[i] * 0.014 +
coastalSettlement[i] * 0.012 +
villageInfluence[i] * 0.040 +
townInfluence[i] * 0.022 +
roadDensityInfluence[i] * 0.038 +
stationDensityInfluence[i] * 0.020 +
railInfluence2[i] * 0.012 -
slope[i] * 0.030 -
ridgeField[i] * 0.020 -
highPenaltyDensity * 0.058
);
const remoteWilderness = elevation[i] > 0.60 && slope[i] > 0.34 && ridgeField[i] > 0.38 && densityTransport < 0.035 && villageInfluence[i] < 0.025 && townInfluence[i] < 0.025 && cityInfluence[i] < 0.025;
ruralDensityFloor[i] = remoteWilderness ? 0 : clamp(agrarianDensity, 0, elevation[i] > 0.62 || slope[i] > 0.42 ? 0.052 : 0.105);
populationDensity[i] = clamp(
urban * 0.66 +
core * 0.46 +
oldTown * 0.28 +
townInfluence[i] * 0.16 +
villageInfluence[i] * 0.14 +
roadDensityInfluence[i] * 0.42 +
stationDensityInfluence[i] * 0.34 +
railInfluence2[i] * 0.12 +
transport * 0.05 +
agrarianDensity * 0.34
);
maxDensity = Math.max(maxDensity, populationDensity[i]);
if (elevation[i] > 0.67 || (slope[i] > 0.56 && ridgeField[i] > 0.30) || ridgeField[i] > 0.70) {
landuse[i] = LANDUSE.FOREST;
continue;
}
if (industrialInfluence[i] > 0.22 && urbanCapacity[i] > 0.10) {
landuse[i] = LANDUSE.INDUSTRIAL;
continue;
}
if (logisticsInfluence[i] > 0.24 && urbanCapacity[i] > 0.08 && (roadInfluence[i] > 0.08 || railInfluence2[i] > 0.06)) {
landuse[i] = LANDUSE.LOGISTICS;
continue;
}
if (core > 0.38 && urbanCapacity[i] > 0.10) {
landuse[i] = LANDUSE.CBD;
continue;
}
if (oldTown > 0.18 && urbanCapacity[i] > 0.09) {
landuse[i] = LANDUSE.OLD_URBAN;
continue;
}
const suburbanity = urban * 0.88 + transport * 0.23 + stationInfluence[i] * 0.12 + townInfluence[i] * 0.08 + riverUrban * 0.08;
const edgeTaper = clamp(cityInfluence[i] * 0.52 + stationInfluence[i] * 0.16 + roadLanduseInfluence[i] * 0.10 + 0.28);
const sprawlBias = clamp(0.58 + hash2(x, y, baseNoiseSeed) * 0.42);
const sprawlScore = suburbanity * sprawlBias * edgeTaper - core * 0.12;
if (sprawlScore > 0.24 && urbanCapacity[i] > 0.10) {
landuse[i] = LANDUSE.SUBURB;
} else if (roadLanduseInfluence[i] > 0.30 && urbanCapacity[i] > 0.10 && (townInfluence[i] > 0.05 || cityInfluence[i] > 0.11)) {
landuse[i] = LANDUSE.SUBURB;
} else if (agriculture[i] > 0.16 || rural > 0.18 || (developable[i] > 0.13 && plain[i] > 0.13) || (basinField[i] > 0.18 && slope[i] < 0.34) || (coastalLowland[i] > 0.16 && slope[i] < 0.32)) {
landuse[i] = LANDUSE.FARMLAND;
} else {
const usablePlain = slope[i] < 0.30 && (plain[i] > 0.18 || developable[i] > 0.20 || basinField[i] > 0.20 || coastalLowland[i] > 0.18);
landuse[i] = elevation[i] > 0.58 || slope[i] > 0.38 ? LANDUSE.FOREST : usablePlain ? LANDUSE.FARMLAND : LANDUSE.RURAL;
}
}
}
const baseLanduse = landuse.slice();
const isBuilt = (lu) => lu >= LANDUSE.OLD_URBAN && lu <= LANDUSE.ROADSIDE;
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (sea[i] || baseLanduse[i] === LANDUSE.FOREST) continue;
const transport = Math.max(roadLanduseInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82);
let urbanNeighbors = 0;
let cbdNeighbors = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const lu = baseLanduse[i + dy * MAP_W + dx];
if (isBuilt(lu)) urbanNeighbors++;
if (lu === LANDUSE.CBD) cbdNeighbors++;
}
}
if (baseLanduse[i] === LANDUSE.OLD_URBAN && coreInfluence[i] > 0.31 && cbdNeighbors >= 3) {
landuse[i] = LANDUSE.CBD;
continue;
}
if ((baseLanduse[i] === LANDUSE.FARMLAND || baseLanduse[i] === LANDUSE.RURAL) && urbanCapacity[i] > 0.10) {
const fringeChance = urbanNeighbors * 0.055 + cityInfluence[i] * 0.13 + transport * 0.12 + stationInfluence[i] * 0.08;
const noise = 0.23 + hash2(x, y, seed + 15050) * 0.24;
if (fringeChance > 0.34 + noise) {
landuse[i] = LANDUSE.SUBURB;
}
}
if ((river[i] > 0.10 || railInfluence2[i] > 0.16 || roadLanduseInfluence[i] > 0.22) && urbanNeighbors >= 3 && landuse[i] <= LANDUSE.FARMLAND && urbanCapacity[i] > 0.09) {
landuse[i] = cbdNeighbors >= 1 || coreInfluence[i] > 0.15 ? LANDUSE.OLD_URBAN : LANDUSE.SUBURB;
}
}
}
for (const park of logisticsParks) {
const r = 3;
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const x = park.x + dx;
const y = park.y + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (sea[i] || landuse[i] === LANDUSE.CBD || landuse[i] === LANDUSE.FOREST) continue;
if (Math.hypot(dx, dy) <= r && (agriculture[i] > 0.18 || plain[i] > 0.15 || roadInfluence[i] > 0.06 || railInfluence2[i] > 0.05)) {
landuse[i] = LANDUSE.LOGISTICS;
}
}
}
}
if (maxDensity > 0) {
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
const lu = landuse[i];
let floor = ruralDensityFloor[i];
if (lu === LANDUSE.FARMLAND) {
floor = Math.max(floor, clamp(0.024 + agriculture[i] * 0.044 + ruralSuitability[i] * 0.024 + roadDensityInfluence[i] * 0.030 + stationDensityInfluence[i] * 0.026 + villageInfluence[i] * 0.018, 0, 0.110));
} else if (lu === LANDUSE.LOGISTICS) {
floor = Math.max(floor, clamp(0.018 + roadDensityInfluence[i] * 0.026 + railInfluence2[i] * 0.014 + logisticsInfluence[i] * 0.012, 0, 0.060));
} else if (lu === LANDUSE.RURAL) {
floor = Math.max(floor, clamp(0.012 + ruralSuitability[i] * 0.020 + developable[i] * 0.014 + roadDensityInfluence[i] * 0.024 + stationDensityInfluence[i] * 0.020, 0, 0.070));
} else if (lu === LANDUSE.FOREST) {
floor = Math.min(floor, (roadDensityInfluence[i] > 0.04 || villageInfluence[i] > 0.03) ? 0.026 : 0);
}
const normalized = populationDensity[i] / maxDensity;
populationDensity[i] = clamp(Math.max(normalized, floor));
if (lu === LANDUSE.FOREST && floor === 0 && populationDensity[i] < 0.012) populationDensity[i] = 0;
}
}
}
classifyLanduse();
for (const city of modernCities) {
let urbanFootprintCells = 0;
let coreFootprintCells = 0;
const r = Math.ceil((city.urbanRadius || 8) * 1.3);
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const x = city.x + dx;
const y = city.y + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (sea[i]) continue;
if (Math.hypot(dx, dy) > r) continue;
if (landuse[i] >= LANDUSE.OLD_URBAN && landuse[i] <= LANDUSE.ROADSIDE) urbanFootprintCells++;
if (landuse[i] === LANDUSE.CBD) coreFootprintCells++;
}
}
city.urbanFootprintCells = urbanFootprintCells;
city.coreFootprintCells = coreFootprintCells;
}
return { landuse, populationDensity };
}

47
mapFeatureSettlements.js Normal file
View file

@ -0,0 +1,47 @@
import { MAP_H, MAP_W, SIZE, clamp, indexOf } from "./mapUtils.js";
import { influenceFromPoints } from "./mapGeneratorHelpers.js";
export function buildSettlementDemandFields(ctx) {
const {
sea, agriculture, plain, basinField, coastalLowland, slope, ridgeField,
modernCities, markets, commercialPorts, villages,
} = ctx;
const preliminaryUrbanInfluence = influenceFromPoints(modernCities, 18, (c) => clamp((c.population || 60000) / 260000, 0.55, 2.0));
const preliminaryTownInfluence = influenceFromPoints([...markets, ...commercialPorts], 10, (p) => p.portClass === "major" ? 1.35 : clamp((p.population || 12000) / 36000, 0.42, 1.1));
const preliminaryVillageInfluence = influenceFromPoints(villages, 7, (v) => clamp((v.population || 1800) / 5200, 0.22, 0.9));
const settlementDemand = new Float32Array(SIZE);
const urbanEdge = new Float32Array(SIZE);
const logisticsPreSuitability = new Float32Array(SIZE);
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (sea[i]) continue;
const density = clamp(preliminaryUrbanInfluence[i] * 0.62 + preliminaryTownInfluence[i] * 0.34 + preliminaryVillageInfluence[i] * 0.16);
settlementDemand[i] = density;
urbanEdge[i] = clamp(1 - Math.abs(density - 0.46) / 0.32);
logisticsPreSuitability[i] = clamp(
agriculture[i] * 0.30 +
plain[i] * 0.24 +
basinField[i] * 0.14 +
coastalLowland[i] * 0.12 +
preliminaryTownInfluence[i] * 0.18 +
urbanEdge[i] * 0.34 -
preliminaryUrbanInfluence[i] * 0.20 -
slope[i] * 0.50 -
ridgeField[i] * 0.32
);
}
}
return {
preliminaryUrbanInfluence,
preliminaryTownInfluence,
preliminaryVillageInfluence,
settlementDemand,
urbanEdge,
logisticsPreSuitability,
};
}

191
mapFeatureTransportTools.js Normal file
View file

@ -0,0 +1,191 @@
import { INF, MAP_H, MAP_W, SIZE, clamp, hash2, indexOf, inside } from "./mapUtils.js";
export function buildFeatureTransportCostFields(ctx) {
const {
seed,
sea, elevation, slope, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland,
portSuitability, passSuitability, crossingSuitability, naturalBarrierScore,
settlementDemand, urbanEdge, logisticsPreSuitability,
preliminaryTownInfluence, preliminaryVillageInfluence,
valleySettlement, coastalSettlement, developable,
} = ctx;
const expressway = new Float32Array(SIZE);
const rail = new Float32Array(SIZE);
const national = new Float32Array(SIZE);
const local = new Float32Array(SIZE);
const expresswayPotential = new Float32Array(SIZE);
const railPotential = new Float32Array(SIZE);
const nationalPotential = new Float32Array(SIZE);
const localPotential = new Float32Array(SIZE);
function seaAdjacency(x, y, radius = 1) {
let sum = 0;
let total = 0;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
if (!dx && !dy) continue;
const nx = x + dx;
const ny = y + dy;
if (!inside(nx, ny)) continue;
total++;
if (sea[indexOf(nx, ny)]) sum += 1;
}
}
return total > 0 ? sum / total : 0;
}
function highAltitudeTransportClosed(i) {
// Above this contour the generator should treat mountains as no-road
// terrain. A strong mapped pass is the exception, so genuine saddle
// crossings can still exist without roads drilling through entire ranges.
return elevation[i] >= 0.70;
}
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (sea[i] || highAltitudeTransportClosed(i)) {
expressway[i] = rail[i] = national[i] = local[i] = INF;
expresswayPotential[i] = railPotential[i] = nationalPotential[i] = localPotential[i] = 0;
continue;
}
const density = settlementDemand[i];
const mediumDensity = clamp(1 - Math.abs(density - 0.42) / 0.30);
const highDensity = clamp((density - 0.32) / 0.50);
const lowland = clamp(plain[i] * 0.48 + basinField[i] * 0.28 + valleyField[i] * 0.24 + coastalLowland[i] * 0.26 + agriculture[i] * 0.16);
const pass = passSuitability?.[i] || 0;
const crossing = crossingSuitability?.[i] || 0;
const waterCrossingPenalty = river[i] > 0.18 ? (1 - crossing) * (0.72 + river[i] * 1.35) : 0;
const seaNear = seaAdjacency(x, y, 1);
const seaBroad = seaAdjacency(x, y, 3);
const seaWide = seaAdjacency(x, y, 5);
// Roads should use coastal lowlands when there is a settlement/port reason,
// but should not casually trace beaches or hop over small bays.
const coastalTraversePenalty = clamp(seaBroad * 1.72 + seaWide * 0.82 - coastalLowland[i] * 0.48 - (portSuitability?.[i] || 0) * 0.30);
const highMountain = clamp((elevation[i] - 0.52) * 3.6 + slope[i] * 0.95 + ridgeField[i] * 1.05 - pass * 0.55 - valleyField[i] * 0.12);
const extremeMountain = clamp((elevation[i] - 0.64) * 4.8 + slope[i] * 1.55 + ridgeField[i] * 1.45 - pass * 0.80);
const denseCorePenalty = clamp((density - 0.66) / 0.28);
const openPlainParallelPenalty = clamp(plain[i] * 0.48 + agriculture[i] * 0.26 - density * 0.20 - valleyField[i] * 0.20 - coastalLowland[i] * 0.12);
const boundaryRidgePenalty = Math.pow(naturalBarrierScore?.[i] || 0, 2);
if (extremeMountain > 0.92 && pass < 0.34) {
expressway[i] = rail[i] = INF;
national[i] = elevation[i] >= 0.68 ? INF : 2.9 + extremeMountain * 2.8 + waterCrossingPenalty;
local[i] = elevation[i] >= 0.69 ? INF : 2.2 + extremeMountain * 2.3 + waterCrossingPenalty * 0.55;
expresswayPotential[i] = 0;
railPotential[i] = 0;
nationalPotential[i] = clamp(lowland * 0.18 + pass * 0.24 - extremeMountain * 0.55);
localPotential[i] = clamp(valleySettlement[i] * 0.14 + pass * 0.18 - extremeMountain * 0.28);
continue;
}
expresswayPotential[i] = clamp(
mediumDensity * 0.62 +
urbanEdge[i] * 0.38 +
logisticsPreSuitability[i] * 0.54 +
lowland * 0.36 +
agriculture[i] * 0.16 -
denseCorePenalty * 0.54 -
slope[i] * 0.82 -
highMountain * 0.92 -
coastalTraversePenalty * 0.32 -
river[i] * 0.14
);
railPotential[i] = clamp(
highDensity * 0.80 +
preliminaryTownInfluence[i] * 0.22 +
lowland * 0.46 +
valleyField[i] * 0.22 +
coastalLowland[i] * 0.22 -
slope[i] * 1.28 -
highMountain * 1.10 -
coastalTraversePenalty * 0.26 -
ridgeField[i] * 0.34
);
nationalPotential[i] = clamp(
density * 0.46 +
preliminaryTownInfluence[i] * 0.32 +
preliminaryVillageInfluence[i] * 0.20 +
agriculture[i] * 0.24 +
valleyField[i] * 0.28 +
coastalLowland[i] * 0.26 +
pass * 0.18 +
crossing * 0.18 -
slope[i] * 0.48 -
highMountain * 0.24 -
coastalTraversePenalty * 0.16 -
ridgeField[i] * 0.18
);
localPotential[i] = clamp(
preliminaryVillageInfluence[i] * 0.52 +
agriculture[i] * 0.38 +
coastalSettlement[i] * 0.30 +
valleySettlement[i] * 0.30 +
developable[i] * 0.18 -
slope[i] * 0.34 -
coastalTraversePenalty * 0.08 -
ridgeField[i] * 0.10
);
expressway[i] = Math.max(0.18,
1.62 - expresswayPotential[i] * 0.96 +
denseCorePenalty * 1.30 +
slope[i] * 5.4 +
highMountain * 5.8 +
extremeMountain * 4.2 +
boundaryRidgePenalty * 4.2 +
waterCrossingPenalty * 2.1 +
coastalTraversePenalty * 2.65 +
seaNear * 2.35 +
seaWide * 1.10 +
openPlainParallelPenalty * 0.12 +
hash2(x, y, seed + 13301) * 0.04
);
rail[i] = Math.max(0.16,
1.48 - railPotential[i] * 1.02 +
slope[i] * 7.2 +
highMountain * 7.0 +
extremeMountain * 4.8 +
boundaryRidgePenalty * 2.4 +
waterCrossingPenalty * 1.7 +
coastalTraversePenalty * 1.75 +
seaNear * 1.50 +
seaWide * 0.70 +
hash2(x, y, seed + 13302) * 0.03
);
national[i] = Math.max(0.16,
1.28 - nationalPotential[i] * 0.84 +
slope[i] * 2.8 +
ridgeField[i] * 1.18 +
Math.max(0, elevation[i] - 0.62) * 3.0 +
highMountain * 2.9 +
boundaryRidgePenalty * 1.8 +
waterCrossingPenalty * 1.25 -
valleyField[i] * 0.18 -
coastalLowland[i] * 0.08 +
coastalTraversePenalty * 1.55 +
seaNear * 0.84 +
seaWide * 0.48 -
pass * 0.42 +
hash2(x, y, seed + 13303) * 0.05
);
local[i] = Math.max(0.14,
1.12 - localPotential[i] * 0.86 +
slope[i] * 1.72 +
ridgeField[i] * 0.82 +
Math.max(0, elevation[i] - 0.68) * 1.9 +
highMountain * 1.24 +
boundaryRidgePenalty * 0.72 +
waterCrossingPenalty * 0.65 -
valleyField[i] * 0.22 -
coastalLowland[i] * 0.10 +
coastalTraversePenalty * 0.82 +
seaNear * 0.48 +
seaWide * 0.26 +
hash2(x, y, seed + 13304) * 0.07
);
}
}
return { expressway, rail, national, local, expresswayPotential, railPotential, nationalPotential, localPotential };
}

File diff suppressed because it is too large Load diff

View file

@ -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 {

265
mapMunicipalCoherence.js Normal file
View file

@ -0,0 +1,265 @@
import { INF, MAP_H, MAP_W } from "./mapUtils.js";
const ADMIN_ID_KEYS = ["adminId", "municipalityId", "adminNumericId"];
function coordIndex(width, height, x, y) {
if (x < 0 || y < 0 || x >= width || y >= height) return -1;
return y * width + x;
}
function numericAdminId(point) {
for (const key of ADMIN_ID_KEYS) {
const value = point?.[key];
if (Number.isFinite(value) && value >= 0) return Math.floor(value);
}
return -1;
}
function fieldValue(fields, name, i) {
return fields?.[name]?.[i] || 0;
}
function bestCellScore(fields, i) {
return fieldValue(fields, "populationDensity", i) * 3.0
+ fieldValue(fields, "plain", i) * 0.32
+ fieldValue(fields, "agriculture", i) * 0.16
- fieldValue(fields, "slope", i) * 0.30
- fieldValue(fields, "ridgeField", i) * 0.18;
}
function buildMunicipalStats({ adminId, prefectureRegionId, sea, fields = {}, width = MAP_W, height = MAP_H }) {
const stats = new Map();
for (let i = 0; i < adminId.length; i++) {
const id = adminId[i];
if (!Number.isFinite(id) || id < 0 || sea?.[i]) continue;
const row = stats.get(id) || { id, area: 0, sx: 0, sy: 0, bestI: -1, bestScore: -INF, prefVotes: new Map() };
const x = i % width;
const y = Math.floor(i / width);
row.area++;
row.sx += x;
row.sy += y;
const pref = prefectureRegionId?.[i] ?? -1;
if (pref >= 0) row.prefVotes.set(pref, (row.prefVotes.get(pref) || 0) + 1);
const score = bestCellScore(fields, i);
if (score > row.bestScore) {
row.bestScore = score;
row.bestI = i;
}
stats.set(id, row);
}
for (const row of stats.values()) {
let bestPref = -1;
let bestVotes = -1;
for (const [pref, count] of row.prefVotes) {
if (count > bestVotes || (count === bestVotes && pref < bestPref)) {
bestPref = pref;
bestVotes = count;
}
}
row.prefectureRegionId = bestPref;
row.x = row.bestI >= 0 ? row.bestI % width : Math.round(row.sx / Math.max(1, row.area));
row.y = row.bestI >= 0 ? Math.floor(row.bestI / width) : Math.round(row.sy / Math.max(1, row.area));
}
return stats;
}
function pointFieldCoord(point, pointOffsetX, pointOffsetY) {
return {
x: Math.round((point?.x || 0) + pointOffsetX),
y: Math.round((point?.y || 0) + pointOffsetY),
};
}
function centerQuality(center, id, stat, context) {
if (!center) return -INF;
const { adminId, sea, width, height, pointOffsetX, pointOffsetY } = context;
const p = pointFieldCoord(center, pointOffsetX, pointOffsetY);
const i = coordIndex(width, height, p.x, p.y);
const ownsCell = i >= 0 && !sea?.[i] && adminId?.[i] === id;
return (ownsCell ? 100000 : 0)
+ (center.name ? 5000 : 0)
+ (center.representativeFeatureName || center.canonicalSettlementName ? 1200 : 0)
+ (center.generatedOfficePoint ? -200 : 0)
- Math.hypot(p.x - stat.x, p.y - stat.y);
}
function normalizeCenter(center, id, stat, context, generated = false) {
const { pointOffsetX, pointOffsetY, fields = {}, seed = 0 } = context;
const out = {
...(center || {}),
x: stat.x - pointOffsetX,
y: stat.y - pointOffsetY,
adminId: id,
adminNumericId: id,
municipalityId: id,
prefectureRegionId: stat.prefectureRegionId,
municipalArea: stat.area,
insidePrefecture: true,
};
if (generated) {
out.generatedOfficePoint = true;
out.seedKind ||= "coherenceFallbackMunicipalityOffice";
out.kind ||= "Municipal Center";
out.generatedMunicipalityName ||= `自治${id + 1}`;
out.name ||= out.generatedMunicipalityName;
out.labelName ||= out.generatedMunicipalityName;
out.municipalityName ||= out.generatedMunicipalityName;
}
const i = coordIndex(context.width, context.height, stat.x, stat.y);
if (i >= 0) {
out.officePopulationDensity = fields.populationDensity?.[i] || 0;
out.officeLanduse = fields.landuse?.[i] ?? out.officeLanduse;
}
return out;
}
export function reconcileMunicipalMetadata({
adminId,
municipalityId = null,
prefectureRegionId = null,
sea = null,
adminCenters = [],
municipalityToPrefectureId = null,
fields = {},
width = MAP_W,
height = MAP_H,
pointOffsetX = 0,
pointOffsetY = 0,
seed = 0,
} = {}) {
if (!adminId) return { adminCenters: adminCenters || [], municipalityToPrefectureId, stats: new Map(), debug: { activeMunicipalities: 0 } };
const stats = buildMunicipalStats({ adminId, prefectureRegionId, sea, fields, width, height });
if (municipalityId) {
for (let i = 0; i < adminId.length; i++) municipalityId[i] = sea?.[i] ? -1 : (adminId[i] >= 0 ? adminId[i] : -1);
}
const byId = new Map();
let ghostCentersRemoved = 0;
let centersMovedToOwnedCells = 0;
for (const [index, center] of (adminCenters || []).entries()) {
if (!center) continue;
const explicitId = numericAdminId(center);
const id = explicitId >= 0 ? explicitId : (stats.has(index) ? index : -1);
const stat = stats.get(id);
if (!stat) {
ghostCentersRemoved++;
continue;
}
const current = byId.get(id);
if (!current || centerQuality(center, id, stat, { adminId, sea, width, height, pointOffsetX, pointOffsetY }) > centerQuality(current, id, stat, { adminId, sea, width, height, pointOffsetX, pointOffsetY })) {
byId.set(id, center);
}
}
let fallbackCentersAdded = 0;
const nextCenters = [];
for (const [id, stat] of [...stats.entries()].sort((a, b) => a[0] - b[0])) {
const existing = byId.get(id);
if (!existing) fallbackCentersAdded++;
else {
const p = pointFieldCoord(existing, pointOffsetX, pointOffsetY);
const pi = coordIndex(width, height, p.x, p.y);
if (pi < 0 || sea?.[pi] || adminId[pi] !== id) centersMovedToOwnedCells++;
}
nextCenters.push(normalizeCenter(existing, id, stat, { pointOffsetX, pointOffsetY, fields, width, height, seed }, !existing));
}
let maxId = Math.max(-1, ...stats.keys());
if (municipalityToPrefectureId?.length) maxId = Math.max(maxId, municipalityToPrefectureId.length - 1);
const nextMapping = new Int32Array(Math.max(0, maxId + 1));
nextMapping.fill(-1);
if (municipalityToPrefectureId) {
for (let i = 0; i < municipalityToPrefectureId.length && i < nextMapping.length; i++) nextMapping[i] = municipalityToPrefectureId[i] ?? -1;
}
for (const [id, stat] of stats) if (stat.prefectureRegionId >= 0) nextMapping[id] = stat.prefectureRegionId;
return {
adminCenters: nextCenters,
municipalityToPrefectureId: nextMapping,
stats,
debug: {
activeMunicipalities: stats.size,
ghostCentersRemoved,
fallbackCentersAdded,
centersMovedToOwnedCells,
},
};
}
export function refreshPrefectureRegionsMetadata({
prefectureRegionId,
sea,
existing = [],
fields = {},
width = MAP_W,
height = MAP_H,
pointOffsetX = 0,
pointOffsetY = 0,
} = {}) {
if (!prefectureRegionId) return { prefectureRegions: existing || [], debug: { activePrefectureRegions: 0 } };
const byId = new Map();
for (let i = 0; i < prefectureRegionId.length; i++) {
const id = prefectureRegionId[i];
if (!Number.isFinite(id) || id < 0 || sea?.[i]) continue;
const row = byId.get(id) || { id, area: 0, sx: 0, sy: 0, bestI: -1, bestScore: -INF };
const x = i % width;
const y = Math.floor(i / width);
row.area++;
row.sx += x;
row.sy += y;
const score = bestCellScore(fields, i) - Math.hypot(x - row.sx / Math.max(1, row.area), y - row.sy / Math.max(1, row.area)) * 0.02;
if (score > row.bestScore) {
row.bestScore = score;
row.bestI = i;
}
byId.set(id, row);
}
const existingById = new Map();
for (const region of existing || []) {
const id = Number.isFinite(region?.prefectureRegionId) ? Math.floor(region.prefectureRegionId) : Number.isFinite(region?.id) ? Math.floor(region.id) : -1;
if (id >= 0 && !existingById.has(id)) existingById.set(id, region);
}
let fallbackRegionsAdded = 0;
const prefectureRegions = [];
for (const [id, row] of [...byId.entries()].sort((a, b) => a[0] - b[0])) {
const base = existingById.get(id);
if (!base) fallbackRegionsAdded++;
const x = row.bestI >= 0 ? row.bestI % width : Math.round(row.sx / Math.max(1, row.area));
const y = row.bestI >= 0 ? Math.floor(row.bestI / width) : Math.round(row.sy / Math.max(1, row.area));
prefectureRegions.push({
...(base || {}),
id,
prefectureRegionId: id,
featureId: id,
x: x - pointOffsetX,
y: y - pointOffsetY,
area: row.area,
kind: base?.kind || (id === 0 ? "Current Prefecture" : "Prefecture"),
name: base?.name || `県域${id + 1}`,
labelName: base?.labelName || base?.name || `県域${id + 1}`,
forceLabel: true,
labelPriorityBase: base?.labelPriorityBase || 950 + Math.sqrt(row.area),
});
}
return {
prefectureRegions,
debug: {
activePrefectureRegions: byId.size,
fallbackRegionsAdded,
},
};
}
export function municipalCoherenceForMap(map) {
return reconcileMunicipalMetadata({
adminId: map?.adminId,
municipalityId: map?.municipalityId,
prefectureRegionId: map?.prefectureRegionId,
sea: map?.sea,
adminCenters: map?.adminCenters,
municipalityToPrefectureId: map?.municipalityToPrefectureId,
fields: map,
width: map?.width || MAP_W,
height: map?.height || MAP_H,
});
}

View file

@ -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,

2272
mapPatch.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -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;
}

View file

@ -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.

View file

@ -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()),

View file

@ -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,

File diff suppressed because it is too large Load diff

145
mapTransportGraph.js Normal file
View file

@ -0,0 +1,145 @@
import { INF, MAP_H, MAP_W, MinHeap, indexOf, inside } from "./mapUtils.js";
export function buildCoarseCostGraph({ sea, costField }, options = {}) {
const scale = options.scale ?? 4;
const cw = Math.ceil(MAP_W / scale);
const ch = Math.ceil(MAP_H / scale);
const size = cw * ch;
const cost = new Float32Array(size);
const passable = new Uint8Array(size);
cost.fill(INF);
for (let cy = 0; cy < ch; cy++) {
for (let cx = 0; cx < cw; cx++) {
let best = INF;
let sum = 0;
let n = 0;
for (let dy = 0; dy < scale; dy++) {
for (let dx = 0; dx < scale; dx++) {
const x = cx * scale + dx;
const y = cy * scale + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (sea?.[i] || costField?.[i] >= INF) continue;
const v = costField[i];
best = Math.min(best, v);
sum += v;
n++;
}
}
const ci = cy * cw + cx;
if (n > 0) {
passable[ci] = 1;
cost[ci] = best * 0.55 + (sum / n) * 0.45;
}
}
}
return { scale, cw, ch, size, cost, passable };
}
export function routeCoarsePath(start, goal, graph, options = {}) {
if (!start || !goal || !graph) return [];
const sx = Math.floor(start.x / graph.scale);
const sy = Math.floor(start.y / graph.scale);
const gx = Math.floor(goal.x / graph.scale);
const gy = Math.floor(goal.y / graph.scale);
if (sx < 0 || sy < 0 || sx >= graph.cw || sy >= graph.ch || gx < 0 || gy < 0 || gx >= graph.cw || gy >= graph.ch) return [];
const startCi = sy * graph.cw + sx;
const goalCi = gy * graph.cw + gx;
if (!graph.passable[startCi] || !graph.passable[goalCi]) return [];
const score = new Float32Array(graph.size);
const cameFrom = new Int32Array(graph.size);
const closed = new Uint8Array(graph.size);
score.fill(INF);
cameFrom.fill(-1);
score[startCi] = 0;
const heap = new MinHeap();
heap.push({ i: startCi, f: Math.hypot(sx - gx, sy - gy) });
const maxExpanded = options.maxExpanded ?? graph.size;
let expanded = 0;
let found = -1;
while (heap.length && expanded++ < maxExpanded) {
const cur = heap.pop();
if (!cur || closed[cur.i]) continue;
closed[cur.i] = 1;
if (cur.i === goalCi) {
found = cur.i;
break;
}
const cx = cur.i % graph.cw;
const cy = Math.floor(cur.i / graph.cw);
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = cx + dx;
const ny = cy + dy;
if (nx < 0 || ny < 0 || nx >= graph.cw || ny >= graph.ch) continue;
const ni = ny * graph.cw + nx;
if (closed[ni] || !graph.passable[ni]) continue;
const nd = score[cur.i] + graph.cost[ni] * Math.hypot(dx, dy);
if (nd < score[ni]) {
score[ni] = nd;
cameFrom[ni] = cur.i;
heap.push({ i: ni, f: nd + Math.hypot(nx - gx, ny - gy) * (options.heuristicWeight ?? 0.85) });
}
}
}
}
if (found < 0) return [];
const path = [];
for (let p = found; p >= 0; p = cameFrom[p]) {
const cx = p % graph.cw;
const cy = Math.floor(p / graph.cw);
path.push([
Math.min(MAP_W - 1, Math.round(cx * graph.scale + graph.scale * 0.5)),
Math.min(MAP_H - 1, Math.round(cy * graph.scale + graph.scale * 0.5)),
]);
if (p === startCi) break;
}
return path.reverse();
}
export function refineCoarsePath(path, costField, options = {}) {
if (!path || path.length < 2) return [];
const sea = options.sea || null;
const radius = options.snapRadius ?? 1;
const out = [];
let lastKey = "";
for (let k = 0; k < path.length - 1; k++) {
const a = path[k];
const b = path[k + 1];
const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])));
for (let s = k === 0 ? 0 : 1; s <= steps; s++) {
const t = s / steps;
const tx = Math.round(a[0] + (b[0] - a[0]) * t);
const ty = Math.round(a[1] + (b[1] - a[1]) * t);
let best = null;
let bestScore = INF;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const x = tx + dx;
const y = ty + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (sea?.[i] || costField?.[i] >= INF) continue;
const score = costField[i] + Math.hypot(dx, dy) * 0.22;
if (score < bestScore) {
bestScore = score;
best = [x, y];
}
}
}
if (!best) return [];
const key = `${best[0]},${best[1]}`;
if (key !== lastKey) {
out.push(best);
lastKey = key;
}
}
}
return out.length >= 2 ? out : [];
}

View file

@ -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;

View file

@ -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);

View file

@ -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: [
"里", "郷", "村", "町", "宿", "垣", "坪", "軒", "惣",
"庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂",
"庄", "ノ庄", "之庄", "宮", "ノ宮", "之宮", "寺", "社", "堂",
"城", "館", "屋", "家", "所",
"市", "場", "関", "地蔵", "辻", "角", "堰", "通", "路", "道", "筋",
]

150
rectContext.js Normal file
View file

@ -0,0 +1,150 @@
import { clamp } from "./mapUtils.js";
export function createRectContext(options = {}) {
const originX = Math.floor(Number.isFinite(options.originX) ? options.originX : 0);
const originY = Math.floor(Number.isFinite(options.originY) ? options.originY : 0);
const width = Math.max(1, Math.floor(Number.isFinite(options.width) ? options.width : 1));
const height = Math.max(1, Math.floor(Number.isFinite(options.height) ? options.height : 1));
return {
originX,
originY,
width,
height,
size: width * height,
name: options.name || "rect",
};
}
export function rectIndexOf(ctx, x, y) {
return y * ctx.width + x;
}
export function rectXyOf(ctx, i) {
return [i % ctx.width, Math.floor(i / ctx.width)];
}
export function rectInside(ctx, x, y) {
return !!ctx && x >= 0 && y >= 0 && x < ctx.width && y < ctx.height;
}
export function rectWorldX(ctx, x) {
return ctx.originX + x;
}
export function rectWorldY(ctx, y) {
return ctx.originY + y;
}
export function rectWorldCoord(ctx, x, y) {
return { x: ctx.originX + x, y: ctx.originY + y };
}
export function rectLocalCoord(ctx, worldX, worldY) {
return { x: Math.round(worldX - ctx.originX), y: Math.round(worldY - ctx.originY) };
}
export function rectWorldIndex(ctx, worldX, worldY) {
const x = Math.round(worldX - ctx.originX);
const y = Math.round(worldY - ctx.originY);
return rectInside(ctx, x, y) ? rectIndexOf(ctx, x, y) : -1;
}
export function rectFromBounds(bounds, name = "rect") {
const x0 = Math.floor(Math.min(bounds.x0, bounds.x1));
const y0 = Math.floor(Math.min(bounds.y0, bounds.y1));
const x1 = Math.ceil(Math.max(bounds.x0, bounds.x1));
const y1 = Math.ceil(Math.max(bounds.y0, bounds.y1));
return createRectContext({ originX: x0, originY: y0, width: Math.max(1, x1 - x0), height: Math.max(1, y1 - y0), name });
}
export function rectBounds(ctx) {
return { x0: ctx.originX, y0: ctx.originY, x1: ctx.originX + ctx.width, y1: ctx.originY + ctx.height };
}
export function clampRectToBounds(rect, bounds) {
const x0 = Math.max(bounds.x0 ?? 0, Math.floor(rect.x0));
const y0 = Math.max(bounds.y0 ?? 0, Math.floor(rect.y0));
const x1 = Math.min(bounds.x1 ?? Infinity, Math.ceil(rect.x1));
const y1 = Math.min(bounds.y1 ?? Infinity, Math.ceil(rect.y1));
return { x0, y0, x1: Math.max(x0, x1), y1: Math.max(y0, y1) };
}
export function expandRectBounds(rect, margin, bounds = null) {
const expanded = {
x0: Math.floor(rect.x0) - margin,
y0: Math.floor(rect.y0) - margin,
x1: Math.ceil(rect.x1) + margin,
y1: Math.ceil(rect.y1) + margin,
};
return bounds ? clampRectToBounds(expanded, bounds) : expanded;
}
export function rectNeighbors8(ctx, x, y) {
const out = [];
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = x + dx;
const ny = y + dy;
if (rectInside(ctx, nx, ny)) out.push([nx, ny]);
}
}
return out;
}
export function rectDistanceToEdge(ctx, x, y) {
return Math.min(x, y, ctx.width - 1 - x, ctx.height - 1 - y);
}
export function createRectTerrainFields(ctx) {
const size = ctx.size;
const flowTo = new Int32Array(size);
flowTo.fill(-1);
return {
elevation: new Float32Array(size),
moisture: new Float32Array(size),
slope: new Float32Array(size),
sea: new Uint8Array(size),
ocean: new Uint8Array(size),
lake: new Uint8Array(size),
river: new Float32Array(size),
floodplain: new Float32Array(size),
plain: new Float32Array(size),
agriculture: new Float32Array(size),
ridgeField: new Float32Array(size),
valleyField: new Float32Array(size),
basinField: new Float32Array(size),
coastalLowland: new Float32Array(size),
flowAccum: new Float32Array(size),
erosionField: new Float32Array(size),
depositionField: new Float32Array(size),
arcSpineField: new Float32Array(size),
branchRidgeField: new Float32Array(size),
depositionalLowland: new Float32Array(size),
alluvialFanField: new Float32Array(size),
deltaField: new Float32Array(size),
naturalBarrierScore: new Float32Array(size),
flowTo,
portSuitability: new Float32Array(size),
crossingSuitability: new Float32Array(size),
passSuitability: new Float32Array(size),
visibleRavineField: new Float32Array(size),
surfaceTextureField: new Float32Array(size),
watershedId: (() => { const a = new Int32Array(size); a.fill(-1); return a; })(),
naturalCompartmentId: (() => { const a = new Int32Array(size); a.fill(-1); return a; })(),
regionId: (() => { const a = new Int32Array(size); a.fill(-1); return a; })(),
};
}
export function isRectCellField(value, ctx) {
return ArrayBuffer.isView(value) && typeof value.length === "number" && value.length === ctx.size;
}
export function rectQuantile(values, q) {
const arr = Array.from(values).filter(Number.isFinite).sort((a, b) => a - b);
if (!arr.length) return 0;
const p = clamp(q) * (arr.length - 1);
const i = Math.floor(p);
const f = p - i;
return arr[i] + (arr[Math.min(arr.length - 1, i + 1)] - arr[i]) * f;
}

View file

@ -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();
}

View file

@ -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}

30
test.js
View file

@ -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");

201
worldMap.js Normal file
View file

@ -0,0 +1,201 @@
import { MAP_H, MAP_W, SIZE } from "./mapUtils.js";
export const DEFAULT_WORLD_PADDING_X = MAP_W;
export const DEFAULT_WORLD_PADDING_Y = MAP_H;
const NEGATIVE_ONE_FIELDS = new Set([
"adminId",
"prefectureRegionId",
"regionId",
"municipalityId",
"naturalCompartmentId",
"watershedId",
]);
function isCellField(value) {
return ArrayBuffer.isView(value) && typeof value.length === "number" && value.length === SIZE;
}
function defaultForField(name, Constructor) {
if (name === "sea") return 1;
if (name === "elevation") return 0.08;
if (name === "seaLevel") return undefined;
if (NEGATIVE_ONE_FIELDS.has(name)) return -1;
if (Constructor === Float32Array || Constructor === Float64Array) return 0;
return 0;
}
function makeWorldField(name, source, worldWidth, worldHeight, originX, originY) {
const Constructor = NEGATIVE_ONE_FIELDS.has(name) ? Int32Array : source.constructor;
const out = new Constructor(worldWidth * worldHeight);
const fallback = defaultForField(name, Constructor);
if (fallback !== 0) out.fill(fallback);
for (let y = 0; y < MAP_H; y++) {
const srcRow = y * MAP_W;
const dstRow = (originY + y) * worldWidth + originX;
for (let x = 0; x < MAP_W; x++) out[dstRow + x] = source[srcRow + x];
}
return out;
}
function sanitizeInitialWorldFields(fields, width, height) {
const sea = fields.sea;
if (!sea) return;
const adminFields = ["adminId", "municipalityId", "prefectureRegionId"];
for (let i = 0; i < width * height; i++) {
const isSea = Boolean(sea[i]);
if (fields.landMask) fields.landMask[i] = isSea ? 0 : (fields.prefectureMask?.[i] ? 1 : fields.landMask[i]);
if (fields.humanRegionMask && isSea) fields.humanRegionMask[i] = 0;
if (isSea) {
for (const key of adminFields) if (fields[key]) fields[key][i] = -1;
if (fields.landuse) fields.landuse[i] = 0;
} else {
if (fields.municipalityId && fields.adminId && fields.municipalityId[i] < 0 && fields.adminId[i] >= 0) fields.municipalityId[i] = fields.adminId[i];
}
}
}
export function createWorldMap(initialMap, options = {}) {
const paddingX = Number.isFinite(options.paddingX) ? Math.max(0, Math.floor(options.paddingX)) : DEFAULT_WORLD_PADDING_X;
const paddingY = Number.isFinite(options.paddingY) ? Math.max(0, Math.floor(options.paddingY)) : DEFAULT_WORLD_PADDING_Y;
const worldWidth = MAP_W + paddingX * 2;
const worldHeight = MAP_H + paddingY * 2;
const originX = paddingX;
const originY = paddingY;
const fields = {};
for (const [name, value] of Object.entries(initialMap || {})) {
if (isCellField(value)) fields[name] = makeWorldField(name, value, worldWidth, worldHeight, originX, originY);
}
if (!fields.sea) {
fields.sea = new Uint8Array(worldWidth * worldHeight);
fields.sea.fill(1);
}
if (!fields.elevation) {
fields.elevation = new Float32Array(worldWidth * worldHeight);
fields.elevation.fill(0.08);
}
sanitizeInitialWorldFields(fields, worldWidth, worldHeight);
return {
seed: initialMap?.seed ?? 0,
width: worldWidth,
height: worldHeight,
originX,
originY,
sourceWidth: initialMap?.width || MAP_W,
sourceHeight: initialMap?.height || MAP_H,
sourceMap: initialMap,
fields,
invalidatedRects: [],
humanPatchHistory: [],
generatedRects: [{
x0: originX,
y0: originY,
x1: originX + MAP_W,
y1: originY + MAP_H,
terrainType: initialMap?.terrainTemplate?.terrainType || initialMap?.terrainDebug?.terrainType || "auto",
label: initialMap?.terrainTemplate?.terrainTypeLabel || initialMap?.terrainDebug?.terrainTypeLabel || "Initial generation",
}],
};
}
export function createInitialCamera(world) {
return {
x: world?.originX || 0,
y: world?.originY || 0,
};
}
export function clampCameraToWorld(camera, world, viewWidth = MAP_W, viewHeight = MAP_H) {
if (!camera || !world) return { x: 0, y: 0 };
const maxX = Math.max(0, world.width - viewWidth);
const maxY = Math.max(0, world.height - viewHeight);
return {
x: Math.min(Math.max(Math.round(camera.x || 0), 0), maxX),
y: Math.min(Math.max(Math.round(camera.y || 0), 0), maxY),
};
}
function expandRectByOffset(rect, dx, dy) {
if (!rect) return rect;
return { ...rect, x0: rect.x0 + dx, y0: rect.y0 + dy, x1: rect.x1 + dx, y1: rect.y1 + dy };
}
function shiftSelectionShape(shape, dx, dy) {
if (!shape?.polygon) return shape;
return {
...shape,
x0: Number.isFinite(shape.x0) ? shape.x0 + dx : shape.x0,
y0: Number.isFinite(shape.y0) ? shape.y0 + dy : shape.y0,
x1: Number.isFinite(shape.x1) ? shape.x1 + dx : shape.x1,
y1: Number.isFinite(shape.y1) ? shape.y1 + dy : shape.y1,
polygon: shape.polygon.map((p) => ({ ...p, x: p.x + dx, y: p.y + dy })),
};
}
function shiftPatchMetadata(item, dx, dy) {
const out = expandRectByOffset(item, dx, dy);
for (const sub of ["coreRect", "writeRect", "repairRect", "contextRect", "blendRect", "transportReachRect"]) {
if (out?.[sub]) out[sub] = expandRectByOffset(out[sub], dx, dy);
}
if (out?.selectionShape) out.selectionShape = shiftSelectionShape(out.selectionShape, dx, dy);
return out;
}
function shiftRectCollections(world, dx, dy) {
if (!dx && !dy) return;
for (const key of ["generatedRects", "invalidatedRects", "humanPatchHistory"]) {
if (!Array.isArray(world[key])) continue;
world[key] = world[key].map((item) => shiftPatchMetadata(item, dx, dy));
}
if (world.lastPatchResult) world.lastPatchResult = shiftPatchMetadata(world.lastPatchResult, dx, dy);
}
export function expandWorldMap(world, margins = {}) {
if (!world) return { world, dx: 0, dy: 0, expanded: false };
const left = Math.max(0, Math.floor(margins.left || 0));
const right = Math.max(0, Math.floor(margins.right || 0));
const top = Math.max(0, Math.floor(margins.top || 0));
const bottom = Math.max(0, Math.floor(margins.bottom || 0));
if (!left && !right && !top && !bottom) return { world, dx: 0, dy: 0, expanded: false };
const oldWidth = world.width;
const oldHeight = world.height;
const newWidth = oldWidth + left + right;
const newHeight = oldHeight + top + bottom;
const newFields = {};
for (const [name, field] of Object.entries(world.fields || {})) {
if (!ArrayBuffer.isView(field)) continue;
const Constructor = NEGATIVE_ONE_FIELDS.has(name) ? Int32Array : field.constructor;
const out = new Constructor(newWidth * newHeight);
const fallback = defaultForField(name, Constructor);
if (fallback !== 0) out.fill(fallback);
for (let y = 0; y < oldHeight; y++) {
const srcRow = y * oldWidth;
const dstRow = (y + top) * newWidth + left;
for (let x = 0; x < oldWidth; x++) out[dstRow + x] = field[srcRow + x];
}
newFields[name] = out;
}
world.width = newWidth;
world.height = newHeight;
world.originX += left;
world.originY += top;
world.fields = newFields;
shiftRectCollections(world, left, top);
return { world, dx: left, dy: top, expanded: true };
}
export function ensureWorldPaddingForCamera(world, camera, viewWidth = MAP_W, viewHeight = MAP_H, padding = Math.floor(Math.min(MAP_W, MAP_H) * 0.45)) {
if (!world || !camera) return { dx: 0, dy: 0, expanded: false };
const grow = Math.max(64, Math.floor(padding));
const margins = { left: 0, right: 0, top: 0, bottom: 0 };
if (camera.x < grow) margins.left = grow;
if (camera.y < grow) margins.top = grow;
if (camera.x + viewWidth > world.width - grow) margins.right = grow;
if (camera.y + viewHeight > world.height - grow) margins.bottom = grow;
return expandWorldMap(world, margins);
}

250
worldViewport.js Normal file
View file

@ -0,0 +1,250 @@
import { MAP_H, MAP_W, SIZE } from "./mapUtils.js";
const EMPTY_ARRAY_KEYS = new Set([
"villages", "markets", "castles", "ports", "modernCities", "satelliteCities", "stations",
"interchanges", "industrialZones", "logisticsParks", "newTowns", "adminCenters",
"premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads",
"railways", "branchRailways", "ringRailways", "externalRailways", "expressways", "externalExpressways",
"mainRivers", "tributaryRivers", "smallStreams", "prefectureBorder", "regionalPrefectureBorders",
"adminBorders", "externalGateways", "prefectureRegions",
]);
const PATH_ARRAY_KEYS = new Set([
"premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads",
"railways", "branchRailways", "ringRailways", "externalRailways", "expressways", "externalExpressways",
"mainRivers", "tributaryRivers", "smallStreams",
]);
const SEGMENT_ARRAY_KEYS = new Set([
"prefectureBorder", "regionalPrefectureBorders", "adminBorders",
]);
const POINT_ARRAY_KEYS = new Set([
"villages", "markets", "castles", "ports", "modernCities", "satelliteCities", "stations",
"interchanges", "industrialZones", "logisticsParks", "newTowns", "adminCenters",
"externalGateways", "prefectureRegions",
]);
const NEGATIVE_ONE_FIELDS = new Set([
"adminId",
"prefectureRegionId",
"regionId",
"municipalityId",
"naturalCompartmentId",
"watershedId",
]);
function isCellField(value) {
return ArrayBuffer.isView(value) && typeof value.length === "number" && value.length === SIZE;
}
function defaultForField(name, Constructor) {
if (name === "sea") return 1;
if (name === "elevation") return 0.08;
if (NEGATIVE_ONE_FIELDS.has(name)) return -1;
if (Constructor === Float32Array || Constructor === Float64Array) return 0;
return 0;
}
function worldIndex(world, x, y) {
if (!world || x < 0 || y < 0 || x >= world.width || y >= world.height) return -1;
return y * world.width + x;
}
function copyViewportField(name, source, world, camera, viewWidth, viewHeight) {
const Constructor = source.constructor;
const out = new Constructor(viewWidth * viewHeight);
const fallback = defaultForField(name, Constructor);
if (fallback !== 0) out.fill(fallback);
const cx = Math.round(camera.x || 0);
const cy = Math.round(camera.y || 0);
for (let y = 0; y < viewHeight; y++) {
for (let x = 0; x < viewWidth; x++) {
const src = worldIndex(world, cx + x, cy + y);
if (src >= 0) out[y * viewWidth + x] = source[src];
}
}
return out;
}
function inViewportPoint(p, margin = 0, viewWidth = MAP_W, viewHeight = MAP_H) {
return p && p.x >= -margin && p.y >= -margin && p.x < viewWidth + margin && p.y < viewHeight + margin;
}
function transformPointObject(point, camera, originX, originY) {
if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) return null;
return {
...point,
x: point.x + originX - camera.x,
y: point.y + originY - camera.y,
worldX: point.x + originX,
worldY: point.y + originY,
};
}
function transformPointArray(items, camera, originX, originY, margin = 36, preserveIndexes = false, viewWidth = MAP_W, viewHeight = MAP_H) {
const mapped = (items || []).map((item) => transformPointObject(item, camera, originX, originY));
return preserveIndexes ? mapped : mapped.filter((item) => inViewportPoint(item, margin, viewWidth, viewHeight));
}
function transformTuple(tuple, camera, originX, originY) {
if (!Array.isArray(tuple) || tuple.length < 2) return null;
return [tuple[0] + originX - camera.x, tuple[1] + originY - camera.y];
}
function tupleInside(tuple, margin = 0, viewWidth = MAP_W, viewHeight = MAP_H) {
return tuple && tuple[0] >= -margin && tuple[1] >= -margin && tuple[0] < viewWidth + margin && tuple[1] < viewHeight + margin;
}
function splitTransformedPath(path, camera, originX, originY, margin = 0, viewWidth = MAP_W, viewHeight = MAP_H) {
const chunks = [];
let current = [];
for (const tuple of path || []) {
const p = transformTuple(tuple, camera, originX, originY);
const inside = tupleInside(p, margin, viewWidth, viewHeight);
if (inside) {
current.push([Math.round(p[0]), Math.round(p[1])]);
} else if (current.length >= 2) {
chunks.push(current);
current = [];
} else {
current = [];
}
}
if (current.length >= 2) chunks.push(current);
return chunks;
}
function transformPaths(paths, camera, originX, originY, viewWidth = MAP_W, viewHeight = MAP_H) {
const out = [];
for (const path of paths || []) out.push(...splitTransformedPath(path, camera, originX, originY, 0, viewWidth, viewHeight));
return out;
}
function segmentIntersectsViewport(seg, margin = 4, viewWidth = MAP_W, viewHeight = MAP_H) {
if (!seg || seg.length < 2) return false;
const xs = [seg[0][0], seg[1][0]];
const ys = [seg[0][1], seg[1][1]];
return Math.max(...xs) >= -margin && Math.min(...xs) <= viewWidth + margin && Math.max(...ys) >= -margin && Math.min(...ys) <= viewHeight + margin;
}
function transformSegments(segments, camera, originX, originY, viewWidth = MAP_W, viewHeight = MAP_H) {
return (segments || [])
.map((seg) => [transformTuple(seg?.[0], camera, originX, originY), transformTuple(seg?.[1], camera, originX, originY)])
.filter((seg) => segmentIntersectsViewport(seg, 4, viewWidth, viewHeight));
}
function copySourceMapViewportField(source, camera, originX, originY, viewWidth, viewHeight) {
if (!ArrayBuffer.isView(source) || typeof source.length !== "number" || source.length !== SIZE) return source;
const out = new source.constructor(viewWidth * viewHeight);
const cx = Math.round(camera.x || 0);
const cy = Math.round(camera.y || 0);
for (let y = 0; y < viewHeight; y++) {
for (let x = 0; x < viewWidth; x++) {
const sx = cx + x - originX;
const sy = cy + y - originY;
if (sx >= 0 && sy >= 0 && sx < MAP_W && sy < MAP_H) out[y * viewWidth + x] = source[sy * MAP_W + sx];
}
}
return out;
}
function transformTransportDebug(debug, camera, originX, originY, viewport = null, viewWidth = MAP_W, viewHeight = MAP_H) {
if (!debug?.layers) return debug;
const layers = { ...debug.layers };
for (const [key, value] of Object.entries(layers)) {
if (ArrayBuffer.isView(value) && value.length === SIZE) layers[key] = copySourceMapViewportField(value, camera, originX, originY, viewWidth, viewHeight);
}
// The original transport-debug potential layers are fixed-map arrays. Once the
// viewport pans into patched world cells, synthesize equivalent viewport-sized
// debug fields from the current world-backed fields so the color overlay moves
// with the terrain instead of staying tied to the initial source map.
if (viewport) {
const n = viewWidth * viewHeight;
const make = (fn) => {
const out = new Float32Array(n);
for (let i = 0; i < n; i++) out[i] = fn(i);
return out;
};
const sea = viewport.sea || new Uint8Array(n);
const slope = viewport.slope || new Float32Array(n);
const plain = viewport.plain || new Float32Array(n);
const pop = viewport.populationDensity || viewport.settlementScore || new Float32Array(n);
const road = viewport.roadInfluence || new Float32Array(n);
const rail = viewport.railInfluence2 || viewport.stationInfluence || new Float32Array(n);
layers.expresswayPotential = make((i) => sea[i] ? 0 : Math.max(0, Math.min(1, road[i] * 0.72 + pop[i] * 0.42 + plain[i] * 0.22 - slope[i] * 0.52)));
layers.nationalRoadPotential = make((i) => sea[i] ? 0 : Math.max(0, Math.min(1, road[i] * 0.58 + pop[i] * 0.55 + plain[i] * 0.18 - slope[i] * 0.38)));
layers.railPotential = make((i) => sea[i] ? 0 : Math.max(0, Math.min(1, rail[i] * 0.72 + pop[i] * 0.38 + plain[i] * 0.26 - slope[i] * 0.72)));
layers.slopeSeaPenalty = make((i) => sea[i] ? 1 : Math.max(0, Math.min(1, slope[i] * 1.35)));
}
if (Array.isArray(layers.components)) {
layers.components = layers.components.map((component) => ({
...component,
cells: (component.cells || [])
.map((cell) => transformTuple(cell, camera, originX, originY))
.filter((cell) => tupleInside(cell, 0, viewWidth, viewHeight))
.map(([x, y]) => [Math.round(x), Math.round(y)]),
})).filter((component) => component.cells.length);
}
if (Array.isArray(layers.repairedSegments)) {
layers.repairedSegments = layers.repairedSegments.flatMap((repair) => transformPaths([repair.path || []], camera, originX, originY, viewWidth, viewHeight).map((path) => ({ ...repair, path })));
}
if (Array.isArray(layers.unservedSettlements)) layers.unservedSettlements = transformPointArray(layers.unservedSettlements, camera, originX, originY, 36, false, viewWidth, viewHeight);
return { ...debug, layers };
}
function buildEmptyViewportFromSource(sourceMap, world, camera, viewWidth, viewHeight) {
const viewport = { ...sourceMap };
viewport.width = viewWidth;
viewport.height = viewHeight;
viewport.worldCamera = { x: camera.x, y: camera.y };
viewport.worldOrigin = { x: world.originX, y: world.originY };
viewport.generatedRects = world.generatedRects || [];
for (const key of EMPTY_ARRAY_KEYS) if (Array.isArray(viewport[key])) viewport[key] = [];
return viewport;
}
export function getViewportMap(world, camera, viewWidth = MAP_W, viewHeight = MAP_H, options = {}) {
const sourceMap = world?.sourceMap || {};
const normalizedCamera = {
x: Math.round(camera?.x || 0),
y: Math.round(camera?.y || 0),
};
const viewport = buildEmptyViewportFromSource(sourceMap, world, normalizedCamera, viewWidth, viewHeight);
for (const [name, value] of Object.entries(world?.fields || {})) {
viewport[name] = copyViewportField(name, value, world, normalizedCamera, viewWidth, viewHeight);
}
if (options.light) return viewport;
const originX = world?.originX || 0;
const originY = world?.originY || 0;
for (const key of POINT_ARRAY_KEYS) {
if (!Array.isArray(sourceMap[key])) continue;
viewport[key] = transformPointArray(sourceMap[key], normalizedCamera, originX, originY, 36, key === "adminCenters", viewWidth, viewHeight);
}
for (const key of PATH_ARRAY_KEYS) if (Array.isArray(sourceMap[key])) viewport[key] = transformPaths(sourceMap[key], normalizedCamera, originX, originY, viewWidth, viewHeight);
for (const key of SEGMENT_ARRAY_KEYS) if (Array.isArray(sourceMap[key])) viewport[key] = transformSegments(sourceMap[key], normalizedCamera, originX, originY, viewWidth, viewHeight);
if (sourceMap.adminDebug) {
viewport.adminDebug = {
...sourceMap.adminDebug,
compartmentBorders: transformSegments(sourceMap.adminDebug.compartmentBorders || [], normalizedCamera, originX, originY, viewWidth, viewHeight),
lowlandAdminSeeds: transformPointArray(sourceMap.adminDebug.lowlandAdminSeeds || [], normalizedCamera, originX, originY, 36, false, viewWidth, viewHeight),
};
}
if (sourceMap.transportDebug) viewport.transportDebug = transformTransportDebug(sourceMap.transportDebug, normalizedCamera, originX, originY, viewport, viewWidth, viewHeight);
if (sourceMap.neighborPrefectureDetails) {
viewport.neighborPrefectureDetails = {
...sourceMap.neighborPrefectureDetails,
cities: transformPointArray(sourceMap.neighborPrefectureDetails.cities || [], normalizedCamera, originX, originY, 36, false, viewWidth, viewHeight),
adminCenters: transformPointArray(sourceMap.neighborPrefectureDetails.adminCenters || [], normalizedCamera, originX, originY, 36, false, viewWidth, viewHeight),
roads: transformPaths(sourceMap.neighborPrefectureDetails.roads || [], normalizedCamera, originX, originY, viewWidth, viewHeight),
};
}
return viewport;
}