diff --git a/app.js b/app.js
index 0380924..d989f9c 100644
--- a/app.js
+++ b/app.js
@@ -28,6 +28,8 @@ const state = {
world: null,
camera: { x: 0, y: 0 },
viewportMap: null,
+ viewWidth: MAP_W,
+ viewHeight: MAP_H,
hoverEntities: [],
selectionRect: null,
patchVariant: 0,
@@ -50,6 +52,7 @@ 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");
@@ -57,6 +60,8 @@ const progressTimingsEl = document.getElementById("generationProgressTimings");
let generationStartedAt = 0;
let generationCurrentStage = "";
let generationTimer = null;
+let zoomRedrawRaf = null;
+let zoomSettledTimer = null;
const dragState = {
mode: null,
@@ -67,6 +72,7 @@ const dragState = {
startCameraY: 0,
selectStart: null,
selectEnd: null,
+ selectPath: null,
pendingCamera: null,
panRaf: null,
};
@@ -81,19 +87,31 @@ function clampZoom(value) {
return Math.min(Math.max(parsed, 0.55), 2.8);
}
-function zoomTransform() {
- const zoom = clampZoom(state.zoom || 1);
- const width = canvas.width || MAP_W * CELL_SIZE;
- const height = canvas.height || MAP_H * CELL_SIZE;
+function viewportSizeForZoom(zoom = state.zoom) {
+ const z = clampZoom(zoom || 1);
return {
- zoom,
- width,
- height,
- tx: width * (1 - zoom) * 0.5,
- ty: height * (1 - zoom) * 0.5,
+ width: Math.max(1, Math.ceil(MAP_W / z)),
+ height: Math.max(1, Math.ceil(MAP_H / z)),
};
}
+function clampCameraForView(camera, size = viewportSizeForZoom(state.zoom)) {
+ return clampCameraToWorld(camera, state.world, size?.width || MAP_W, size?.height || MAP_H);
+}
+
+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);
@@ -105,38 +123,39 @@ function applyCanvasZoom() {
}
function displayedCellSize() {
- return CELL_SIZE * clampZoom(state.zoom || 1);
+ return mapCellScreenSize();
}
-function screenPointToMapPixel(clientX, clientY) {
+function screenPointToMapPixel(clientX, clientY, sizeOverride = null) {
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return null;
- const t = zoomTransform();
- const scaleX = t.width / rect.width;
- const scaleY = t.height / rect.height;
- const canvasX = (clientX - rect.left) * scaleX;
- const canvasY = (clientY - rect.top) * scaleY;
- return {
- x: (canvasX - t.tx) / t.zoom,
- y: (canvasY - t.ty) / t.zoom,
- };
+ 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 t = zoomTransform();
- const canvasX = t.tx + px * t.zoom;
- const canvasY = t.ty + py * t.zoom;
+ 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, t.width)),
- y: canvas.offsetTop + canvasY * (rect.height / Math.max(1, t.height)),
+ 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) {
+function mapClientToCell(event, sizeOverride = null) {
const map = activeMap();
- if (!map) return null;
- const p = screenPointToMapPixel(event.clientX, event.clientY);
+ 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),
@@ -160,24 +179,99 @@ function clampCanvasPoint(event) {
};
}
+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 pts = points.map((p) => `${p.x},${p.y}`).join(" ");
+ selectionSvgEl.setAttribute("viewBox", `0 0 ${canvas.clientWidth || canvas.width || 1} ${canvas.clientHeight || canvas.height || 1}`);
+ selectionSvgEl.innerHTML = ` `;
+ selectionSvgEl.style.display = "block";
+ selectionSvgEl.classList.toggle("invalid", !!invalid);
+}
+
+function hideSelectionSvg() {
+ if (!selectionSvgEl) return;
+ selectionSvgEl.style.display = "none";
+ selectionSvgEl.innerHTML = "";
+ selectionSvgEl.classList.remove("invalid");
+}
+
function updateSelectionOverlay() {
- if (!selectionEl || !dragState.selectStart || !dragState.selectEnd) return;
- const x0 = Math.min(dragState.selectStart.x, dragState.selectEnd.x);
- const y0 = Math.min(dragState.selectStart.y, dragState.selectEnd.y);
- const x1 = Math.max(dragState.selectStart.x, dragState.selectEnd.x);
- const y1 = Math.max(dragState.selectStart.y, dragState.selectEnd.y);
- 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 liveRect = selectionPixelsToCells(dragState.selectStart, dragState.selectEnd);
- const validation = validatePatchRect(liveRect, state.world);
- selectionEl.classList.toggle("invalid", !validation.ok);
+ 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 || liveRect;
+ const current = validation.rect || liveShape;
patchStatusEl.textContent = validation.ok
? `Selected: ${formatRectSize(validation.rect)}. Release to confirm patch selection.`
: `${validation.reason} Current: ${formatRectSize(current)}.`;
@@ -186,9 +280,16 @@ function updateSelectionOverlay() {
}
function updateSelectionOverlayFromWorldRect() {
- if (!selectionEl || !state.selectionRect || !state.camera || !activeMap()) return;
+ 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);
@@ -205,6 +306,7 @@ function updateSelectionOverlayFromWorldRect() {
selectionEl.style.display = "none";
return;
}
+ hideSelectionSvg();
selectionEl.style.display = "block";
selectionEl.style.left = `${canvas.offsetLeft + x0}px`;
selectionEl.style.top = `${canvas.offsetTop + y0}px`;
@@ -218,7 +320,8 @@ function formatRectSize(rect) {
if (!rect) return "-";
const w = Math.max(0, rect.x1 - rect.x0);
const h = Math.max(0, rect.y1 - rect.y0);
- return `${w} x ${h} cells / ${(w * h).toLocaleString()} cells`;
+ const area = Math.max(0, rect.areaCells || (w * h));
+ return `${w} x ${h} cells / ${area.toLocaleString()} cells`;
}
function normalizePatchVariant(value) {
@@ -251,7 +354,7 @@ function updatePatchControls() {
if (alternativePatchButton) alternativePatchButton.disabled = !validation.ok;
if (!patchStatusEl) return;
if (!state.selectionRect) {
- patchStatusEl.textContent = `Right-drag an area. Minimum: ${PATCH_MIN_WIDTH} x ${PATCH_MIN_HEIGHT} cells and ${PATCH_MIN_AREA.toLocaleString()} cells. Current variant: ${variant}.`;
+ 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;
}
@@ -262,7 +365,7 @@ function updatePatchControls() {
}
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}, idmap ${state.lastPatchResult.humanGeography.continuityIdMappings || 0}/${state.lastPatchResult.humanGeography.continuityIdMappedCells || 0}, invalid ports ${state.lastPatchResult.humanGeography.invalidPortsRemoved || 0}` : ""}.`
+ ? ` 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);
@@ -296,39 +399,31 @@ function schedulePanRedraw(camera) {
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 map = activeMap();
- if (!map || !start || !end) return null;
- const rect = canvas.getBoundingClientRect();
- if (!rect.width || !rect.height) return null;
- const toMapPixel = (p) => {
- const t = zoomTransform();
- const canvasX = p.x * (t.width / rect.width);
- const canvasY = p.y * (t.height / rect.height);
- return { x: (canvasX - t.tx) / t.zoom, y: (canvasY - t.ty) / t.zoom };
- };
- const a = toMapPixel(start);
- const b = toMapPixel(end);
- const localX0 = Math.floor(Math.min(a.x, b.x) / CELL_SIZE);
- const localY0 = Math.floor(Math.min(a.y, b.y) / CELL_SIZE);
- const localX1 = Math.ceil(Math.max(a.x, b.x) / CELL_SIZE);
- const localY1 = Math.ceil(Math.max(a.y, b.y) / CELL_SIZE);
- const cameraX = Math.round(state.camera?.x || 0);
- const cameraY = Math.round(state.camera?.y || 0);
+ const a = screenPointToWorldCell(start);
+ const b = screenPointToWorldCell(end);
+ if (!a || !b) return null;
return {
- x0: cameraX + Math.min(Math.max(localX0, 0), map.width - 1),
- y0: cameraY + Math.min(Math.max(localY0, 0), map.height - 1),
- x1: cameraX + Math.min(Math.max(localX1, 1), map.width),
- y1: cameraY + Math.min(Math.max(localY1, 1), map.height),
+ 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;
@@ -346,6 +441,7 @@ function handleMapPointerDown(event) {
dragState.mode = "select";
dragState.selectStart = clampCanvasPoint(event);
dragState.selectEnd = dragState.selectStart;
+ dragState.selectPath = [dragState.selectStart];
canvasShell.classList.add("selecting");
updateSelectionOverlay();
}
@@ -362,13 +458,16 @@ function handleMapPointerMove(event) {
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 = clampCameraToWorld({
+ const nextCamera = clampCameraForView({
x: dragState.startCameraX - dxCells,
y: dragState.startCameraY - dyCells,
- }, state.world, MAP_W, MAP_H);
+ }, 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();
}
@@ -380,10 +479,13 @@ function handleMapPointerUp(event) {
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 (width >= 4 && height >= 4) {
- state.selectionRect = selectionPixelsToCells(dragState.selectStart, dragState.selectEnd);
+ if (shape && (dragState.selectPath.length >= 3 || (width >= 4 && height >= 4))) {
+ state.selectionRect = shape;
state.lastPatchResult = null;
resetPatchVariant({ update: false });
updateSelectionOverlayFromWorldRect();
@@ -553,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; }
}
@@ -564,61 +667,106 @@ function landuseName(value) {
return landuseLabel(value);
}
-function numericIdOf(item) {
- for (const key of ["adminId", "municipalityId", "id", "adminNumericId"]) {
+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 value;
+ if (Number.isFinite(value)) return Math.floor(value);
+ }
+ return 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 || [];
- const direct = centers[adminId];
- if (direct && [direct.adminId, direct.municipalityId, direct.id, direct.adminNumericId].some((v) => v === adminId)) return direct;
- return centers.find((center) => [center?.adminId, center?.municipalityId, center?.id, center?.adminNumericId].some((v) => v === adminId)) || 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)\s+-?\d+/i.test(text);
+ return !text || /^-?\d+(?:\s*[,,]\s*\d+)*$/u.test(text) || /^(Municipality|Prefecture|Admin|Region)\s*-?\d+/i.test(text);
}
-function nearestNamedAdminCenter(map, cellIndex, maxDistance = 36) {
+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 || []) {
- const name = center?.name || center?.municipalityName || center?.canonicalSettlementName || center?.municipalityRootName || center?.generatedMunicipalityName;
- if (looksNumericName(name) || !Number.isFinite(center?.x) || !Number.isFinite(center?.y)) continue;
+ 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; }
}
+ if (!best && adminId != null && adminId >= 0) return nearestNamedAdminCenter(map, cellIndex, maxDistance, null);
return best;
}
function adminName(map, adminId, cellIndex = -1) {
- const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex);
- const name = center?.municipalityName || center?.name || center?.canonicalSettlementName || center?.municipalityRootName || center?.generatedMunicipalityName;
- if (!looksNumericName(name)) return name;
+ 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);
- return Number.isFinite(center?.municipalityPopulation) ? center.municipalityPopulation : null;
+ 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);
- if (region?.name && !looksNumericName(region.name)) return region.name;
- const center = nearestNamedAdminCenter(map, i, 80);
- return center?.prefectureName || center?.prefectureRegionName || (id >= 0 ? "Unnamed prefecture" : "-");
+ const region = (map.prefectureRegions || []).find((p) => hasNumericId(p, id, PREFECTURE_ID_KEYS));
+ const regionName = firstUsableText(region, PREFECTURE_NAME_KEYS);
+ if (regionName) return regionName;
+ const center = nearestNamedAdminCenter(map, i, 90);
+ const fromCenter = firstUsableText(center, ["prefectureName", "prefectureRegionName", "regionName"]);
+ return fromCenter || (id >= 0 ? "Unnamed prefecture" : "-");
}
function updateTooltip(event) {
@@ -640,8 +788,9 @@ function updateTooltip(event) {
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
- ? `${entity.name || entity.facilityLabel || entity.kind || "Feature"} / ${entity.kind || "Feature"}`
+ ? `${entityName || adminName(map, hoveredAdminId, i) || entity.kind || "Feature"} / ${entity.kind || "Feature"}`
: coordinateText;
const lines = [
`${entityTitle} `,
@@ -721,23 +870,38 @@ function handleCanvasWheel(event) {
if (!state.world || !activeMap()) return;
event.preventDefault();
tooltipEl?.classList.remove("visible");
- const beforeCell = mapClientToCell(event);
+ 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.12 : 1 / 1.12;
+ 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);
+ const afterCell = mapClientToCell(event, nextSize);
if (afterCell) {
- state.camera = clampCameraToWorld({
+ state.camera = clampCameraForView({
x: beforeWorld.x - afterCell.x,
y: beforeWorld.y - afterCell.y,
- }, state.world, MAP_W, MAP_H);
+ }, nextSize);
}
}
- redraw({ fastTerrain: false });
+
+ // 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() {
@@ -786,26 +950,32 @@ async function generateAlternativePatch() {
function redraw(options = {}) {
if (!state.world) return;
- const expansion = ensureWorldPaddingForCamera(state.world, state.camera, MAP_W, MAP_H);
+ 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 = {
- x0: state.selectionRect.x0 + (expansion.dx || 0),
- y0: state.selectionRect.y0 + (expansion.dy || 0),
- x1: state.selectionRect.x1 + (expansion.dx || 0),
- y1: state.selectionRect.y1 + (expansion.dy || 0),
+ ...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 = clampCameraToWorld(state.camera, state.world, MAP_W, MAP_H);
- state.viewportMap = getViewportMap(state.world, state.camera, MAP_W, MAP_H);
- state.hoverEntities = buildHoverEntities(state.viewportMap);
+ 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,
+ showFeatures: state.showFeatures && !options.fastTerrain,
showLabels: state.showLabels && !options.fastTerrain,
continuousTerrain: !options.fastTerrain,
+ fastTerrain: !!options.fastTerrain,
zoom: state.zoom || 1,
});
applyCanvasZoom();
diff --git a/index.html b/index.html
index fa4f34a..a0b500d 100644
--- a/index.html
+++ b/index.html
@@ -19,6 +19,7 @@
+
Generating map...
@@ -67,7 +68,7 @@
Generate Selected Area
Alternative
-
Right-drag an area to enable patch generation.
+
Right-drag to lasso a freeform patch area.
@@ -112,7 +113,7 @@
Notes
- Open index.html with Live Server. Left-drag pans the viewport; right-drag selects a regeneration area; use Patch Generation to write terrain into that area.
+ Open index.html with Live Server. Left-drag pans the viewport; right-drag draws a freeform regeneration area; use Patch Generation to write terrain into that area.
Add preferred reusable place names in CUSTOM_NAME_LIST inside names.js.
diff --git a/mapFeatures.js b/mapFeatures.js
index 75e009a..9ffaada 100644
--- a/mapFeatures.js
+++ b/mapFeatures.js
@@ -1960,9 +1960,9 @@ const premodernRoads = [];
// are through-corridors here, not automatic suburbanization generators.
// A narrow field controls land-use attachment, while a broader field raises
// population density around trunk roads without painting a wide suburb band.
- const roadLanduseInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 2.25, "road:landuse");
- const roadInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 5.0, "road:influence");
- const roadDensityInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 9.0, "road:density");
+ let roadLanduseInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 2.25, "road:landuse:provisional");
+ let roadInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 5.0, "road:influence:provisional");
+ let roadDensityInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 9.0, "road:density:provisional");
const railInfluence2 = cachedInfluenceFromPaths([...railways, ...branchRailways, ...externalRailways], 4, "rail:influence");
const stations = [];
@@ -2522,6 +2522,18 @@ const premodernRoads = [];
transportDebugLayers.postConnectivityExpresswayDedup = dedupeTransportPathSet(expressways, { minLength: 8, sampleStep: 2 });
transportDebugLayers.postConnectivityExpresswayEndpointICs = ensureExpresswayEndpointsHaveICs();
markFeatureTiming("post-connectivity-guarantees");
+
+ const finalRoadInfluencePaths = [...nationalRoads, ...ringRoads, ...externalRoads];
+ roadLanduseInfluence = cachedInfluenceFromPaths(finalRoadInfluencePaths, 2.25, "road:landuse:final");
+ roadInfluence = cachedInfluenceFromPaths(finalRoadInfluencePaths, 5.0, "road:influence:final");
+ roadDensityInfluence = cachedInfluenceFromPaths(finalRoadInfluencePaths, 9.0, "road:density:final");
+ transportDebugLayers.finalRoadInfluenceRefresh = {
+ roadPaths: finalRoadInfluencePaths.length,
+ nationalRoads: nationalRoads.length,
+ externalRoads: externalRoads.length,
+ ringRoads: ringRoads.length,
+ };
+
const { landuse, populationDensity } = buildFeatureLanduse({
seed,
elevation, slope, sea, river, floodplain, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland,
diff --git a/mapOutput.js b/mapOutput.js
index f23c24f..d79b529 100644
--- a/mapOutput.js
+++ b/mapOutput.js
@@ -1223,6 +1223,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,
diff --git a/mapPatch.js b/mapPatch.js
index cabe7a4..d01be2a 100644
--- a/mapPatch.js
+++ b/mapPatch.js
@@ -44,7 +44,7 @@ const ADMIN_CONTINUITY_FIELD_NAMES = new Set(["adminId", "municipalityId", "pref
const NATURAL_CONTINUITY_FIELD_NAMES = new Set(["regionId", "naturalCompartmentId", "watershedId"]);
const CONTINUITY_FIELD_NAMES = new Set([...ADMIN_CONTINUITY_FIELD_NAMES, ...NATURAL_CONTINUITY_FIELD_NAMES]);
-const SKIP_CELL_FIELDS = new Set(["flowTo"]);
+const SKIP_CELL_FIELDS = new Set(["flowTo", "prefectureMask", "humanRegionMask"]);
function worldIndex(world, x, y) {
if (!world || x < 0 || y < 0 || x >= world.width || y >= world.height) return -1;
@@ -81,6 +81,89 @@ function normalizeRect(rect) {
return { x0, y0, x1, y1 };
}
+function isPolygonSelection(input) {
+ return !!input && Array.isArray(input.polygon) && input.polygon.length >= 3;
+}
+
+function clampPointToWorld(point, world) {
+ return {
+ x: clamp(Math.round(point.x ?? 0), 0, Math.max(0, (world?.width || 1) - 1)),
+ y: clamp(Math.round(point.y ?? 0), 0, Math.max(0, (world?.height || 1) - 1)),
+ };
+}
+
+function polygonBounds(polygon) {
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ for (const p of polygon || []) {
+ if (!Number.isFinite(p?.x) || !Number.isFinite(p?.y)) continue;
+ minX = Math.min(minX, p.x);
+ minY = Math.min(minY, p.y);
+ maxX = Math.max(maxX, p.x);
+ maxY = Math.max(maxY, p.y);
+ }
+ if (!Number.isFinite(minX)) return null;
+ return { x0: Math.floor(minX), y0: Math.floor(minY), x1: Math.ceil(maxX + 1), y1: Math.ceil(maxY + 1) };
+}
+
+function polygonAreaCells(polygon) {
+ if (!polygon || polygon.length < 3) return 0;
+ let area = 0;
+ for (let i = 0; i < polygon.length; i++) {
+ const a = polygon[i];
+ const b = polygon[(i + 1) % polygon.length];
+ area += a.x * b.y - b.x * a.y;
+ }
+ return Math.abs(area) * 0.5;
+}
+
+function normalizeSelectionShape(input, world = null) {
+ if (!isPolygonSelection(input)) return normalizeRect(input);
+ const polygon = (input.polygon || []).map((p) => world ? clampPointToWorld(p, world) : { x: Math.round(p.x), y: Math.round(p.y) });
+ const bounds = polygonBounds(polygon);
+ if (!bounds) return null;
+ return {
+ kind: input.kind || 'lasso',
+ polygon,
+ areaCells: Math.max(1, Math.round(input.areaCells || polygonAreaCells(polygon))),
+ x0: bounds.x0,
+ y0: bounds.y0,
+ x1: bounds.x1,
+ y1: bounds.y1,
+ };
+}
+
+function pointInPolygon(px, py, polygon) {
+ let inside = false;
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
+ const xi = polygon[i].x + 0.5;
+ const yi = polygon[i].y + 0.5;
+ const xj = polygon[j].x + 0.5;
+ const yj = polygon[j].y + 0.5;
+ const intersect = ((yi > py) !== (yj > py)) && (px < ((xj - xi) * (py - yi)) / Math.max(1e-6, (yj - yi)) + xi);
+ if (intersect) inside = !inside;
+ }
+ return inside;
+}
+
+function pointSegmentDistance(px, py, ax, ay, bx, by) {
+ const dx = bx - ax;
+ const dy = by - ay;
+ const len2 = dx * dx + dy * dy;
+ if (len2 <= 1e-6) return Math.hypot(px - ax, py - ay);
+ const t = clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0, 1);
+ return Math.hypot(px - (ax + dx * t), py - (ay + dy * t));
+}
+
+function distanceToPolygonEdge(px, py, polygon) {
+ let best = Infinity;
+ for (let i = 0; i < polygon.length; i++) {
+ const a = polygon[i];
+ const b = polygon[(i + 1) % polygon.length];
+ best = Math.min(best, pointSegmentDistance(px, py, a.x + 0.5, a.y + 0.5, b.x + 0.5, b.y + 0.5));
+ }
+ return best;
+}
+
function insideRect(x, y, rect) {
return !!rect && x >= rect.x0 && y >= rect.y0 && x < rect.x1 && y < rect.y1;
}
@@ -119,8 +202,9 @@ function ensureWorldField(world, name, source) {
}
export function clipPatchRect(rect, world) {
- const normalized = normalizeRect(rect);
+ const normalized = normalizeSelectionShape(rect, world);
if (!normalized || !world) return null;
+ if (isPolygonSelection(normalized)) return normalized;
return {
x0: Math.min(Math.max(normalized.x0, 0), world.width),
y0: Math.min(Math.max(normalized.y0, 0), world.height),
@@ -134,7 +218,7 @@ export function validatePatchRect(rect, world) {
if (!clipped) return { ok: false, rect: null, reason: "No selected area." };
const width = rectWidth(clipped);
const height = rectHeight(clipped);
- const area = width * height;
+ const area = isPolygonSelection(clipped) ? Math.max(1, Math.round(clipped.areaCells || polygonAreaCells(clipped.polygon))) : width * height;
if (width < PATCH_MIN_WIDTH || height < PATCH_MIN_HEIGHT) {
const parts = [];
if (width < PATCH_MIN_WIDTH) parts.push(`minimum width ${PATCH_MIN_WIDTH} cells`);
@@ -162,7 +246,7 @@ export function validatePatchRect(rect, world) {
}
export function buildPatchRects(userRect, world = null) {
- const coreRect = normalizeRect(userRect);
+ const coreRect = normalizeSelectionShape(userRect, world);
const width = rectWidth(coreRect);
const height = rectHeight(coreRect);
const shortSide = Math.max(1, Math.min(width, height));
@@ -173,7 +257,7 @@ export function buildPatchRects(userRect, world = null) {
const repairMargin = Math.max(writeMargin, Math.min(desiredRepair, maxBySource));
const writeRect = expandRect(coreRect, writeMargin, world);
const repairRect = expandRect(coreRect, repairMargin, world);
- const transportReachMargin = Math.max(repairMargin + 96, Math.min(260, repairMargin + Math.max(MAP_W, MAP_H)));
+ const transportReachMargin = Math.max(repairMargin + 160, Math.min(420, Math.max(220, repairMargin + Math.floor(Math.max(MAP_W, MAP_H) * 1.35))));
const transportReachRect = expandRect(coreRect, transportReachMargin, world);
return {
coreRect,
@@ -184,6 +268,7 @@ export function buildPatchRects(userRect, world = null) {
blendRect: coreRect,
userRect: writeRect,
selectedRect: coreRect,
+ selectionShape: isPolygonSelection(coreRect) ? coreRect : null,
writeMargin,
repairMargin,
transportReachMargin,
@@ -195,10 +280,21 @@ export function buildPatchRects(userRect, world = null) {
function patchAlpha(x, y, rects, seed = 0) {
const writeRect = rects.writeRect || rects.userRect;
if (!insideRect(x, y, writeRect)) return 0;
- const edge = distanceToRectEdge(x, y, writeRect);
const margin = Math.max(1, rects.writeMargin || 1);
const low = valueNoise(x, y, seed ^ 0x7153a9d1, 18) - 0.5;
const mid = valueNoise(x, y, seed ^ 0x9e3779b9, 7) - 0.5;
+ const shape = rects.selectionShape;
+ if (shape?.polygon?.length >= 3) {
+ const px = x + 0.5;
+ const py = y + 0.5;
+ const inside = pointInPolygon(px, py, shape.polygon);
+ const dist = distanceToPolygonEdge(px, py, shape.polygon);
+ const noisyDist = dist + low * margin * 0.28 + mid * margin * 0.10;
+ if (inside) return 1;
+ if (noisyDist >= margin * 1.08) return 0;
+ return clamp(smoothstep(1 - noisyDist / Math.max(1e-6, margin)));
+ }
+ const edge = distanceToRectEdge(x, y, writeRect);
const noisyEdge = edge + low * margin * 0.42 + mid * margin * 0.16;
const base = smoothstep(clamp(noisyEdge / margin));
// Keep the expanded repair band as the actual seam. The user's selected core
@@ -251,105 +347,253 @@ function fieldIdOffset(name, seed) {
return base + ((seed >>> 0) % 997) * 10000;
}
-function offsetFieldValue(name, raw, seed) {
- if (!Number.isFinite(raw) || raw < 0) return raw;
- const offset = fieldIdOffset(name, seed);
- return offset ? raw + offset : raw;
+function maxFieldId(field) {
+ if (!field) return -1;
+ let max = -1;
+ for (let i = 0; i < field.length; i++) {
+ const id = field[i];
+ if (Number.isFinite(id) && id > max) max = id;
+ }
+ return max;
}
-function isContinuityTransitionCell(x, y, rects, seed = 0, mode = "normal") {
- if (!insideRect(x, y, rects.writeRect)) return false;
- const edge = distanceToRectEdge(x, y, rects.writeRect);
- const margin = Math.max(2, rects.writeMargin || 1);
- const alpha = patchAlpha(x, y, rects, seed);
- const edgeLimit = mode === "prefecture" ? margin * 2.15 : mode === "admin" ? margin * 1.75 : margin * 1.45;
- const alphaLimit = mode === "prefecture" ? 0.995 : mode === "admin" ? 0.985 : 0.96;
- return edge <= edgeLimit || alpha < alphaLimit;
+function addMappingVote(votes, from, to, weight = 1) {
+ if (!Number.isFinite(from) || from < 0 || !Number.isFinite(to) || to < 0) return;
+ const key = Math.floor(from);
+ const target = Math.floor(to);
+ const bucket = votes.get(key) || new Map();
+ bucket.set(target, (bucket.get(target) || 0) + Math.max(1, weight));
+ votes.set(key, bucket);
}
-function addCount(bucket, key, amount = 1) {
- if (!Number.isFinite(key) || key < 0) return;
- bucket.set(key, (bucket.get(key) || 0) + amount);
-}
-
-function buildContinuityIdMappings(world, candidate, rects, window, oldFields, seed = 0) {
- const out = new Map();
- const debug = { continuityIdMappings: 0, continuityIdMappedCells: 0 };
- const dirs = [[0,0],[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
-
- for (const name of CONTINUITY_FIELD_NAMES) {
- const source = candidate?.[name];
- const old = oldFields?.get(name) || world.fields?.[name];
- if (!source || !old || !isCellField(source)) continue;
- const mode = name === "prefectureRegionId" ? "prefecture" : (name === "adminId" || name === "municipalityId") ? "admin" : "natural";
- const contacts = new Map();
-
- for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
- for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
- if (!isContinuityTransitionCell(x, y, rects, seed, mode)) continue;
- const s = sourceCoordForWorld(window, x, y);
- const si = sourceIndex(s.x, s.y);
- if (si < 0) continue;
- const from = offsetFieldValue(name, source[si], seed);
- if (!Number.isFinite(from) || from < 0) continue;
- const bucket = contacts.get(from) || new Map();
- for (const [dx, dy] of dirs) {
- const nx = x + dx, ny = y + dy;
- const wi = worldIndex(world, nx, ny);
- if (wi < 0 || old[wi] < 0) continue;
- const outsideWrite = !insideRect(nx, ny, rects.writeRect);
- const weakPatch = insideRect(nx, ny, rects.writeRect) && patchAlpha(nx, ny, rects, seed) < (mode === "prefecture" ? 0.96 : 0.88);
- const edgeWeight = outsideWrite ? 6 : weakPatch ? 3 : (dx || dy ? 1 : 2);
- addCount(bucket, old[wi], edgeWeight);
- }
- contacts.set(from, bucket);
- }
- }
-
- const mapping = new Map();
- for (const [from, bucket] of contacts) {
- let total = 0;
- let best = -1;
- let bestCount = 0;
- for (const [to, count] of bucket) {
- total += count;
- if (count > bestCount) { best = to; bestCount = count; }
- }
- const minCount = mode === "prefecture" ? 10 : mode === "admin" ? 8 : 5;
- const minShare = mode === "prefecture" ? 0.42 : mode === "admin" ? 0.48 : 0.36;
- if (best >= 0 && bestCount >= minCount && bestCount / Math.max(1, total) >= minShare) {
- mapping.set(from, best);
- }
- }
- if (mapping.size) {
- out.set(name, mapping);
- debug.continuityIdMappings += mapping.size;
+function chooseVotedTarget(bucket, minVotes = 1) {
+ let best = -1;
+ let bestVotes = 0;
+ for (const [target, count] of bucket || []) {
+ if (count > bestVotes || (count === bestVotes && target < best)) {
+ best = target;
+ bestVotes = count;
}
}
- out.debug = debug;
- return out;
+ return best >= 0 && bestVotes >= minVotes ? best : -1;
}
-function applyContinuityMapping(idMappings, name, value) {
- const map = idMappings?.get?.(name);
- return map && map.has(value) ? map.get(value) : value;
-}
-
-function pointCandidateContinuityIds(p, key, seed = 0) {
- const ids = [];
- if (!p) return ids;
- if (key === "adminCenters") {
- for (const raw of [p.id, p.adminId, p.adminNumericId, p.municipalityId]) {
- if (Number.isFinite(raw) && raw >= 0) ids.push(offsetFieldValue("adminId", raw, seed));
- }
- } else if (key === "prefectureRegions") {
- for (const raw of [p.id, p.prefectureRegionId]) {
- if (Number.isFinite(raw) && raw >= 0) ids.push(offsetFieldValue("prefectureRegionId", raw, seed));
+function collectCandidateIdsInRect(candidateField, rects, window, minAlpha = 0.20, seed = 0) {
+ const ids = new Set();
+ if (!candidateField) return ids;
+ const rect = rects.writeRect;
+ for (let y = rect.y0; y < rect.y1; y++) {
+ for (let x = rect.x0; x < rect.x1; x++) {
+ if (patchAlpha(x, y, rects, seed) < minAlpha) continue;
+ const s = sourceCoordForWorld(window, x, y);
+ const si = sourceIndex(s.x, s.y);
+ if (si < 0) continue;
+ const id = candidateField[si];
+ if (Number.isFinite(id) && id >= 0) ids.add(Math.floor(id));
}
}
return ids;
}
+function buildCandidatePrefByAdmin(candidateMap, candidateAdminIds) {
+ const out = new Map();
+ const direct = candidateMap?.municipalityToPrefectureId;
+ for (const id of candidateAdminIds || []) {
+ const pref = direct?.[id];
+ if (Number.isFinite(pref) && pref >= 0) out.set(id, Math.floor(pref));
+ }
+ const adminField = candidateMap?.adminId || candidateMap?.municipalityId;
+ const prefField = candidateMap?.prefectureRegionId;
+ if (!adminField || !prefField) return out;
+ const votes = new Map();
+ for (let i = 0; i < adminField.length; i++) {
+ const admin = adminField[i];
+ const pref = prefField[i];
+ if (!Number.isFinite(admin) || admin < 0 || !Number.isFinite(pref) || pref < 0) continue;
+ if (candidateAdminIds?.size && !candidateAdminIds.has(Math.floor(admin))) continue;
+ addMappingVote(votes, admin, pref, 1);
+ }
+ for (const [admin, bucket] of votes) {
+ if (!out.has(admin)) {
+ const pref = chooseVotedTarget(bucket, 1);
+ if (pref >= 0) out.set(admin, pref);
+ }
+ }
+ return out;
+}
+
+export function buildAdminIdMapping({ candidateMap, world, writeRect, seamBand = 24, window = null, rects = null, seed = 0 } = {}) {
+ const actualWindow = window || (rects ? sourceWindowForRects(rects) : null);
+ const actualRects = rects || { writeRect, writeMargin: seamBand || 1 };
+ if (!candidateMap || !world || !writeRect || !actualWindow) {
+ return {
+ prefecture: new Map(),
+ municipality: new Map(),
+ admin: new Map(),
+ candidateAdminToPrefecture: new Map(),
+ debug: { prefecturesMappedToExisting: 0, prefecturesAllocated: 0, municipalitiesMappedToExisting: 0, municipalitiesAllocated: 0 },
+ };
+ }
+
+ const candidateAdmin = candidateMap.adminId || candidateMap.municipalityId;
+ const candidateMunicipality = candidateMap.municipalityId || candidateAdmin;
+ const candidatePrefecture = candidateMap.prefectureRegionId;
+ const worldAdmin = world.fields?.adminId || world.fields?.municipalityId;
+ const worldMunicipality = world.fields?.municipalityId || worldAdmin;
+ const worldPrefecture = world.fields?.prefectureRegionId;
+
+ const candidateAdminIds = collectCandidateIdsInRect(candidateAdmin, actualRects, actualWindow, 0.18, seed);
+ const candidateMunicipalityIds = collectCandidateIdsInRect(candidateMunicipality, actualRects, actualWindow, 0.18, seed);
+ const candidatePrefectureIds = collectCandidateIdsInRect(candidatePrefecture, actualRects, actualWindow, 0.18, seed);
+ const candidateAdminToPrefecture = buildCandidatePrefByAdmin(candidateMap, candidateAdminIds);
+
+ const adminVotes = new Map();
+ const municipalityVotes = new Map();
+ const prefectureVotes = new Map();
+ const band = Math.max(2, Math.floor(seamBand));
+ const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
+
+ for (let y = writeRect.y0; y < writeRect.y1; y++) {
+ for (let x = writeRect.x0; x < writeRect.x1; x++) {
+ const edge = distanceToRectEdge(x, y, writeRect);
+ if (edge > band) continue;
+ const s = sourceCoordForWorld(actualWindow, x, y);
+ const si = sourceIndex(s.x, s.y);
+ const wi = worldIndex(world, x, y);
+ if (si < 0 || wi < 0) continue;
+ const cAdmin = candidateAdmin?.[si] ?? -1;
+ const cMunicipality = candidateMunicipality?.[si] ?? cAdmin;
+ const cPrefecture = candidatePrefecture?.[si] ?? -1;
+ const sameCellWeight = Math.max(1, band + 1 - edge);
+ addMappingVote(adminVotes, cAdmin, worldAdmin?.[wi] ?? -1, sameCellWeight);
+ addMappingVote(municipalityVotes, cMunicipality, worldMunicipality?.[wi] ?? worldAdmin?.[wi] ?? -1, sameCellWeight);
+ addMappingVote(prefectureVotes, cPrefecture, worldPrefecture?.[wi] ?? -1, sameCellWeight);
+
+ for (const [dx, dy] of dirs) {
+ for (let step = 1; step <= 6; step++) {
+ const nx = x + dx * step;
+ const ny = y + dy * step;
+ if (insideRect(nx, ny, writeRect)) continue;
+ const ni = worldIndex(world, nx, ny);
+ if (ni < 0) break;
+ const w = Math.max(1, 7 - step) + Math.max(0, band - edge) * 0.25;
+ addMappingVote(adminVotes, cAdmin, worldAdmin?.[ni] ?? -1, w);
+ addMappingVote(municipalityVotes, cMunicipality, worldMunicipality?.[ni] ?? worldAdmin?.[ni] ?? -1, w);
+ addMappingVote(prefectureVotes, cPrefecture, worldPrefecture?.[ni] ?? -1, w);
+ break;
+ }
+ }
+ }
+ }
+
+ const prefecture = new Map();
+ let nextPrefectureId = maxFieldId(worldPrefecture) + 1;
+ let prefecturesMappedToExisting = 0;
+ let prefecturesAllocated = 0;
+ for (const id of [...candidatePrefectureIds].sort((a, b) => a - b)) {
+ const voted = chooseVotedTarget(prefectureVotes.get(id), 3);
+ if (voted >= 0) {
+ prefecture.set(id, voted);
+ prefecturesMappedToExisting++;
+ } else {
+ prefecture.set(id, nextPrefectureId++);
+ prefecturesAllocated++;
+ }
+ }
+
+ const usedAdminIds = new Set();
+ if (worldAdmin) {
+ for (let i = 0; i < worldAdmin.length; i++) if (worldAdmin[i] >= 0) usedAdminIds.add(worldAdmin[i]);
+ }
+ const municipality = new Map();
+ const admin = new Map();
+ let nextMunicipalityId = maxFieldId(worldAdmin || worldMunicipality) + 1;
+ let municipalitiesMappedToExisting = 0;
+ let municipalitiesAllocated = 0;
+ const allMunicipalityIds = new Set([...candidateAdminIds, ...candidateMunicipalityIds]);
+ for (const id of [...allMunicipalityIds].sort((a, b) => a - b)) {
+ const voted = chooseVotedTarget(municipalityVotes.get(id) || adminVotes.get(id), 4);
+ if (voted >= 0) {
+ municipality.set(id, voted);
+ admin.set(id, voted);
+ municipalitiesMappedToExisting++;
+ } else {
+ while (usedAdminIds.has(nextMunicipalityId)) nextMunicipalityId++;
+ municipality.set(id, nextMunicipalityId);
+ admin.set(id, nextMunicipalityId);
+ usedAdminIds.add(nextMunicipalityId);
+ nextMunicipalityId++;
+ municipalitiesAllocated++;
+ }
+ }
+
+ const municipalityToPrefecture = new Map();
+ for (const [candidateAdminId, worldAdminId] of admin) {
+ const candidatePrefId = candidateAdminToPrefecture.get(candidateAdminId);
+ const worldPrefId = prefecture.get(candidatePrefId);
+ if (Number.isFinite(worldAdminId) && Number.isFinite(worldPrefId)) municipalityToPrefecture.set(worldAdminId, worldPrefId);
+ }
+
+ return {
+ prefecture,
+ municipality,
+ admin,
+ candidateAdminToPrefecture,
+ municipalityToPrefecture,
+ debug: {
+ candidatePrefectureIds: candidatePrefectureIds.size,
+ candidateMunicipalityIds: allMunicipalityIds.size,
+ prefecturesMappedToExisting,
+ prefecturesAllocated,
+ municipalitiesMappedToExisting,
+ municipalitiesAllocated,
+ },
+ };
+}
+
+function remapAdminCandidateValue(name, raw, adminIdMapping) {
+ if (!Number.isFinite(raw) || raw < 0 || !adminIdMapping) return raw;
+ const id = Math.floor(raw);
+ if (name === "prefectureRegionId") return adminIdMapping.prefecture?.get(id) ?? raw;
+ if (name === "adminId") return adminIdMapping.admin?.get(id) ?? raw;
+ if (name === "municipalityId") return adminIdMapping.municipality?.get(id) ?? adminIdMapping.admin?.get(id) ?? raw;
+ return raw;
+}
+
+function numericFeatureId(point, keys) {
+ for (const key of keys) {
+ const value = point?.[key];
+ if (Number.isFinite(value) && value >= 0) return Math.floor(value);
+ }
+ return -1;
+}
+
+function summarizeIdMapping(mapping) {
+ return { ...(mapping?.debug || {}) };
+}
+
+function updateSourceAdminMetadata(sourceMap, adminIdMapping) {
+ if (!sourceMap || !adminIdMapping?.municipalityToPrefecture?.size) return 0;
+ let maxId = -1;
+ const current = sourceMap.municipalityToPrefectureId;
+ if (current && typeof current.length === "number") maxId = Math.max(maxId, current.length - 1);
+ for (const [adminId] of adminIdMapping.municipalityToPrefecture) maxId = Math.max(maxId, adminId);
+ const next = new Int32Array(Math.max(0, maxId + 1));
+ next.fill(-1);
+ if (current && typeof current.length === "number") {
+ for (let i = 0; i < current.length && i < next.length; i++) next[i] = current[i] ?? -1;
+ }
+ let updated = 0;
+ for (const [adminId, prefId] of adminIdMapping.municipalityToPrefecture) {
+ if (!Number.isFinite(adminId) || adminId < 0 || !Number.isFinite(prefId) || prefId < 0) continue;
+ if (next[adminId] !== prefId) updated++;
+ next[adminId] = prefId;
+ }
+ sourceMap.municipalityToPrefectureId = next;
+ sourceMap.patchAdminIdMappingDebug = summarizeIdMapping(adminIdMapping);
+ return updated;
+}
+
function cloneContinuityFields(world) {
const out = new Map();
for (const name of CONTINUITY_FIELD_NAMES) {
@@ -431,8 +675,15 @@ function copyFullPipelineFields(world, candidate, rects, seed) {
const window = sourceWindowForRects(rects);
const oldSea = world.fields.sea ? new Uint8Array(world.fields.sea) : null;
const oldContinuityFields = cloneContinuityFields(world);
- const continuityIdMappings = buildContinuityIdMappings(world, candidate, rects, window, oldContinuityFields, seed);
- let continuityIdMappedCells = 0;
+ const adminIdMapping = buildAdminIdMapping({
+ candidateMap: candidate,
+ world,
+ writeRect: rects.writeRect,
+ seamBand: Math.max(8, Math.floor(rects.writeMargin || 24)),
+ window,
+ rects,
+ seed,
+ });
let updatedCells = 0;
let coastCellsChanged = 0;
let terrainCellsFullyReplaced = 0;
@@ -462,12 +713,10 @@ function copyFullPipelineFields(world, candidate, rects, seed) {
const threshold = continuityReplaceThreshold(name, x, y, rects, seed);
if (alpha >= threshold) {
const raw = source[si];
- let value = idOffset && raw >= 0 ? raw + idOffset : raw;
- if (CONTINUITY_FIELD_NAMES.has(name)) {
- const mapped = applyContinuityMapping(continuityIdMappings, name, value);
- if (mapped !== value) continuityIdMappedCells++;
- value = mapped;
- }
+ const mapped = remapAdminCandidateValue(name, raw, adminIdMapping);
+ const value = (name === "adminId" || name === "municipalityId" || name === "prefectureRegionId")
+ ? mapped
+ : idOffset && raw >= 0 ? raw + idOffset : raw;
if (name === "sea" && oldSea && dest[wi] !== value) coastCellsChanged++;
if ((name === "naturalCompartmentId" || name === "watershedId" || name === "regionId") && dest[wi] !== value) naturalRegionsUpdated++;
if ((name === "adminId" || name === "municipalityId") && dest[wi] !== value) adminCellsReassigned++;
@@ -531,22 +780,184 @@ function copyFullPipelineFields(world, candidate, rects, seed) {
}
const continuityDebug = stabilizeContinuitySeam(world, rects, oldContinuityFields, seed);
+ if (world.fields.adminId && world.fields.municipalityId && !candidate?.municipalityId) {
+ for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
+ for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
+ const wi = worldIndex(world, x, y);
+ if (wi >= 0) world.fields.municipalityId[wi] = world.fields.adminId[wi];
+ }
+ }
+ }
return {
window,
- idMappings: continuityIdMappings,
updatedCells,
terrainCellsFullyReplaced,
coastCellsChanged,
naturalRegionsUpdated,
adminCellsReassigned,
landUseCellsUpdated,
- continuityIdMappedCells,
- continuityIdMappings: continuityIdMappings.debug?.continuityIdMappings || 0,
+ adminIdMapping,
+ adminIdMappingDebug: summarizeIdMapping(adminIdMapping),
...continuityDebug,
};
}
+
+function repairDisplayMasks(world, rects, seed = 0) {
+ const expected = world.width * world.height;
+ if (!world.fields.prefectureMask || world.fields.prefectureMask.length !== expected) world.fields.prefectureMask = new Uint8Array(expected);
+ if (!world.fields.landMask || world.fields.landMask.length !== expected) world.fields.landMask = new Uint8Array(expected);
+ if (!world.fields.humanRegionMask || world.fields.humanRegionMask.length !== expected) world.fields.humanRegionMask = new Uint8Array(expected);
+ const coverage = world.fields.prefectureMask;
+ const landMask = world.fields.landMask;
+ const humanMask = world.fields.humanRegionMask;
+ const sea = world.fields.sea;
+ let displayMaskUpdated = 0;
+ for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
+ for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0) continue;
+ const a = patchAlpha(x, y, rects, seed);
+ if (a <= 0.08) continue;
+ if (!coverage[i]) displayMaskUpdated++;
+ coverage[i] = 1;
+ const isSea = Boolean(sea?.[i]);
+ humanMask[i] = isSea ? 0 : 1;
+ landMask[i] = isSea ? 0 : 1;
+ }
+ }
+ return { displayMaskUpdated };
+}
+
+
+function nearestLandFieldValue(world, x, y, fieldName, rect, options = {}) {
+ const field = world.fields?.[fieldName];
+ const sea = world.fields?.sea;
+ if (!field) return -1;
+ const maxRadius = Math.max(1, Math.floor(options.maxRadius || 18));
+ const requiredPref = Number.isFinite(options.requiredPref) ? Math.floor(options.requiredPref) : null;
+ const prefField = world.fields?.prefectureRegionId;
+ for (let r = 1; r <= maxRadius; r++) {
+ let best = -1;
+ let bestD = Infinity;
+ const y0 = Math.max(0, y - r);
+ const y1 = Math.min(world.height - 1, y + r);
+ const x0 = Math.max(0, x - r);
+ const x1 = Math.min(world.width - 1, x + r);
+ for (let yy = y0; yy <= y1; yy++) {
+ for (let xx = x0; xx <= x1; xx++) {
+ if (Math.max(Math.abs(xx - x), Math.abs(yy - y)) !== r) continue;
+ if (rect && !insideRect(xx, yy, rect)) continue;
+ const i = worldIndex(world, xx, yy);
+ if (i < 0 || sea?.[i]) continue;
+ if (requiredPref !== null && prefField?.[i] !== requiredPref) continue;
+ const id = field[i];
+ if (!Number.isFinite(id) || id < 0) continue;
+ const d = Math.hypot(xx - x, yy - y);
+ if (d < bestD) { best = Math.floor(id); bestD = d; }
+ }
+ }
+ if (best >= 0) return best;
+ }
+ return -1;
+}
+
+function lookupPrefectureForAdmin(sourceMap, adminIdMapping, adminId) {
+ if (!Number.isFinite(adminId) || adminId < 0) return -1;
+ const id = Math.floor(adminId);
+ const mapped = adminIdMapping?.municipalityToPrefecture?.get(id);
+ if (Number.isFinite(mapped) && mapped >= 0) return Math.floor(mapped);
+ const table = sourceMap?.municipalityToPrefectureId;
+ if (table && id >= 0 && id < table.length && Number.isFinite(table[id]) && table[id] >= 0) return Math.floor(table[id]);
+ return -1;
+}
+
+function repairAdminCoverage(world, sourceMap, rects, adminIdMapping = null) {
+ const admin = world.fields?.adminId;
+ if (!admin) return { seaAdminCellsCleared: 0, landAdminCellsFilled: 0, prefectureCellsFilled: 0, adminPrefectureCellsAligned: 0 };
+ const expected = world.width * world.height;
+ if (!world.fields.municipalityId || world.fields.municipalityId.length !== expected) {
+ world.fields.municipalityId = new Int32Array(expected);
+ world.fields.municipalityId.fill(-1);
+ }
+ if (!world.fields.prefectureRegionId || world.fields.prefectureRegionId.length !== expected) {
+ world.fields.prefectureRegionId = new Int32Array(expected);
+ world.fields.prefectureRegionId.fill(-1);
+ }
+ const municipality = world.fields.municipalityId;
+ const prefecture = world.fields.prefectureRegionId;
+ const sea = world.fields.sea;
+ const coverage = world.fields.prefectureMask;
+ let seaAdminCellsCleared = 0;
+ let landAdminCellsFilled = 0;
+ let prefectureCellsFilled = 0;
+ let adminPrefectureCellsAligned = 0;
+
+ for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
+ for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0) continue;
+ if (sea?.[i]) {
+ if (admin[i] >= 0 || municipality[i] >= 0 || prefecture[i] >= 0) seaAdminCellsCleared++;
+ admin[i] = -1;
+ municipality[i] = -1;
+ prefecture[i] = -1;
+ continue;
+ }
+ const generated = !coverage || coverage[i] || patchAlpha(x, y, rects, 0) > 0.08;
+ if (!generated) continue;
+
+ if (admin[i] < 0) {
+ const preferredPref = prefecture[i] >= 0 ? prefecture[i] : null;
+ let nearest = nearestLandFieldValue(world, x, y, 'adminId', rects.repairRect || rects.writeRect, { requiredPref: preferredPref, maxRadius: 24 });
+ if (nearest < 0) nearest = nearestLandFieldValue(world, x, y, 'adminId', null, { requiredPref: preferredPref, maxRadius: 18 });
+ if (nearest >= 0) {
+ admin[i] = nearest;
+ municipality[i] = nearest;
+ landAdminCellsFilled++;
+ }
+ }
+ if (municipality[i] < 0 && admin[i] >= 0) municipality[i] = admin[i];
+ if (admin[i] >= 0 && municipality[i] !== admin[i]) municipality[i] = admin[i];
+
+ let targetPref = lookupPrefectureForAdmin(sourceMap, adminIdMapping, admin[i]);
+ if (targetPref < 0 && prefecture[i] < 0) targetPref = nearestLandFieldValue(world, x, y, 'prefectureRegionId', rects.repairRect || rects.writeRect, { maxRadius: 28 });
+ if (targetPref >= 0 && prefecture[i] !== targetPref) {
+ if (prefecture[i] < 0) prefectureCellsFilled++;
+ else adminPrefectureCellsAligned++;
+ prefecture[i] = targetPref;
+ } else if (prefecture[i] < 0) {
+ const nearestPref = nearestLandFieldValue(world, x, y, 'prefectureRegionId', null, { maxRadius: 22 });
+ if (nearestPref >= 0) { prefecture[i] = nearestPref; prefectureCellsFilled++; }
+ }
+ }
+ }
+
+ const current = sourceMap?.municipalityToPrefectureId;
+ let maxId = current?.length ? current.length - 1 : -1;
+ for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
+ for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i >= 0 && admin[i] >= 0 && prefecture[i] >= 0) maxId = Math.max(maxId, admin[i]);
+ }
+ }
+ if (sourceMap && maxId >= 0) {
+ const next = new Int32Array(maxId + 1);
+ next.fill(-1);
+ if (current) for (let i = 0; i < current.length && i < next.length; i++) next[i] = current[i] ?? -1;
+ for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
+ for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i >= 0 && admin[i] >= 0 && prefecture[i] >= 0) next[admin[i]] = prefecture[i];
+ }
+ }
+ sourceMap.municipalityToPrefectureId = next;
+ }
+
+ return { seaAdminCellsCleared, landAdminCellsFilled, prefectureCellsFilled, adminPrefectureCellsAligned };
+}
+
function smoothWaterTopology(world, rect, seaLevel = 0.30) {
const sea = world.fields.sea;
const ocean = world.fields.ocean;
@@ -675,7 +1086,7 @@ function sourcePathFromWorld(world, path) {
return path.map(([x, y]) => [Math.round(x - world.originX), Math.round(y - world.originY)]);
}
-function transformCandidatePoint(world, window, p, key, seed = 0) {
+function transformCandidatePoint(world, window, p, key, seed = 0, adminIdMapping = null) {
if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y)) return null;
const w = worldCoordForSource(window, p.x, p.y);
if (key === "ports") {
@@ -689,18 +1100,39 @@ function transformCandidatePoint(world, window, p, key, seed = 0) {
}
const out = sourcePointFromWorld(world, { ...p, x: w.x, y: w.y });
if (key === "adminCenters") {
- const offset = fieldIdOffset("adminId", seed);
- if (Number.isFinite(out.id)) out.id += offset;
- if (Number.isFinite(out.adminId)) out.adminId += offset;
- if (Number.isFinite(out.adminNumericId)) out.adminNumericId += offset;
- if (Number.isFinite(out.municipalityId)) out.municipalityId += offset;
- if (!Number.isFinite(out.adminId) && Number.isFinite(out.municipalityId)) out.adminId = out.municipalityId;
- if (!Number.isFinite(out.municipalityId) && Number.isFinite(out.adminId)) out.municipalityId = out.adminId;
+ const rawAdminId = numericFeatureId(out, ["adminId", "municipalityId", "adminNumericId"]);
+ const mappedAdminId = rawAdminId >= 0
+ ? adminIdMapping?.admin?.get(rawAdminId) ?? adminIdMapping?.municipality?.get(rawAdminId)
+ : undefined;
+ if (Number.isFinite(mappedAdminId)) {
+ out.sourceAdminId = rawAdminId;
+ out.adminId = mappedAdminId;
+ out.adminNumericId = mappedAdminId;
+ out.municipalityId = mappedAdminId;
+ const candidatePrefId = adminIdMapping?.candidateAdminToPrefecture?.get(rawAdminId);
+ const mappedPrefId = adminIdMapping?.prefecture?.get(candidatePrefId);
+ if (Number.isFinite(mappedPrefId)) out.prefectureRegionId = mappedPrefId;
+ } else {
+ const offset = fieldIdOffset("adminId", seed);
+ if (Number.isFinite(out.adminId)) out.adminId += offset;
+ if (Number.isFinite(out.adminNumericId)) out.adminNumericId += offset;
+ if (Number.isFinite(out.municipalityId)) out.municipalityId += offset;
+ if (!Number.isFinite(out.adminId) && Number.isFinite(out.municipalityId)) out.adminId = out.municipalityId;
+ if (!Number.isFinite(out.municipalityId) && Number.isFinite(out.adminId)) out.municipalityId = out.adminId;
+ }
}
if (key === "prefectureRegions") {
- const offset = fieldIdOffset("prefectureRegionId", seed);
- if (Number.isFinite(out.id)) out.id += offset;
- if (Number.isFinite(out.prefectureRegionId)) out.prefectureRegionId += offset;
+ const rawPrefectureId = numericFeatureId(out, ["prefectureRegionId", "id"]);
+ const mappedPrefectureId = rawPrefectureId >= 0 ? adminIdMapping?.prefecture?.get(rawPrefectureId) : undefined;
+ if (Number.isFinite(mappedPrefectureId)) {
+ out.sourcePrefectureRegionId = rawPrefectureId;
+ out.id = mappedPrefectureId;
+ out.prefectureRegionId = mappedPrefectureId;
+ } else {
+ const offset = fieldIdOffset("prefectureRegionId", seed);
+ if (Number.isFinite(out.id)) out.id += offset;
+ if (Number.isFinite(out.prefectureRegionId)) out.prefectureRegionId += offset;
+ }
}
if (key === "logisticsParks") sanitizeLogisticsPark(out);
return out;
@@ -890,32 +1322,71 @@ function collectInternalNetworkPoints(world, sourceMap, keys, rect, mode = "road
return points;
}
-function connectAnchors(world, sourceMap, anchors, mode, rect) {
+function rectDistance(x, y, rect) {
+ if (insideRect(x, y, rect)) return 0;
+ const dx = x < rect.x0 ? rect.x0 - x : x >= rect.x1 ? x - rect.x1 + 1 : 0;
+ const dy = y < rect.y0 ? rect.y0 - y : y >= rect.y1 ? y - rect.y1 + 1 : 0;
+ return Math.hypot(dx, dy);
+}
+
+function collectExternalNetworkAnchors(world, sourceMap, keys, writeRect, reachRect, mode = "road") {
+ const candidates = [];
+ const seen = new Set();
+ const step = mode === "rail" ? 6 : 4;
+ for (const key of keys) {
+ for (const path of sourceMap[key] || []) {
+ for (let i = 0; i < path.length; i += step) {
+ const x = Math.round(tupleWorldX(world, path[i]));
+ const y = Math.round(tupleWorldY(world, path[i]));
+ if (!insideRect(x, y, reachRect) || insideRect(x, y, writeRect) || !isLand(world, x, y)) continue;
+ const d = rectDistance(x, y, writeRect);
+ if (d < 4 || d > (mode === "rail" ? 380 : 420)) continue;
+ const sig = `${x},${y},${mode}`;
+ if (seen.has(sig)) continue;
+ seen.add(sig);
+ candidates.push({ x, y, mode, external: true, d });
+ }
+ }
+ }
+ candidates.sort((a, b) => a.d - b.d);
+ return candidates.slice(0, mode === "rail" ? 18 : 28);
+}
+
+function connectAnchors(world, sourceMap, anchors, mode, rect, preferredTargetRect = null) {
const keys = mode === "rail" ? ["railways", "branchRailways"] : ["nationalRoads", "minorRoads", "premodernRoads"];
- const targets = collectInternalNetworkPoints(world, sourceMap, keys, rect, mode);
+ const allTargets = collectInternalNetworkPoints(world, sourceMap, keys, rect, mode);
+ const preferredTargets = preferredTargetRect ? allTargets.filter((p) => insideRect(p.x, p.y, preferredTargetRect)) : [];
+ const targets = preferredTargets.length ? preferredTargets : allTargets;
if (!targets.length) return { connectors: 0, disconnected: anchors.length };
let connectors = 0;
let disconnected = 0;
const layer = mode === "rail" ? "branchRailways" : "minorRoads";
sourceMap[layer] ||= [];
const seen = new Set();
- const maxRange = mode === "rail" ? 260 : 300;
+ const maxRange = mode === "rail" ? 320 : 360;
for (const raw of anchors) {
const anchorLand = nearestLand(world, raw.x, raw.y, rect, 18);
if (!anchorLand) { disconnected++; continue; }
- const target = targets
+ const targetList = targets
.map((p) => ({ ...p, d: Math.hypot(p.x - anchorLand.x, p.y - anchorLand.y) }))
- .filter((p) => p.d <= maxRange)
- .sort((a, b) => (a.d / Math.max(0.6, a.weight || 1)) - (b.d / Math.max(0.6, b.weight || 1)))[0];
- if (!target) { disconnected++; continue; }
- const sig = `${anchorLand.x},${anchorLand.y}:${target.x},${target.y}:${mode}`;
- if (seen.has(sig)) continue;
- seen.add(sig);
- const searchRect = expandRect(rect, 8, world);
- const path = localPathfind(world, anchorLand, target, searchRect, mode, 42000);
- if (!path || path.length < 2) { disconnected++; continue; }
- sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(path, mode === "rail" ? 3 : 2)));
- connectors++;
+ .filter((p) => p.d <= maxRange && p.d >= 6)
+ .sort((a, b) => (a.d / Math.max(0.6, a.weight || 1)) - (b.d / Math.max(0.6, b.weight || 1)))
+ .slice(0, 5);
+ if (!targetList.length) { disconnected++; continue; }
+ let made = false;
+ for (const target of targetList) {
+ const sig = `${anchorLand.x},${anchorLand.y}:${target.x},${target.y}:${mode}`;
+ if (seen.has(sig)) continue;
+ const searchRect = expandRect(rect, 24, world);
+ const path = localPathfind(world, anchorLand, target, searchRect, mode, mode === "rail" ? 62000 : 76000);
+ if (!path || path.length < 2) continue;
+ seen.add(sig);
+ sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(path, mode === "rail" ? 3 : 2)));
+ connectors++;
+ made = true;
+ break;
+ }
+ if (!made) disconnected++;
}
return { connectors, disconnected };
}
@@ -947,12 +1418,12 @@ function ensureSettlementRoadCoverage(world, sourceMap, rect) {
for (const p of sourceMap[key] || []) {
const start = nearestLand(world, pointWorldX(world, p), pointWorldY(world, p), rect, 10);
if (!start || !insideRect(start.x, start.y, rect)) continue;
- const target = nearestNetworkPoint(world, sourceMap, keys, start, rect, key === "villages" ? 36 : 58);
+ const target = nearestNetworkPoint(world, sourceMap, keys, start, rect, key === "villages" ? 72 : 132);
if (!target || Math.hypot(target.x - start.x, target.y - start.y) < 5) continue;
const sig = `${start.x},${start.y}:${target.x},${target.y}`;
if (seen.has(sig)) continue;
seen.add(sig);
- const path = localPathfind(world, start, target, rect, "road", 28000);
+ const path = localPathfind(world, start, target, rect, "road", 64000);
if (!path || path.length < 2) continue;
sourceMap.minorRoads.push(sourcePathFromWorld(world, simplifyPath(path, 2)));
connectors++;
@@ -961,7 +1432,39 @@ function ensureSettlementRoadCoverage(world, sourceMap, rect) {
return connectors;
}
-function mergePointLayers(world, sourceMap, candidate, rects, window, seed, idMappings = null) {
+function dedupeAdminCentersByWorldId(kept, generated) {
+ const out = [...kept];
+ const seen = new Set();
+ for (const p of kept) {
+ const id = numericFeatureId(p, ["adminId", "municipalityId", "adminNumericId"]);
+ if (id >= 0) seen.add(id);
+ }
+ for (const p of generated) {
+ const id = numericFeatureId(p, ["adminId", "municipalityId", "adminNumericId"]);
+ if (id >= 0 && seen.has(id)) continue;
+ if (id >= 0) seen.add(id);
+ out.push(p);
+ }
+ return out;
+}
+
+function dedupePrefectureRegionsByWorldId(kept, generated) {
+ const out = [...kept];
+ const seen = new Set();
+ for (const p of kept) {
+ const id = numericFeatureId(p, ["prefectureRegionId", "id"]);
+ if (id >= 0) seen.add(id);
+ }
+ for (const p of generated) {
+ const id = numericFeatureId(p, ["prefectureRegionId", "id"]);
+ if (id >= 0 && seen.has(id)) continue;
+ if (id >= 0) seen.add(id);
+ out.push(p);
+ }
+ return out;
+}
+
+function mergePointLayers(world, sourceMap, candidate, rects, window, seed, adminIdMapping = null) {
let preservedExternalEntities = 0;
let regeneratedInternalEntities = 0;
let invalidPortsRemoved = 0;
@@ -982,14 +1485,7 @@ function mergePointLayers(world, sourceMap, candidate, rects, window, seed, idMa
}
const generated = [];
for (const p of candidate[key] || []) {
- const candidateIds = pointCandidateContinuityIds(p, key, seed);
- if (key === "adminCenters" && candidateIds.some((id) => applyContinuityMapping(idMappings, "adminId", id) !== id || applyContinuityMapping(idMappings, "municipalityId", id) !== id)) {
- continue;
- }
- if (key === "prefectureRegions" && candidateIds.some((id) => applyContinuityMapping(idMappings, "prefectureRegionId", id) !== id)) {
- continue;
- }
- const q = transformCandidatePoint(world, window, p, key, seed);
+ const q = transformCandidatePoint(world, window, p, key, seed, adminIdMapping);
if (!q) continue;
const wx = Math.round(pointWorldX(world, q));
const wy = Math.round(pointWorldY(world, q));
@@ -997,8 +1493,10 @@ function mergePointLayers(world, sourceMap, candidate, rects, window, seed, idMa
if (patchAlpha(wx, wy, rects, seed) < 0.42) continue;
generated.push(q);
}
- sourceMap[key] = [...kept, ...generated];
- regeneratedInternalEntities += generated.length;
+ if (key === "adminCenters") sourceMap[key] = dedupeAdminCentersByWorldId(kept, generated);
+ else if (key === "prefectureRegions") sourceMap[key] = dedupePrefectureRegionsByWorldId(kept, generated);
+ else sourceMap[key] = [...kept, ...generated];
+ regeneratedInternalEntities += Math.max(0, sourceMap[key].length - kept.length);
}
return { preservedExternalEntities, regeneratedInternalEntities, invalidPortsRemoved };
}
@@ -1031,8 +1529,12 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) {
sourceMap[key] = next;
}
const transportRect = rects.transportReachRect || rects.repairRect || rects.writeRect;
- const roadConn = connectAnchors(world, sourceMap, roadAnchors, "road", transportRect);
- const railConn = connectAnchors(world, sourceMap, railAnchors, "rail", transportRect);
+ const externalRoadAnchors = collectExternalNetworkAnchors(world, sourceMap, ["nationalRoads", "minorRoads", "premodernRoads", "externalRoads"], rects.writeRect, transportRect, "road");
+ const externalRailAnchors = collectExternalNetworkAnchors(world, sourceMap, ["railways", "branchRailways", "externalRailways"], rects.writeRect, transportRect, "rail");
+ roadAnchors = roadAnchors.concat(externalRoadAnchors);
+ railAnchors = railAnchors.concat(externalRailAnchors);
+ const roadConn = connectAnchors(world, sourceMap, roadAnchors, "road", transportRect, rects.writeRect);
+ const railConn = connectAnchors(world, sourceMap, railAnchors, "rail", transportRect, rects.writeRect);
const settlementRoadConnectors = ensureSettlementRoadCoverage(world, sourceMap, transportRect);
return {
roadsClipped,
@@ -1042,6 +1544,8 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) {
railwayConnectorsCreated: railConn.connectors,
disconnectedRoadComponents: roadConn.disconnected,
disconnectedRailComponents: railConn.disconnected,
+ externalRoadAnchors: externalRoadAnchors.length,
+ externalRailAnchors: externalRailAnchors.length,
};
}
@@ -1088,7 +1592,7 @@ function mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rec
const b = worldCoordForSource(window, seg[1]?.[0], seg[1]?.[1]);
const mx = (a.x + b.x) * 0.5;
const my = (a.y + b.y) * 0.5;
- if (!insideRect(mx, my, rects.writeRect) || patchAlpha(mx, my, rects, seed) < 0.38) continue;
+ if (!insideRect(mx, my, rects.writeRect) || patchAlpha(mx, my, rects, seed) < 0.08) continue;
debug.compartmentBorders.push(sourcePathFromWorld(world, [[a.x, a.y], [b.x, b.y]]));
added++;
}
@@ -1108,8 +1612,11 @@ function mergeSegmentLayers(world, sourceMap, rects, seed = 0) {
sourceMap.prefectureBorder ||= [];
const debug = sourceMap.adminDebug || {};
+ // Use the legacy full-pipeline compartment debug segments for regenerated
+ // areas. Rebuilding directly from the raster field made patch compartments
+ // look denser/smaller than the initial map. Candidate segments are merged
+ // just after this function.
debug.compartmentBorders = (debug.compartmentBorders || []).filter((seg) => !segmentTouchesRect(world, seg, rects.writeRect));
- debug.compartmentBorders.push(...buildBoundarySegmentsFromField(world, "naturalCompartmentId", rects.writeRect, { rects, seed, minAlpha: 0.40 }));
sourceMap.adminDebug = debug;
return {
adminBordersRebuilt: sourceMap.adminBorders.length,
@@ -1172,18 +1679,37 @@ export function generatePatch(world, userRectInput, options = {}) {
const terrainType = options.terrainType || "auto";
const seed = Number.isFinite(options.seed) ? options.seed >>> 0 : ((world?.seed || 0) + 1013904223) >>> 0;
const variant = Number.isFinite(options.variant) ? Math.max(0, Math.floor(options.variant)) >>> 0 : 0;
- const candidate = generateMap(seed, { terrainType, legacyTerrain: true, onProgress: () => {} });
+ const candidateWindow = sourceWindowForRects(rects);
+ const candidateOriginX = Math.round(candidateWindow.worldCenterX - candidateWindow.sourceCenterX);
+ const candidateOriginY = Math.round(candidateWindow.worldCenterY - candidateWindow.sourceCenterY);
+ const candidate = generateMap(seed, {
+ terrainType,
+ legacyTerrain: true,
+ worldNative: true,
+ variant,
+ originX: candidateOriginX,
+ originY: candidateOriginY,
+ width: MAP_W,
+ height: MAP_H,
+ contextRect: rects.contextRect,
+ boundaryWorld: world,
+ onProgress: () => {},
+ });
const sourceMap = world.sourceMap || (world.sourceMap = {});
const logisticsLabelsMigrated = sanitizeExistingLogistics(sourceMap);
const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed);
const waterDebug = smoothWaterTopology(world, rects.writeRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30);
+ const maskDebug = repairDisplayMasks(world, rects, seed);
recomputeSlopeAndWaterDependentFields(world, rects.repairRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30);
- const pointDebug = mergePointLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed, fieldDebug.idMappings);
+ const sourceAdminMetadataUpdated = updateSourceAdminMetadata(sourceMap, fieldDebug.adminIdMapping);
+ const adminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, fieldDebug.adminIdMapping);
+ const pointDebug = mergePointLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed, fieldDebug.adminIdMapping);
const pathDebug = mergePathLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed);
const segmentDebug = mergeSegmentLayers(world, sourceMap, rects, seed);
const candidateCompartmentSegmentsAdded = mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rects, fieldDebug.window, seed);
const landDebug = repairLanduseAndPopulation(world, rects);
+ const finalAdminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, fieldDebug.adminIdMapping);
sanitizeExistingLogistics(sourceMap);
const seaStats = countSea(world, rects.coreRect);
@@ -1197,11 +1723,17 @@ export function generatePatch(world, userRectInput, options = {}) {
...pointDebug,
...pathDebug,
adminCellsReassigned: fieldDebug.adminCellsReassigned,
+ adminIdMapping: fieldDebug.adminIdMappingDebug,
+ sourceAdminMetadataUpdated,
+ ...adminCoverageDebug,
+ finalSeaAdminCellsCleared: finalAdminCoverageDebug.seaAdminCellsCleared || 0,
+ finalLandAdminCellsFilled: finalAdminCoverageDebug.landAdminCellsFilled || 0,
+ finalPrefectureCellsFilled: finalAdminCoverageDebug.prefectureCellsFilled || 0,
+ finalAdminPrefectureCellsAligned: finalAdminCoverageDebug.adminPrefectureCellsAligned || 0,
continuityCellsRestored: fieldDebug.continuityCellsRestored || 0,
continuityCellsRemapped: fieldDebug.continuityCellsRemapped || 0,
- continuityIdMappings: fieldDebug.continuityIdMappings || 0,
- continuityIdMappedCells: fieldDebug.continuityIdMappedCells || 0,
landUseCellsUpdated: fieldDebug.landUseCellsUpdated + landDebug.landUseCellsUpdated,
+ displayMaskUpdated: maskDebug.displayMaskUpdated || 0,
logisticsLabelsMigrated,
...segmentDebug,
candidateCompartmentSegmentsAdded,
@@ -1210,6 +1742,11 @@ export function generatePatch(world, userRectInput, options = {}) {
const record = {
...rects.coreRect,
coreRect: { ...rects.coreRect },
+ selectionShape: rects.selectionShape ? {
+ kind: rects.selectionShape.kind || 'lasso',
+ areaCells: rects.selectionShape.areaCells || 0,
+ polygon: rects.selectionShape.polygon.map((p) => ({ x: p.x, y: p.y })),
+ } : null,
writeRect: { ...rects.writeRect },
repairRect: { ...rects.repairRect },
contextRect: { ...rects.contextRect },
@@ -1218,12 +1755,15 @@ export function generatePatch(world, userRectInput, options = {}) {
label,
seed,
variant,
+ candidateOriginX,
+ candidateOriginY,
patchGenerationMode: "legacy-full-pipeline",
updatedCells: fieldDebug.updatedCells,
terrainCellsFullyReplaced: fieldDebug.terrainCellsFullyReplaced,
coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged,
naturalRegionsUpdated: fieldDebug.naturalRegionsUpdated,
naturalRegionFragmentsMerged: 0,
+ adminIdMapping: fieldDebug.adminIdMappingDebug,
seaRatio: seaStats.seaRatio,
humanGeography,
createdAt: Date.now(),
@@ -1235,17 +1775,27 @@ export function generatePatch(world, userRectInput, options = {}) {
return {
ok: true,
validation,
- rects,
+ rects: {
+ ...rects,
+ selectionShape: rects.selectionShape ? {
+ kind: rects.selectionShape.kind || 'lasso',
+ areaCells: rects.selectionShape.areaCells || 0,
+ polygon: rects.selectionShape.polygon.map((p) => ({ x: p.x, y: p.y })),
+ } : null,
+ },
terrainType: id,
label,
seed,
variant,
+ candidateOriginX,
+ candidateOriginY,
patchGenerationMode: "legacy-full-pipeline",
updatedCells: record.updatedCells,
terrainCellsFullyReplaced: record.terrainCellsFullyReplaced,
coastCellsChanged: record.coastCellsChanged,
naturalRegionsUpdated: record.naturalRegionsUpdated,
naturalRegionFragmentsMerged: 0,
+ adminIdMapping: fieldDebug.adminIdMappingDebug,
seaRatio: seaStats.seaRatio,
humanGeography,
};
diff --git a/mapPipeline.js b/mapPipeline.js
index c1fba77..0aed38d 100644
--- a/mapPipeline.js
+++ b/mapPipeline.js
@@ -1,5 +1,5 @@
import { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
-import { generateInitialTerrainRect, generateTerrainAndRivers } from "./mapTerrain.js";
+import { generateTerrainAndRivers } from "./mapTerrain.js";
import { generateMapFeatures } from "./mapFeatures.js";
import { finishMapOutput } from "./mapOutput.js";
import { generateAdminLayout } from "./mapAdminStage.js";
@@ -8,19 +8,91 @@ 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 = {}) {
- // The original high-detail terrain system remains the default for full-map
- // generation. Rect-native terrain is available as an explicit option and is
- // used by patch generation, but the historical noise/natural-compartment
- // pipeline is still the visual baseline for ordinary maps.
- if (options?.rectNativeInitial === true) return generateInitialTerrainRect(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(options = {}) {
- return options?.rectNativeInitial === true
- ? "Rect-native terrain, rivers, and natural compartments"
- : "Terrain, rivers, and natural compartments";
+function terrainStageLabel() {
+ return "Terrain, rivers, and natural compartments";
}
function nowMs() {
@@ -60,7 +132,9 @@ 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 = [];
@@ -86,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,
@@ -109,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?.({
@@ -127,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,
@@ -139,11 +213,16 @@ 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 = [];
@@ -169,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,
@@ -192,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?.({
@@ -210,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,
@@ -222,5 +301,8 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
}));
output.generationTimings = generationTimings;
output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0);
+ output.baseSeed = options.baseSeed;
+ output.effectiveSeed = seed;
+ output.generationContext = { ...options.generationContext };
return output;
}
diff --git a/mapTerrain.js b/mapTerrain.js
index d11fc59..497084e 100644
--- a/mapTerrain.js
+++ b/mapTerrain.js
@@ -1816,6 +1816,11 @@ export function generateInitialTerrainRect(seed, 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);
@@ -1835,9 +1840,11 @@ export function generateTerrainAndRivers(seed, options = {}) {
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;
@@ -1862,7 +1869,7 @@ export function generateTerrainAndRivers(seed, options = {}) {
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);
@@ -1870,10 +1877,10 @@ export function generateTerrainAndRivers(seed, options = {}) {
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;
@@ -1886,7 +1893,7 @@ export function generateTerrainAndRivers(seed, options = {}) {
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
@@ -1897,7 +1904,7 @@ export function generateTerrainAndRivers(seed, options = {}) {
}
if (terrainTemplate.terrainType === "oceanic_archipelago") {
// 海洋型は大きな山塊ではなく、島列を読むための低い起伏を点在させる。
- const islandCore = clamp((fbm(x * 0.82 + 41, y * 0.82 - 29, seed + 572) - 0.46) * 2.7);
+ 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;
@@ -1910,7 +1917,7 @@ export function generateTerrainAndRivers(seed, options = {}) {
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);
}
}
@@ -1972,9 +1979,20 @@ export function generateTerrainAndRivers(seed, options = {}) {
mountainRatio: mountainCount / Math.max(1, landCount),
plainRatio: plainCount / Math.max(1, landCount),
mountainSystemCount: systems.length,
+ originX,
+ originY,
+ width: MAP_W,
+ height: MAP_H,
+ variant,
+ worldNative,
};
return {
+ originX,
+ originY,
+ width: MAP_W,
+ height: MAP_H,
+ generationContext: { ...generationContext, originX, originY, width: MAP_W, height: MAP_H, variant, worldNative },
terrainTemplate,
seaLevel,
elevation,
diff --git a/mapTransport.js b/mapTransport.js
index 3f0bdb0..887bd6f 100644
--- a/mapTransport.js
+++ b/mapTransport.js
@@ -10,12 +10,15 @@ import {
makeSpatialIndex,
makeUnionFind,
meanFieldAround as meanFieldAroundBase,
+ nearestNetworkPoint,
packDebugField,
pathCumulativeLengths,
pathAverageField,
+ pathEndpoints,
pathLengthCells,
pointAtPathDistance,
sampledNetworkCells as sampledNetworkCellsBase,
+ splitPathToValidCells as splitPathToValidCellsBase,
squaredDistance,
TRANSPORT_ROUTE_POLICIES,
} from "./mapTransportUtils.js";
@@ -1332,55 +1335,20 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
function stitchRasterNearContacts() {
const debug = { expressway: 0, national: 0, localToNational: 0, local: 0, broadNearMisses: 0 };
- function sampledCells(paths, step = 1) {
- const cells = [];
- for (let pathId = 0; pathId < (paths || []).length; pathId++) {
- const path = paths[pathId];
- for (let k = 0; k < path.length; k += step) {
- const [x, y] = path[k];
- if (inside(x, y) && !sea[indexOf(x, y)]) cells.push({ x, y, pathId });
- }
- }
- return cells;
- }
- function endpointListWithIds(paths) {
- const out = [];
- for (let pathId = 0; pathId < (paths || []).length; pathId++) {
- const p = paths[pathId];
- if (!p || p.length < 2) continue;
- out.push({ x: p[0][0], y: p[0][1], pathId });
- const q = p[p.length - 1];
- out.push({ x: q[0], y: q[1], pathId });
- }
- return out;
- }
- function nearestWithin(source, targets, radius, excludeSamePath = true) {
- let best = null;
- let bestD = radius + 1;
- for (const t of targets) {
- if (excludeSamePath && source.pathId != null && t.pathId === source.pathId) continue;
- const d = Math.hypot(source.x - t.x, source.y - t.y);
- if (d > 0.01 && d < bestD) {
- bestD = d;
- best = t;
- }
- }
- return best ? { target: best, d: bestD } : null;
- }
function stitchEndpoints(paths, mode, targets, maxAdds, radius, probability = 1) {
let added = 0;
- const endpoints = endpointListWithIds(paths);
+ const endpoints = pathEndpoints(paths);
for (const ep of endpoints) {
if (added >= maxAdds) break;
if (probability < 1 && hash2(ep.x, ep.y, seed + 18331 + added * 17) > probability) continue;
- const near = nearestWithin(ep, targets, radius, true);
+ const near = nearestNetworkPoint(ep, targets, radius);
if (near && addShortConnector(paths, ep, near.target, mode)) added++;
}
return added;
}
- const expresswayCells = sampledCells(expressways, 1);
- const nationalCells = sampledCells([...nationalRoads, ...externalRoads], 1);
- const localCells = sampledCells(minorRoads, 1);
+ const expresswayCells = sampledNetworkCellsBase(expressways, 1, sea);
+ const nationalCells = sampledNetworkCellsBase([...nationalRoads, ...externalRoads], 1, sea);
+ const localCells = sampledNetworkCellsBase(minorRoads, 1, sea);
debug.expressway += stitchEndpoints(expressways, "expressway", expresswayCells, 14, 46.0, 0.62);
debug.national += stitchEndpoints(nationalRoads, "national", nationalCells, 48, 14.0);
debug.localToNational += stitchEndpoints(minorRoads, "local", nationalCells, 240, 20.0);
@@ -1390,11 +1358,11 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
// not share a raster cell. This addresses line simplification and diagonal
// near-contact cases not caught by endpoint-only repair.
const allLocalTargets = [...nationalCells, ...localCells];
- const candidates = sampledCells([...nationalRoads, ...minorRoads], 3)
+ const candidates = sampledNetworkCellsBase([...nationalRoads, ...minorRoads], 3, sea)
.sort((a, b) => hash2(a.x, a.y, seed + 18377) - hash2(b.x, b.y, seed + 18377));
for (const c of candidates) {
if (debug.broadNearMisses >= 260) break;
- const near = nearestWithin(c, allLocalTargets, 4.75, true);
+ const near = nearestNetworkPoint(c, allLocalTargets, 4.75);
if (!near) continue;
const out = c.pathId < nationalRoads.length ? nationalRoads : minorRoads;
const mode = out === nationalRoads ? "national" : "local";
@@ -1410,56 +1378,21 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
function stitchLongLocalBranches() {
const debug = { localToTrunk: 0, localToLocal: 0 };
- function cells(paths, step = 2) {
- const out = [];
- for (let pathId = 0; pathId < (paths || []).length; pathId++) {
- const path = paths[pathId];
- for (let k = 0; k < path.length; k += step) {
- const [x, y] = path[k];
- if (inside(x, y) && !sea[indexOf(x, y)]) out.push({ x, y, pathId });
- }
- }
- return out;
- }
- function endpoints(paths) {
- const out = [];
- for (let pathId = 0; pathId < (paths || []).length; pathId++) {
- const p = paths[pathId];
- if (!p || p.length < 2) continue;
- out.push({ x: p[0][0], y: p[0][1], pathId });
- const q = p[p.length - 1];
- out.push({ x: q[0], y: q[1], pathId });
- }
- return out;
- }
- function nearest(source, targets, radius, excludePath = null) {
- let best = null;
- let bestD = radius + 1;
- for (const t of targets) {
- if (excludePath != null && t.pathId === excludePath) continue;
- const d = Math.hypot(source.x - t.x, source.y - t.y);
- if (d > 0.01 && d < bestD) {
- bestD = d;
- best = t;
- }
- }
- return best ? { ...best, d: bestD } : null;
- }
- const trunkCells = cells([...nationalRoads, ...externalRoads], 2);
- const localCells = cells(minorRoads, 2);
- const eps = endpoints(minorRoads)
+ const trunkCells = sampledNetworkCellsBase([...nationalRoads, ...externalRoads], 2, sea);
+ const localCells = sampledNetworkCellsBase(minorRoads, 2, sea);
+ const eps = pathEndpoints(minorRoads)
.sort((a, b) => hash2(a.x, a.y, seed + 18491) - hash2(b.x, b.y, seed + 18491));
for (const ep of eps) {
if (debug.localToTrunk >= 160) break;
- const hit = nearest(ep, trunkCells, 20, null);
+ const hit = nearestNetworkPoint(ep, trunkCells, 20, { excludeSamePath: false });
if (!hit) continue;
if (addShortConnector(minorRoads, ep, hit, "local")) debug.localToTrunk++;
}
- const eps2 = endpoints(minorRoads)
+ const eps2 = pathEndpoints(minorRoads)
.sort((a, b) => hash2(a.x, a.y, seed + 18493) - hash2(b.x, b.y, seed + 18493));
for (const ep of eps2) {
if (debug.localToLocal >= 150) break;
- const hit = nearest(ep, localCells, 17, ep.pathId);
+ const hit = nearestNetworkPoint(ep, localCells, 17, { excludePath: ep.pathId });
if (!hit) continue;
if (addShortConnector(minorRoads, ep, hit, "local")) debug.localToLocal++;
}
@@ -1929,33 +1862,11 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
return transportFields.local;
}
function splitPathToValidCells(path, costField, minCells = 2) {
- const chunks = [];
- let cur = [];
- function valid(x, y) {
+ return splitPathToValidCellsBase(path, (x, y) => {
if (!inside(x, y)) return false;
const i = indexOf(x, y);
return !sea[i] && !highAltitudeRoadClosed(i) && costField[i] < INF;
- }
- function pushPoint(x, y) {
- if (!valid(x, y)) {
- if (cur.length >= minCells) chunks.push(cur);
- cur = [];
- return;
- }
- if (!cur.length || cur[cur.length - 1][0] !== x || cur[cur.length - 1][1] !== y) cur.push([x, y]);
- }
- for (let k = 0; k < (path?.length || 0); k++) {
- const a = path[k];
- const b = path[Math.min(k + 1, path.length - 1)];
- const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])));
- for (let s = 0; s <= steps; s++) {
- if (k > 0 && s === 0) continue;
- const t = s / steps;
- pushPoint(Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t));
- }
- }
- if (cur.length >= minCells) chunks.push(cur);
- return chunks;
+ }, minCells);
}
function normalizeRoadGroups() {
const groups = roadGroups();
diff --git a/mapTransportUtils.js b/mapTransportUtils.js
index 749420c..580c49f 100644
--- a/mapTransportUtils.js
+++ b/mapTransportUtils.js
@@ -95,6 +95,61 @@ export function sampledNetworkCells(paths, step = 2, sea = null) {
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++) {
diff --git a/renderer.js b/renderer.js
index c756360..61ac178 100644
--- a/renderer.js
+++ b/renderer.js
@@ -1,12 +1,37 @@
-import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf, inside } from "./mapUtils.js";
+import { CELL_SIZE, MAP_H, MAP_W, clamp } from "./mapUtils.js";
const segmentVectorCache = new WeakMap();
const pathVectorCache = new WeakMap();
const coastlineCache = new WeakMap();
+const rasterBorderCache = new WeakMap();
const baseImageCache = new WeakMap();
const MAX_BASE_CACHE_IMAGES = 4;
+function mapWidth(map) {
+ return Math.max(1, Math.floor(Number.isFinite(map?.width) ? map.width : MAP_W));
+}
+
+function mapHeight(map) {
+ return Math.max(1, Math.floor(Number.isFinite(map?.height) ? map.height : MAP_H));
+}
+
+function cellIndex(map, x, y) {
+ return y * mapWidth(map) + x;
+}
+
+function insideMap(map, x, y) {
+ return x >= 0 && y >= 0 && x < mapWidth(map) && y < mapHeight(map);
+}
+
+function mapPixelWidth(map) {
+ return mapWidth(map) * CELL_SIZE;
+}
+
+function mapPixelHeight(map) {
+ return mapHeight(map) * CELL_SIZE;
+}
+
function pointKey(p) {
return `${p[0]},${p[1]}`;
}
@@ -183,16 +208,18 @@ function getCoastlineSegments(map) {
if (cached) return cached;
const segments = [];
- for (let y = 0; y < MAP_H; y++) {
- for (let x = 0; x < MAP_W; x++) {
- const i = indexOf(x, y);
+ const w = mapWidth(map);
+ const h = mapHeight(map);
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ const i = cellIndex(map, x, y);
const a = Boolean(map.sea[i]);
- if (x + 1 < MAP_W) {
- const b = Boolean(map.sea[indexOf(x + 1, y)]);
+ if (x + 1 < w) {
+ const b = Boolean(map.sea[cellIndex(map, x + 1, y)]);
if (a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
}
- if (y + 1 < MAP_H) {
- const b = Boolean(map.sea[indexOf(x, y + 1)]);
+ if (y + 1 < h) {
+ const b = Boolean(map.sea[cellIndex(map, x, y + 1)]);
if (a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
}
}
@@ -244,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) {
@@ -261,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);
}
@@ -293,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);
}
@@ -329,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),
@@ -352,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]],
@@ -378,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でも山腹の起伏を読ませる。
@@ -398,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);
@@ -419,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]) {
@@ -451,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);
@@ -491,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++) {
@@ -506,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) {
@@ -523,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;
@@ -558,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) {
@@ -660,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];
@@ -684,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;
@@ -744,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);
@@ -806,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";
@@ -849,11 +929,11 @@ function drawLabels(ctx, points, limit = Infinity, occupied = null) {
return used;
}
-function drawScaleBar(ctx) {
+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;
@@ -893,23 +973,33 @@ export function drawMap(canvas, map, options) {
const showFeatures = options.showFeatures !== false;
const showLabels = options.showLabels !== false;
const continuousTerrain = options.continuousTerrain !== false;
- const zoom = Math.min(Math.max(Number(options.zoom) || 1, 0.55), 2.8);
-
- const width = MAP_W * CELL_SIZE;
- const height = MAP_H * CELL_SIZE;
- if (canvas.width !== width) canvas.width = width;
- if (canvas.height !== height) canvas.height = height;
- ctx.clearRect(0, 0, width, height);
+ 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(width * (1 - zoom) * 0.5, height * (1 - zoom) * 0.5);
- ctx.scale(zoom, zoom);
+ ctx.translate(drawOffsetX, drawOffsetY);
+ ctx.scale(drawScale, drawScale);
+ ctx.__mapPixelWidth = sourceWidth;
+ ctx.__mapPixelHeight = sourceHeight;
const finish = () => {
ctx.restore();
- drawScaleBar(ctx);
+ drawScaleBar(ctx, cellScreenSize);
+ delete ctx.__mapPixelWidth;
+ delete ctx.__mapPixelHeight;
};
// 1. Base Terrain & Urban
- drawBase(ctx, map, mode, continuousTerrain);
+ 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 });
@@ -926,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) {
@@ -977,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
@@ -990,13 +1082,13 @@ 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 });
}
diff --git a/styles.css b/styles.css
index 4aa2bed..7ffa8ee 100644
--- a/styles.css
+++ b/styles.css
@@ -72,6 +72,9 @@ 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}
+.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}
diff --git a/worldMap.js b/worldMap.js
index 379adfe..4c5fc05 100644
--- a/worldMap.js
+++ b/worldMap.js
@@ -39,6 +39,24 @@ function makeWorldField(name, source, worldWidth, worldHeight, originX, originY)
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;
@@ -60,6 +78,7 @@ export function createWorldMap(initialMap, options = {}) {
fields.elevation = new Float32Array(worldWidth * worldHeight);
fields.elevation.fill(0.08);
}
+ sanitizeInitialWorldFields(fields, worldWidth, worldHeight);
return {
seed: initialMap?.seed ?? 0,
diff --git a/worldViewport.js b/worldViewport.js
index 8cb6272..448a1d9 100644
--- a/worldViewport.js
+++ b/worldViewport.js
@@ -68,8 +68,8 @@ function copyViewportField(name, source, world, camera, viewWidth, viewHeight) {
return out;
}
-function inViewportPoint(p, margin = 0) {
- return p && p.x >= -margin && p.y >= -margin && p.x < MAP_W + margin && p.y < MAP_H + margin;
+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) {
@@ -83,9 +83,9 @@ function transformPointObject(point, camera, originX, originY) {
};
}
-function transformPointArray(items, camera, originX, originY, margin = 36, preserveIndexes = false) {
+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));
+ return preserveIndexes ? mapped : mapped.filter((item) => inViewportPoint(item, margin, viewWidth, viewHeight));
}
function transformTuple(tuple, camera, originX, originY) {
@@ -93,16 +93,16 @@ function transformTuple(tuple, camera, originX, originY) {
return [tuple[0] + originX - camera.x, tuple[1] + originY - camera.y];
}
-function tupleInside(tuple, margin = 0) {
- return tuple && tuple[0] >= -margin && tuple[1] >= -margin && tuple[0] < MAP_W + margin && tuple[1] < MAP_H + margin;
+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) {
+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);
+ const inside = tupleInside(p, margin, viewWidth, viewHeight);
if (inside) {
current.push([Math.round(p[0]), Math.round(p[1])]);
} else if (current.length >= 2) {
@@ -116,23 +116,23 @@ function splitTransformedPath(path, camera, originX, originY, margin = 0) {
return chunks;
}
-function transformPaths(paths, camera, originX, originY) {
+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));
+ for (const path of paths || []) out.push(...splitTransformedPath(path, camera, originX, originY, 0, viewWidth, viewHeight));
return out;
}
-function segmentIntersectsViewport(seg, margin = 4) {
+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) <= MAP_W + margin && Math.max(...ys) >= -margin && Math.min(...ys) <= MAP_H + margin;
+ 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) {
+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(segmentIntersectsViewport);
+ .filter((seg) => segmentIntersectsViewport(seg, 4, viewWidth, viewHeight));
}
@@ -151,18 +151,18 @@ function copySourceMapViewportField(source, camera, originX, originY, viewWidth,
return out;
}
-function transformTransportDebug(debug, camera, originX, originY, viewport = null) {
+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, MAP_W, MAP_H);
+ 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 = MAP_W * MAP_H;
+ const n = viewWidth * viewHeight;
const make = (fn) => {
const out = new Float32Array(n);
for (let i = 0; i < n; i++) out[i] = fn(i);
@@ -184,14 +184,14 @@ function transformTransportDebug(debug, camera, originX, originY, viewport = nul
...component,
cells: (component.cells || [])
.map((cell) => transformTuple(cell, camera, originX, originY))
- .filter((cell) => tupleInside(cell, 0))
+ .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).map((path) => ({ ...repair, path })));
+ 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);
+ if (Array.isArray(layers.unservedSettlements)) layers.unservedSettlements = transformPointArray(layers.unservedSettlements, camera, originX, originY, 36, false, viewWidth, viewHeight);
return { ...debug, layers };
}
@@ -206,7 +206,7 @@ function buildEmptyViewportFromSource(sourceMap, world, camera, viewWidth, viewH
return viewport;
}
-export function getViewportMap(world, camera, viewWidth = MAP_W, viewHeight = MAP_H) {
+export function getViewportMap(world, camera, viewWidth = MAP_W, viewHeight = MAP_H, options = {}) {
const sourceMap = world?.sourceMap || {};
const normalizedCamera = {
x: Math.round(camera?.x || 0),
@@ -218,29 +218,31 @@ export function getViewportMap(world, camera, viewWidth = MAP_W, viewHeight = MA
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");
+ 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);
- for (const key of SEGMENT_ARRAY_KEYS) if (Array.isArray(sourceMap[key])) viewport[key] = transformSegments(sourceMap[key], normalizedCamera, originX, originY);
+ 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),
- lowlandAdminSeeds: transformPointArray(sourceMap.adminDebug.lowlandAdminSeeds || [], normalizedCamera, originX, originY),
+ 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);
+ 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),
- adminCenters: transformPointArray(sourceMap.neighborPrefectureDetails.adminCenters || [], normalizedCamera, originX, originY),
- roads: transformPaths(sourceMap.neighborPrefectureDetails.roads || [], normalizedCamera, originX, originY),
+ 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),
};
}