This commit is contained in:
33333-33333 2026-05-29 14:31:42 +09:00
commit f2d0306d96
13 changed files with 1434 additions and 516 deletions

388
app.js
View file

@ -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 = `<polygon points="${pts}" />`;
selectionSvgEl.style.display = "block";
selectionSvgEl.classList.toggle("invalid", !!invalid);
}
function hideSelectionSvg() {
if (!selectionSvgEl) return;
selectionSvgEl.style.display = "none";
selectionSvgEl.innerHTML = "";
selectionSvgEl.classList.remove("invalid");
}
function updateSelectionOverlay() {
if (!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 = [
`<strong>${entityTitle}</strong>`,
@ -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();

View file

@ -19,6 +19,7 @@
<div class="canvas-shell">
<canvas id="mapCanvas" class="map-canvas"></canvas>
<svg id="mapSelectionSvg" class="map-selection-svg" aria-hidden="true"></svg>
<div id="mapSelection" class="map-selection" aria-hidden="true"></div>
<div id="generationProgress" class="generation-progress hidden" role="status" aria-live="polite">
<div class="progress-title">Generating map...</div>
@ -67,7 +68,7 @@
<button id="generatePatch" type="button" class="primary-button" disabled>Generate Selected Area</button>
<button id="alternativePatch" type="button" class="secondary-button" disabled>Alternative</button>
</div>
<p id="patchStatus" class="patch-status">Right-drag an area to enable patch generation.</p>
<p id="patchStatus" class="patch-status">Right-drag to lasso a freeform patch area.</p>
</section>
<section class="card">
@ -112,7 +113,7 @@
<section class="card legend">
<div class="card-title">Notes</div>
<p>Open <code>index.html</code> with Live Server. Left-drag pans the viewport; right-drag selects a regeneration area; use Patch Generation to write terrain into that area.</p>
<p>Open <code>index.html</code> with Live Server. Left-drag pans the viewport; right-drag draws a freeform regeneration area; use Patch Generation to write terrain into that area.</p>
<p>Add preferred reusable place names in <code>CUSTOM_NAME_LIST</code> inside <code>names.js</code>.</p>
</section>
</aside>

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1,37 @@
import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf, inside } from "./mapUtils.js";
import { CELL_SIZE, MAP_H, MAP_W, clamp } from "./mapUtils.js";
const segmentVectorCache = new WeakMap();
const pathVectorCache = new WeakMap();
const coastlineCache = new WeakMap();
const rasterBorderCache = new WeakMap();
const baseImageCache = new WeakMap();
const MAX_BASE_CACHE_IMAGES = 4;
function mapWidth(map) {
return Math.max(1, Math.floor(Number.isFinite(map?.width) ? map.width : MAP_W));
}
function mapHeight(map) {
return Math.max(1, Math.floor(Number.isFinite(map?.height) ? map.height : MAP_H));
}
function cellIndex(map, x, y) {
return y * mapWidth(map) + x;
}
function insideMap(map, x, y) {
return x >= 0 && y >= 0 && x < mapWidth(map) && y < mapHeight(map);
}
function mapPixelWidth(map) {
return mapWidth(map) * CELL_SIZE;
}
function mapPixelHeight(map) {
return mapHeight(map) * CELL_SIZE;
}
function pointKey(p) {
return `${p[0]},${p[1]}`;
}
@ -183,16 +208,18 @@ function getCoastlineSegments(map) {
if (cached) return cached;
const segments = [];
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
const w = mapWidth(map);
const h = mapHeight(map);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = cellIndex(map, x, y);
const a = Boolean(map.sea[i]);
if (x + 1 < MAP_W) {
const b = Boolean(map.sea[indexOf(x + 1, y)]);
if (x + 1 < w) {
const b = Boolean(map.sea[cellIndex(map, x + 1, y)]);
if (a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
}
if (y + 1 < MAP_H) {
const b = Boolean(map.sea[indexOf(x, y + 1)]);
if (y + 1 < h) {
const b = Boolean(map.sea[cellIndex(map, x, y + 1)]);
if (a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
}
}
@ -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 });
}

View file

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

View file

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

View file

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