map/app.js
2026-05-29 23:49:02 +09:00

2336 lines
92 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { generateMapAsync } from "./mapGenerator.js";
import { drawMap } from "./renderer.js";
import { landuseLabel } from "./landuseCodes.js";
import { CELL_SIZE, MAP_H, MAP_W } from "./mapUtils.js";
import { clampCameraToWorld, createInitialCamera, createWorldMap, ensureWorldPaddingForCamera } from "./worldMap.js";
import { getViewportMap } from "./worldViewport.js";
import { PATCH_MIN_AREA, PATCH_MIN_HEIGHT, PATCH_MIN_WIDTH, buildPatchRects, generatePatch, validatePatchRect } from "./mapPatch.js";
const modes = [
["all", "All"],
["terrain", "Terrain"],
["modern", "Modern"],
["history", "Premodern"],
["landuse", "Land Use"],
["admin", "Admin"],
];
const state = {
seedText: "114514",
generationType: "auto",
mode: "all",
toolMode: "pan",
showFeatures: true,
showLabels: true,
map: null,
world: null,
camera: { x: 0, y: 0 },
viewportMap: null,
viewWidth: MAP_W,
viewHeight: MAP_H,
hoverEntities: [],
selectionRect: null,
patchVariant: 0,
zoom: 1,
lastPatchResult: null,
pendingPatch: null,
generationRuns: [],
patchRuns: [],
interactionRuns: [],
diagnosticLog: [],
diagnostics: {
worldExpansionCount: 0,
lastWorldExpansion: null,
lastViewport: null,
lastFeatureCounts: null,
lastWorkerUsed: null,
lastWorkerFallbackReason: null,
lastPatchWorkerKind: null,
},
};
const canvas = document.getElementById("mapCanvas");
const canvasShell = document.querySelector(".canvas-shell");
const seedInput = document.getElementById("seed");
const generationTypeInput = document.getElementById("generationType");
const patchTerrainTypeInput = document.getElementById("patchTerrainType");
const patchVariantInput = document.getElementById("patchVariant");
const generatePatchButton = document.getElementById("generatePatch");
const alternativePatchButton = document.getElementById("alternativePatch");
const patchStatusEl = document.getElementById("patchStatus");
const generateMapButton = document.getElementById("generateMap");
const randomSeedButton = document.getElementById("randomSeed");
const toolPanButton = document.getElementById("toolPan");
const toolPatchButton = document.getElementById("toolPatch");
const toolHintEl = document.getElementById("toolHint");
const zoomInButton = document.getElementById("zoomIn");
const zoomOutButton = document.getElementById("zoomOut");
const zoomResetButton = document.getElementById("zoomReset");
const centerMapButton = document.getElementById("centerMap");
const applyPatchButton = document.getElementById("applyPatch");
const discardPatchButton = document.getElementById("discardPatch");
const showFeaturesInput = document.getElementById("showFeatures");
const showLabelsInput = document.getElementById("showLabels");
const modeGrid = document.getElementById("modeGrid");
const mainLegendGrid = document.getElementById("mainLegendGrid");
const floatingLegendGrid = document.getElementById("floatingLegendGrid");
const statsEl = document.getElementById("stats");
const advancedGenerationStatsEl = document.getElementById("advancedGenerationStats");
const advancedGenerationHistoryEl = document.getElementById("advancedGenerationHistory");
const advancedPatchStatsEl = document.getElementById("advancedPatchStats");
const advancedPatchHistoryEl = document.getElementById("advancedPatchHistory");
const advancedInteractionStatsEl = document.getElementById("advancedInteractionStats");
const advancedInteractionHistoryEl = document.getElementById("advancedInteractionHistory");
const advancedViewportDiagnosticsEl = document.getElementById("advancedViewportDiagnostics");
const advancedFeatureCountsEl = document.getElementById("advancedFeatureCounts");
const advancedPatchDiagnosticsEl = document.getElementById("advancedPatchDiagnostics");
const advancedWorkerDiagnosticsEl = document.getElementById("advancedWorkerDiagnostics");
const advancedWorldDiagnosticsEl = document.getElementById("advancedWorldDiagnostics");
const advancedWarningHistoryEl = document.getElementById("advancedWarningHistory");
const copyImportantDataButton = document.getElementById("copyImportantData");
const copyDebugStatusEl = document.getElementById("copyDebugStatus");
const tooltipEl = document.getElementById("mapTooltip");
const selectionSvgEl = document.getElementById("mapSelectionSvg");
const selectionEl = document.getElementById("mapSelection");
const progressEl = document.getElementById("generationProgress");
const progressStageEl = document.getElementById("generationProgressStage");
const progressTimingsEl = document.getElementById("generationProgressTimings");
let generationStartedAt = 0;
let generationCurrentStage = "";
let generationTimer = null;
let zoomRedrawRaf = null;
let zoomSettledTimer = null;
let zoomVisualState = null;
let zoomLatencyStartedAt = null;
let patchWorker = null;
let patchJobSeq = 0;
const dragState = {
mode: null,
pointerId: null,
startClientX: 0,
startClientY: 0,
startCameraX: 0,
startCameraY: 0,
lastClientX: 0,
lastClientY: 0,
panRemainderX: 0,
panRemainderY: 0,
selectStart: null,
selectEnd: null,
selectPath: null,
pendingCamera: null,
panRaf: null,
panLatencyStartedAt: null,
};
function displayWorld() {
return state.pendingPatch?.world || state.world;
}
function displaySourceMap() {
return displayWorld()?.sourceMap || state.map;
}
function activeMap() {
return state.viewportMap || displaySourceMap();
}
function clampZoom(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 1;
return Math.min(Math.max(parsed, 0.55), 2.8);
}
function viewportSizeForZoom(zoom = state.zoom) {
const z = clampZoom(zoom || 1);
return {
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), world = displayWorld()) {
return clampCameraToWorld(camera, 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 canvasInteractionRect() {
return zoomVisualState?.baseRect || canvas.getBoundingClientRect();
}
function mapCellScreenSize(map = activeMap()) {
const rect = canvasInteractionRect();
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);
const baseWidth = MAP_W * CELL_SIZE;
const baseHeight = MAP_H * CELL_SIZE;
const shellRect = canvasShell?.getBoundingClientRect();
const availableWidth = Math.max(160, (shellRect?.width || baseWidth) - 24);
const availableHeight = Math.max(160, (shellRect?.height || baseHeight) - 24);
const displayScale = Math.max(0.18, Math.min(availableWidth / baseWidth, availableHeight / baseHeight));
canvas.style.width = `${Math.round(baseWidth * displayScale)}px`;
canvas.style.height = `${Math.round(baseHeight * displayScale)}px`;
syncSelectionSvgToCanvas();
updateSelectionOverlayFromWorldRect();
}
function displayedCellSize() {
return mapCellScreenSize();
}
function screenPointToMapPixel(clientX, clientY, sizeOverride = null) {
const rect = canvasInteractionRect();
if (!rect.width || !rect.height) return null;
const map = activeMap();
const viewWidth = Math.max(1, sizeOverride?.width || map?.width || state.viewWidth || MAP_W);
const viewHeight = Math.max(1, sizeOverride?.height || map?.height || state.viewHeight || MAP_H);
const canvasX = (clientX - rect.left) * ((canvas.width || MAP_W * CELL_SIZE) / rect.width);
const canvasY = (clientY - rect.top) * ((canvas.height || MAP_H * CELL_SIZE) / rect.height);
const cellX = canvasX / Math.max(1e-6, (canvas.width || MAP_W * CELL_SIZE) / viewWidth);
const cellY = canvasY / Math.max(1e-6, (canvas.height || MAP_H * CELL_SIZE) / viewHeight);
return { x: cellX * CELL_SIZE, y: cellY * CELL_SIZE };
}
function mapPixelToScreenPoint(px, py) {
const rect = canvasInteractionRect();
const map = activeMap();
const viewWidth = Math.max(1, map?.width || state.viewWidth || MAP_W);
const viewHeight = Math.max(1, map?.height || state.viewHeight || MAP_H);
const canvasX = (px / CELL_SIZE) * ((canvas.width || MAP_W * CELL_SIZE) / viewWidth);
const canvasY = (py / CELL_SIZE) * ((canvas.height || MAP_H * CELL_SIZE) / viewHeight);
return {
x: canvas.offsetLeft + canvasX * (rect.width / Math.max(1, canvas.width || MAP_W * CELL_SIZE)),
y: canvas.offsetTop + canvasY * (rect.height / Math.max(1, canvas.height || MAP_H * CELL_SIZE)),
};
}
function mapClientToCell(event, sizeOverride = null) {
const map = activeMap();
if (!map && !sizeOverride) return null;
const p = screenPointToMapPixel(event.clientX, event.clientY, sizeOverride);
if (!p) return null;
return {
x: Math.floor(p.x / CELL_SIZE),
y: Math.floor(p.y / CELL_SIZE),
};
}
function viewportCellToWorldCell(cell) {
if (!cell || !state.camera) return null;
return {
x: Math.round(state.camera.x || 0) + cell.x,
y: Math.round(state.camera.y || 0) + cell.y,
};
}
function clampCanvasPoint(event) {
const rect = canvasInteractionRect();
return {
x: Math.min(Math.max(event.clientX - rect.left, 0), rect.width),
y: Math.min(Math.max(event.clientY - rect.top, 0), rect.height),
};
}
function screenPointToWorldCell(point) {
const map = activeMap();
if (!map || !point) return null;
const rect = canvasInteractionRect();
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 syncSelectionSvgToCanvas() {
if (!selectionSvgEl || !canvas) return null;
const rect = canvas.getBoundingClientRect();
const width = Math.max(1, rect.width || canvas.clientWidth || canvas.width || 1);
const height = Math.max(1, rect.height || canvas.clientHeight || canvas.height || 1);
selectionSvgEl.style.left = `${canvas.offsetLeft}px`;
selectionSvgEl.style.top = `${canvas.offsetTop}px`;
selectionSvgEl.style.width = `${width}px`;
selectionSvgEl.style.height = `${height}px`;
selectionSvgEl.setAttribute("width", String(width));
selectionSvgEl.setAttribute("height", String(height));
selectionSvgEl.setAttribute("viewBox", `0 0 ${width} ${height}`);
return { width, height };
}
function drawSelectionSvg(points, invalid = false) {
if (!selectionSvgEl) return;
const bounds = syncSelectionSvgToCanvas();
if (!bounds || !points || points.length < 3) {
selectionSvgEl.style.display = "none";
selectionSvgEl.innerHTML = "";
return;
}
const pts = points.map((p) => {
const x = Math.min(Math.max(p.x, 0), bounds.width);
const y = Math.min(Math.max(p.y, 0), bounds.height);
return `${x},${y}`;
}).join(" ");
selectionSvgEl.innerHTML = `<polygon points="${pts}" />`;
selectionSvgEl.style.display = "block";
selectionSvgEl.classList.toggle("invalid", !!invalid);
}
function hideSelectionSvg() {
if (!selectionSvgEl) return;
selectionSvgEl.style.display = "none";
selectionSvgEl.innerHTML = "";
selectionSvgEl.classList.remove("invalid");
}
function updateSelectionOverlay() {
if (!dragState.selectPath?.length) return;
const liveShape = selectionPathToShape(dragState.selectPath);
const validation = validatePatchRect(liveShape, state.world);
drawSelectionSvg(dragState.selectPath, !validation.ok);
if (selectionEl) selectionEl.style.display = "none";
if (generatePatchButton) generatePatchButton.disabled = true;
if (alternativePatchButton) alternativePatchButton.disabled = true;
if (patchStatusEl) {
const current = validation.rect || liveShape;
patchStatusEl.textContent = validation.ok
? `Selected: ${formatRectSize(validation.rect)}. Release to confirm patch selection.`
: `${validation.reason} Current: ${formatRectSize(current)}.`;
patchStatusEl.classList.toggle("invalid", !validation.ok);
}
}
function updateSelectionOverlayFromWorldRect() {
if (!state.selectionRect || !state.camera || !activeMap()) return;
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return;
if (Array.isArray(state.selectionRect.polygon) && state.selectionRect.polygon.length >= 3) {
const points = state.selectionRect.polygon.map(worldCellToOverlayPoint)
.map((p) => ({ x: Math.min(Math.max(p.x, 0), rect.width), y: Math.min(Math.max(p.y, 0), rect.height) }));
drawSelectionSvg(points, !validatePatchRect(state.selectionRect, state.world).ok);
if (selectionEl) selectionEl.style.display = "none";
return;
}
const cameraX = Math.round(state.camera.x || 0);
const cameraY = Math.round(state.camera.y || 0);
const p0 = mapPixelToScreenPoint((state.selectionRect.x0 - cameraX) * CELL_SIZE, (state.selectionRect.y0 - cameraY) * CELL_SIZE);
const p1 = mapPixelToScreenPoint((state.selectionRect.x1 - cameraX) * CELL_SIZE, (state.selectionRect.y1 - cameraY) * CELL_SIZE);
const vx0 = p0.x - canvas.offsetLeft;
const vy0 = p0.y - canvas.offsetTop;
const vx1 = p1.x - canvas.offsetLeft;
const vy1 = p1.y - canvas.offsetTop;
const x0 = Math.min(Math.max(Math.min(vx0, vx1), 0), rect.width);
const y0 = Math.min(Math.max(Math.min(vy0, vy1), 0), rect.height);
const x1 = Math.min(Math.max(Math.max(vx0, vx1), 0), rect.width);
const y1 = Math.min(Math.max(Math.max(vy0, vy1), 0), rect.height);
if (x1 - x0 < 1 || y1 - y0 < 1) {
selectionEl.style.display = "none";
return;
}
hideSelectionSvg();
selectionEl.style.display = "block";
selectionEl.style.left = `${canvas.offsetLeft + x0}px`;
selectionEl.style.top = `${canvas.offsetTop + y0}px`;
selectionEl.style.width = `${Math.max(1, x1 - x0)}px`;
selectionEl.style.height = `${Math.max(1, y1 - y0)}px`;
const validation = validatePatchRect(state.selectionRect, state.world);
selectionEl.classList.toggle("invalid", !validation.ok);
}
function formatRectSize(rect) {
if (!rect) return "-";
const w = Math.max(0, rect.x1 - rect.x0);
const h = Math.max(0, rect.y1 - rect.y0);
const area = Math.max(0, rect.areaCells || (w * h));
return `${w} x ${h} cells / ${area.toLocaleString()} cells`;
}
function normalizePatchVariant(value) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? Math.max(0, parsed) >>> 0 : 0;
}
function setPatchVariant(value, { update = true } = {}) {
state.patchVariant = normalizePatchVariant(value);
if (patchVariantInput && patchVariantInput.value !== String(state.patchVariant)) {
patchVariantInput.value = String(state.patchVariant);
}
if (update) updatePatchControls();
return state.patchVariant;
}
function readPatchVariant() {
return setPatchVariant(patchVariantInput?.value ?? state.patchVariant, { update: false });
}
function resetPatchVariant({ update = true } = {}) {
return setPatchVariant(0, { update });
}
function updatePatchControls() {
const variant = readPatchVariant();
const validation = validatePatchRect(state.selectionRect, state.world);
const hasValidSelection = !!validation.ok;
const hasPreview = !!state.pendingPatch;
if (generatePatchButton) generatePatchButton.disabled = !hasValidSelection;
if (alternativePatchButton) alternativePatchButton.disabled = !hasValidSelection;
if (applyPatchButton) applyPatchButton.disabled = !hasPreview;
if (discardPatchButton) discardPatchButton.disabled = !hasPreview;
if (!patchStatusEl) return;
if (!state.selectionRect) {
patchStatusEl.textContent = state.toolMode === "patch"
? `Right-drag a freeform patch area. Minimum: ${PATCH_MIN_WIDTH} x ${PATCH_MIN_HEIGHT} cells and ${PATCH_MIN_AREA.toLocaleString()} cells.`
: "Right-drag on the map to draw a patch area.";
patchStatusEl.classList.toggle("invalid", false);
return;
}
if (!validation.ok) {
patchStatusEl.textContent = `${validation.reason} Current: ${formatRectSize(validation.rect || state.selectionRect)}.`;
patchStatusEl.classList.toggle("invalid", true);
return;
}
const rects = buildPatchRects(validation.rect, state.world);
const shownPatch = state.pendingPatch?.result || state.lastPatchResult;
const previewText = state.pendingPatch
? ` Preview ready: ${shownPatch?.label || "candidate"}, variant ${shownPatch?.variant ?? variant}. Use Apply Preview or Discard.`
: shownPatch
? ` Last applied: ${shownPatch.label || "patch"}, variant ${shownPatch.variant ?? "-"}.`
: "";
patchStatusEl.textContent = `Selection: ${formatRectSize(validation.rect)}. Core ${formatRectSize(rects.coreRect)} / write ${formatRectSize(rects.writeRect)}.${previewText}`;
patchStatusEl.classList.toggle("invalid", false);
}
function setToolMode(mode) {
state.toolMode = mode === "patch" ? "patch" : "pan";
toolPanButton?.classList.toggle("active", state.toolMode === "pan");
toolPatchButton?.classList.toggle("active", state.toolMode === "patch");
toolHintEl?.classList.toggle("hidden", state.toolMode !== "patch");
canvasShell?.classList.toggle("patch-intent", state.toolMode === "patch");
updatePatchControls();
}
function setZoomKeepingCenter(nextZoom) {
const startedAt = performance.now();
const renderWorld = displayWorld();
if (!renderWorld) return;
const oldSize = viewportSizeForZoom(state.zoom);
const centerWorld = {
x: Math.round((state.camera?.x || 0) + oldSize.width / 2),
y: Math.round((state.camera?.y || 0) + oldSize.height / 2),
};
state.zoom = clampZoom(nextZoom);
const nextSize = syncViewportSize();
state.camera = clampCameraForView({
x: Math.round(centerWorld.x - nextSize.width / 2),
y: Math.round(centerWorld.y - nextSize.height / 2),
}, nextSize, renderWorld);
redraw({ fastTerrain: false, allowWorldExpand: false });
recordInteractionLatency("zoom button", startedAt, { zoom: state.zoom });
}
function recenterMap() {
const renderWorld = displayWorld();
if (!renderWorld) return;
state.camera = createInitialCamera(renderWorld);
redraw({ fastTerrain: false, allowWorldExpand: false });
}
function clearDragMode() {
dragState.mode = null;
dragState.pointerId = null;
dragState.pendingCamera = null;
dragState.panRemainderX = 0;
dragState.panRemainderY = 0;
if (dragState.panRaf != null) {
cancelAnimationFrame(dragState.panRaf);
dragState.panRaf = null;
}
dragState.panLatencyStartedAt = null;
canvasShell?.classList.remove("panning", "selecting");
}
function schedulePanRedraw(camera) {
dragState.pendingCamera = camera;
if (!dragState.panLatencyStartedAt) dragState.panLatencyStartedAt = performance.now();
if (dragState.panRaf != null) return;
dragState.panRaf = requestAnimationFrame(() => {
dragState.panRaf = null;
if (!dragState.pendingCamera) return;
const next = dragState.pendingCamera;
dragState.pendingCamera = null;
if (next.x === state.camera.x && next.y === state.camera.y) return;
const startedAt = dragState.panLatencyStartedAt || performance.now();
dragState.panLatencyStartedAt = null;
state.camera = next;
// Do not auto-expand the backing world while a pointer drag is active.
// Expansion shifts world coordinates; doing it mid-drag invalidates the
// pointer-to-camera baseline and can make the viewport appear to jump.
redraw({ fastTerrain: true, allowWorldExpand: false });
recordInteractionLatency("pan", startedAt, { zoom: state.zoom, fast: true });
});
}
function commitPendingPatch({ redrawAfter = true } = {}) {
if (!state.pendingPatch?.world) return false;
state.world = state.pendingPatch.world;
state.map = state.world.sourceMap || state.map;
state.lastPatchResult = state.pendingPatch.result || state.world.lastPatchResult || state.lastPatchResult;
state.pendingPatch = null;
state.viewportMap = null;
if (redrawAfter) {
renderStats(displaySourceMap());
redraw({ fastTerrain: true, allowWorldExpand: false });
window.setTimeout(() => redraw({ fastTerrain: false, allowWorldExpand: false }), 80);
}
return true;
}
function discardPendingPatch({ redrawAfter = true } = {}) {
if (!state.pendingPatch) return false;
state.pendingPatch = null;
state.viewportMap = null;
if (redrawAfter) {
renderStats(displaySourceMap());
redraw({ fastTerrain: false, allowWorldExpand: false });
}
return true;
}
function hideSelectionOverlay(options = {}) {
const commitPreview = options.commitPreview === true;
if (commitPreview) commitPendingPatch({ redrawAfter: false });
else if (options.discardPreview === true) discardPendingPatch({ redrawAfter: false });
dragState.selectStart = null;
dragState.selectEnd = null;
dragState.selectPath = null;
state.selectionRect = null;
resetPatchVariant({ update: false });
hideSelectionSvg();
if (selectionEl) selectionEl.style.display = "none";
state.viewportMap = null;
renderStats(displaySourceMap());
updatePatchControls();
if (commitPreview || options.discardPreview === true) redraw({ fastTerrain: false, allowWorldExpand: false });
}
function selectionPixelsToCells(start, end) {
const a = screenPointToWorldCell(start);
const b = screenPointToWorldCell(end);
if (!a || !b) return null;
return {
x0: Math.min(a.x, b.x),
y0: Math.min(a.y, b.y),
x1: Math.max(a.x, b.x) + 1,
y1: Math.max(a.y, b.y) + 1,
};
}
function selectionPixelsToShape(start, end, path = null) {
if (Array.isArray(path) && path.length >= 3) return selectionPathToShape(path);
return selectionPixelsToCells(start, end);
}
function handleMapPointerDown(event) {
if (!state.world || !canvasShell) return;
if (event.button !== 0 && event.button !== 2) return;
dragState.pointerId = event.pointerId;
dragState.startClientX = event.clientX;
dragState.startClientY = event.clientY;
dragState.startCameraX = state.camera.x;
dragState.startCameraY = state.camera.y;
dragState.lastClientX = event.clientX;
dragState.lastClientY = event.clientY;
dragState.panRemainderX = 0;
dragState.panRemainderY = 0;
tooltipEl?.classList.remove("visible");
if (event.button === 0) {
dragState.mode = "pan";
canvasShell.classList.add("panning");
} else {
if (state.toolMode !== "patch") setToolMode("patch");
if (state.pendingPatch) discardPendingPatch({ redrawAfter: false });
state.selectionRect = null;
hideSelectionSvg();
if (selectionEl) selectionEl.style.display = "none";
dragState.mode = "select";
dragState.selectStart = clampCanvasPoint(event);
dragState.selectEnd = dragState.selectStart;
dragState.selectPath = [dragState.selectStart];
canvasShell.classList.add("selecting");
updateSelectionOverlay();
}
canvas.setPointerCapture?.(event.pointerId);
event.preventDefault();
}
function handleMapPointerMove(event) {
if (!dragState.mode || dragState.pointerId !== event.pointerId || !canvasShell) return;
tooltipEl?.classList.remove("visible");
if (dragState.mode === "pan") {
const rect = canvas.getBoundingClientRect();
const maxDeltaX = Math.max(320, rect.width * 0.72);
const maxDeltaY = Math.max(240, rect.height * 0.72);
const rawDx = event.clientX - dragState.lastClientX;
const rawDy = event.clientY - dragState.lastClientY;
dragState.lastClientX = event.clientX;
dragState.lastClientY = event.clientY;
// Pointer capture can occasionally deliver a stale/outlier coordinate after
// a tab switch, resize, context-menu gesture, or OS-level event hiccup. A
// single implausibly large delta would otherwise become a large camera jump.
if (Math.abs(rawDx) <= maxDeltaX && Math.abs(rawDy) <= maxDeltaY) {
const cellSize = Math.max(1, displayedCellSize());
const totalX = dragState.panRemainderX + rawDx / cellSize;
const totalY = dragState.panRemainderY + rawDy / cellSize;
const dxCells = totalX < 0 ? Math.ceil(totalX) : Math.floor(totalX);
const dyCells = totalY < 0 ? Math.ceil(totalY) : Math.floor(totalY);
dragState.panRemainderX = totalX - dxCells;
dragState.panRemainderY = totalY - dyCells;
if (dxCells || dyCells) {
const base = dragState.pendingCamera || state.camera;
const nextCamera = clampCameraForView({
x: base.x - dxCells,
y: base.y - dyCells,
}, viewportSizeForZoom(state.zoom));
schedulePanRedraw(nextCamera);
}
}
} else if (dragState.mode === "select") {
dragState.selectEnd = clampCanvasPoint(event);
if (!dragState.selectPath || Math.hypot(dragState.selectEnd.x - dragState.selectPath[dragState.selectPath.length - 1].x, dragState.selectEnd.y - dragState.selectPath[dragState.selectPath.length - 1].y) >= 3) {
dragState.selectPath = [...(dragState.selectPath || []), dragState.selectEnd];
}
updateSelectionOverlay();
}
event.preventDefault();
}
function handleMapPointerUp(event) {
if (dragState.pointerId !== event.pointerId) return;
const wasPanning = dragState.mode === "pan";
if (dragState.mode === "select") {
dragState.selectEnd = clampCanvasPoint(event);
if (!dragState.selectPath || dragState.selectPath.length < 2) dragState.selectPath = [dragState.selectStart, dragState.selectEnd];
else dragState.selectPath = [...dragState.selectPath, dragState.selectEnd];
const shape = selectionPixelsToShape(dragState.selectStart, dragState.selectEnd, dragState.selectPath);
const width = Math.abs(dragState.selectEnd.x - dragState.selectStart.x);
const height = Math.abs(dragState.selectEnd.y - dragState.selectStart.y);
if (shape && (dragState.selectPath.length >= 3 || (width >= 4 && height >= 4))) {
state.selectionRect = shape;
state.lastPatchResult = null;
resetPatchVariant({ update: false });
updateSelectionOverlayFromWorldRect();
updatePatchControls();
} else {
hideSelectionOverlay();
}
}
canvas.releasePointerCapture?.(event.pointerId);
if (wasPanning && dragState.pendingCamera) {
state.camera = dragState.pendingCamera;
dragState.pendingCamera = null;
}
clearDragMode();
if (wasPanning) {
const startedAt = performance.now();
redraw({ fastTerrain: false });
recordInteractionLatency("pan settle", startedAt, { zoom: state.zoom, fast: false });
}
event.preventDefault();
}
function parseSeed(seedText) {
const numeric = Number.parseInt(seedText, 10);
if (Number.isFinite(numeric)) return numeric >>> 0;
let hash = 2166136261;
for (const ch of seedText) hash = Math.imul(hash ^ ch.charCodeAt(0), 16777619);
return hash >>> 0;
}
function insideCount(items) {
return items.filter((item) => item.insidePrefecture).length;
}
function outsideCount(items) {
return items.length - insideCount(items);
}
function countText(items) {
return `${insideCount(items)} / outside ${outsideCount(items)}`;
}
function formatMs(ms) {
if (!Number.isFinite(ms)) return "-";
return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${Math.round(ms)}ms`;
}
function formatSeconds(value, digits = 4) {
if (!Number.isFinite(value)) return "-";
return `${value.toFixed(digits)}s`;
}
function formatNumber(value, digits = 1) {
if (!Number.isFinite(value)) return "-";
return value.toFixed(digits);
}
function pushCapped(history, item, limit = 10) {
history.unshift(item);
if (history.length > limit) history.length = limit;
}
function countTruthyCells(mask) {
if (!mask || typeof mask.length !== "number") return 0;
let count = 0;
for (let i = 0; i < mask.length; i++) if (mask[i]) count++;
return count;
}
function mapAreaCells(map) {
if (!map) return MAP_W * MAP_H;
const focusedArea = countTruthyCells(map.focusedPrefectureMask || map.prefectureMask || map.humanRegionMask);
if (focusedArea > 0) return focusedArea;
return Math.max(1, (map.width || MAP_W) * (map.height || MAP_H));
}
function formatAreaCells(cells) {
return `${Math.max(0, Math.round(cells || 0)).toLocaleString()} cells`;
}
function rectAreaCells(rect) {
if (!rect) return 0;
return Math.max(0, Math.round((rect.x1 - rect.x0) * (rect.y1 - rect.y0)));
}
function terrainTypeLabel(value) {
const option = generationTypeInput ? Array.from(generationTypeInput.options).find((item) => item.value === value) : null;
return option?.textContent || value || "Auto";
}
function secondsPerThousandCells(totalMs, areaCells) {
const area = Math.max(1, areaCells || 0);
return (totalMs || 0) / area;
}
function numericValues(items, selector) {
return (items || [])
.map(selector)
.map(Number)
.filter((value) => Number.isFinite(value) && value >= 0);
}
function percentile(values, percentileRank) {
const sorted = [...values].sort((a, b) => a - b);
if (!sorted.length) return NaN;
if (sorted.length === 1) return sorted[0];
const rank = Math.min(Math.max(percentileRank, 0), 100) / 100 * (sorted.length - 1);
const lower = Math.floor(rank);
const upper = Math.ceil(rank);
if (lower === upper) return sorted[lower];
const weight = rank - lower;
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
}
function summarizeValues(values) {
if (!values.length) return null;
const sum = values.reduce((acc, value) => acc + value, 0);
return {
count: values.length,
avg: sum / values.length,
max: Math.max(...values),
p95: percentile(values, 95),
};
}
function renderMetricGrid(container, metrics, emptyText = "No samples yet.") {
if (!container) return;
container.innerHTML = "";
const visibleMetrics = (metrics || []).filter(Boolean);
if (!visibleMetrics.length) {
renderEmptyState(container, emptyText);
return;
}
for (const metric of visibleMetrics) {
const card = document.createElement("div");
card.className = "metric-card";
card.innerHTML = `
<span>${metric.label}</span>
<strong>${metric.value}</strong>
${metric.sub ? `<small>${metric.sub}</small>` : ""}
`;
container.append(card);
}
}
function formatPercent(value, digits = 1) {
if (!Number.isFinite(value)) return "-";
return `${value.toFixed(digits)}%`;
}
function countArray(value) {
return Array.isArray(value) ? value.length : 0;
}
function countPaths(paths) {
return (paths || []).reduce((sum, path) => sum + (Array.isArray(path) ? path.length : 0), 0);
}
function renderDiagnosticGrid(container, metrics, emptyText = "No diagnostics yet.") {
renderMetricGrid(container, metrics, emptyText);
}
function renderDiagnosticTable(container, rows, emptyText = "No diagnostics yet.") {
if (!container) return;
container.innerHTML = "";
const visibleRows = (rows || []).filter(Boolean);
if (!visibleRows.length) {
renderEmptyState(container, emptyText);
return;
}
for (const row of visibleRows) {
const item = document.createElement("div");
item.className = "diagnostic-row";
item.innerHTML = `
<span>${row.label}</span>
<strong>${row.value}</strong>
${row.sub ? `<small>${row.sub}</small>` : ""}
`;
container.append(item);
}
}
function collectFeatureCounts(map = activeMap()) {
if (!map) return null;
const roads = [
...(map.nationalRoads || []),
...(map.ringRoads || []),
...(map.externalRoads || []),
...(map.expressways || []),
...(map.externalExpressways || []),
...(map.minorRoads || []),
...(map.icAccessRoads || []),
];
const railways = [
...(map.railways || []),
...(map.branchRailways || []),
...(map.ringRailways || []),
...(map.externalRailways || []),
];
const rivers = [
...(map.mainRivers || []),
...(map.tributaryRivers || []),
...(map.smallStreams || []),
];
const settlements = [
...(map.modernCities || []),
...(map.satelliteCities || []),
...(map.villages || []),
...(map.markets || []),
...(map.castles || []),
...(map.ports || []),
...(map.newTowns || []),
];
const adminBorders = [
...(map.adminBorders || []),
...(map.prefectureBorder || []),
...(map.regionalPrefectureBorders || []),
];
return {
roads: roads.length,
roadCells: countPaths(roads),
railways: railways.length,
railwayCells: countPaths(railways),
rivers: rivers.length,
riverCells: countPaths(rivers),
settlements: settlements.length,
stations: countArray(map.stations),
labels: countArray(state.hoverEntities),
adminCenters: countArray(map.adminCenters),
adminBorders: adminBorders.length,
adminBorderCells: countPaths(adminBorders),
industrialZones: countArray(map.industrialZones),
logisticsParks: countArray(map.logisticsParks),
};
}
function recordDiagnosticLog(level, title, message = "", meta = {}) {
pushCapped(state.diagnosticLog, {
id: Date.now() + Math.random(),
createdAt: new Date(),
level: level || "info",
title: title || "Diagnostic",
message: String(message || ""),
meta,
});
renderAdvancedData();
}
function updateRenderDiagnostics(options = {}, timings = {}) {
const map = activeMap();
const canvasRect = canvas?.getBoundingClientRect?.();
const viewWidth = Math.max(1, state.viewWidth || map?.width || MAP_W);
const viewHeight = Math.max(1, state.viewHeight || map?.height || MAP_H);
state.diagnostics.lastViewport = {
viewWidth,
viewHeight,
cells: viewWidth * viewHeight,
mapWidth: map?.width || viewWidth,
mapHeight: map?.height || viewHeight,
cssWidth: canvasRect?.width || 0,
cssHeight: canvasRect?.height || 0,
bitmapWidth: canvas?.width || 0,
bitmapHeight: canvas?.height || 0,
zoom: state.zoom || 1,
mode: state.mode,
fast: !!options.fastTerrain,
showFeatures: state.showFeatures && !options.fastTerrain,
showLabels: state.showLabels && !options.fastTerrain,
viewportMs: timings.viewportMs || 0,
hoverMs: timings.hoverMs || 0,
drawMs: timings.drawMs || 0,
totalRenderMs: timings.totalRenderMs || 0,
};
state.diagnostics.lastFeatureCounts = collectFeatureCounts(map);
}
function selectionWriteDiagnostics() {
const rect = state.selectionRect;
const validation = validatePatchRect(rect, state.world);
const currentSelection = validation.rect || rect;
const rects = validation.ok ? buildPatchRects(validation.rect, state.world) : null;
const selectedArea = currentSelection?.areaCells || rectAreaCells(currentSelection);
const writeArea = rects?.writeRect ? rectAreaCells(rects.writeRect) : 0;
const last = state.patchRuns[0];
const lastRatio = last?.areaCells ? (last.writeAreaCells || 0) / Math.max(1, last.areaCells) * 100 : NaN;
return [
{ label: "Current selection", value: selectedArea ? formatAreaCells(selectedArea) : "none", sub: validation.ok ? "valid" : (rect ? validation.reason : "no active selection") },
{ label: "Current write area", value: writeArea ? formatAreaCells(writeArea) : "-", sub: selectedArea ? `${formatPercent(writeArea / Math.max(1, selectedArea) * 100)} of selection` : "requires valid selection" },
{ label: "Last patch selection", value: last ? formatAreaCells(last.areaCells) : "-", sub: last ? `${last.kind || "Patch"} · ${terrainTypeLabel(last.terrainType)}` : "no patch runs" },
{ label: "Last patch write", value: last?.writeAreaCells ? formatAreaCells(last.writeAreaCells) : "-", sub: Number.isFinite(lastRatio) ? `${formatPercent(lastRatio)} of selection` : "no patch runs" },
{ label: "Last patch variant", value: last ? String(last.variant ?? "-") : "-", sub: last?.label || "no candidate" },
];
}
function renderViewportDiagnostics() {
const viewport = state.diagnostics.lastViewport;
renderDiagnosticGrid(advancedViewportDiagnosticsEl, viewport ? [
{ label: "Viewport", value: `${viewport.viewWidth}×${viewport.viewHeight}`, sub: `${formatAreaCells(viewport.cells)} drawn` },
{ label: "Canvas CSS", value: `${Math.round(viewport.cssWidth)}×${Math.round(viewport.cssHeight)}`, sub: "display pixels" },
{ label: "Canvas bitmap", value: `${viewport.bitmapWidth}×${viewport.bitmapHeight}`, sub: "render target" },
{ label: "Zoom", value: `${Number(viewport.zoom || 1).toFixed(2)}x`, sub: viewport.fast ? "fast redraw" : "full redraw" },
{ label: "Viewport build", value: formatMs(viewport.viewportMs), sub: "getViewportMap" },
{ label: "Canvas draw", value: formatMs(viewport.drawMs), sub: "drawMap" },
{ label: "Hover index", value: formatMs(viewport.hoverMs), sub: "labels / hit targets" },
{ label: "Render total", value: formatMs(viewport.totalRenderMs), sub: `${viewport.mode} mode` },
] : [], "No viewport render recorded yet.");
const counts = state.diagnostics.lastFeatureCounts;
renderDiagnosticTable(advancedFeatureCountsEl, counts ? [
{ label: "Road paths", value: counts.roads.toLocaleString(), sub: `${counts.roadCells.toLocaleString()} path cells` },
{ label: "Rail paths", value: counts.railways.toLocaleString(), sub: `${counts.railwayCells.toLocaleString()} path cells` },
{ label: "River paths", value: counts.rivers.toLocaleString(), sub: `${counts.riverCells.toLocaleString()} path cells` },
{ label: "Settlements", value: counts.settlements.toLocaleString(), sub: "cities, towns, ports, castles" },
{ label: "Stations", value: counts.stations.toLocaleString(), sub: "rail markers" },
{ label: "Hover labels", value: counts.labels.toLocaleString(), sub: "active hit targets" },
{ label: "Admin centers", value: counts.adminCenters.toLocaleString(), sub: "municipality labels" },
{ label: "Admin borders", value: counts.adminBorders.toLocaleString(), sub: `${counts.adminBorderCells.toLocaleString()} path cells` },
{ label: "Industry / logistics", value: (counts.industrialZones + counts.logisticsParks).toLocaleString(), sub: `${counts.industrialZones} industrial, ${counts.logisticsParks} logistics` },
] : [], "No feature counts recorded yet.");
}
function renderPatchDiagnostics() {
renderDiagnosticTable(advancedPatchDiagnosticsEl, selectionWriteDiagnostics(), "No patch diagnostics yet.");
}
function renderWorkerWorldDiagnostics() {
const workerAvailable = typeof Worker !== "undefined";
const workerRows = [
{ label: "Worker API", value: workerAvailable ? "available" : "unavailable", sub: "browser capability" },
{ label: "Patch worker object", value: patchWorker ? "active" : "not active", sub: patchWorker ? "created" : "created on demand" },
{ label: "Last patch worker", value: state.diagnostics.lastWorkerUsed == null ? "-" : (state.diagnostics.lastWorkerUsed ? "used" : "main thread"), sub: state.diagnostics.lastPatchWorkerKind || "no patch run" },
{ label: "Fallback reason", value: state.diagnostics.lastWorkerFallbackReason || "none", sub: "last worker fallback" },
];
renderDiagnosticTable(advancedWorkerDiagnosticsEl, workerRows);
const world = displayWorld();
const source = displaySourceMap();
const expansion = state.diagnostics.lastWorldExpansion;
renderDiagnosticTable(advancedWorldDiagnosticsEl, [
{ label: "World size", value: world ? `${world.width}×${world.height}` : "-", sub: world ? formatAreaCells(world.width * world.height) : "no world" },
{ label: "Source map", value: source ? `${source.width || MAP_W}×${source.height || MAP_H}` : "-", sub: source ? formatAreaCells((source.width || MAP_W) * (source.height || MAP_H)) : "no source" },
{ label: "Camera", value: state.camera ? `${Math.round(state.camera.x || 0)}, ${Math.round(state.camera.y || 0)}` : "-", sub: "world-space origin" },
{ label: "World expansions", value: state.diagnostics.worldExpansionCount.toLocaleString(), sub: expansion ? `last dx ${expansion.dx}, dy ${expansion.dy}` : "none yet" },
{ label: "Pending patch", value: state.pendingPatch ? "yes" : "no", sub: state.pendingPatch ? `${terrainTypeLabel(state.pendingPatch.terrainType)} · variant ${state.pendingPatch.variant}` : "committed world" },
]);
}
function renderWarningHistory() {
if (!advancedWarningHistoryEl) return;
advancedWarningHistoryEl.innerHTML = "";
if (!state.diagnosticLog.length) {
renderEmptyState(advancedWarningHistoryEl, "No warnings or errors recorded yet.");
return;
}
for (const entry of state.diagnosticLog) {
const row = document.createElement("div");
row.className = `diagnostic-log-row ${entry.level || "info"}`;
const time = entry.createdAt instanceof Date ? entry.createdAt.toLocaleTimeString() : "-";
row.innerHTML = `
<span>${time}</span>
<strong>${entry.title}</strong>
<p>${entry.message || "-"}</p>
`;
advancedWarningHistoryEl.append(row);
}
}
function performanceMetricsForRuns(runs) {
const totals = summarizeValues(numericValues(runs, (run) => run.totalMs));
const perArea = summarizeValues(numericValues(runs, (run) => run.secondsPerThousand));
if (!totals) return [];
return [
{ label: "Samples", value: String(totals.count), sub: "last 10" },
{ label: "Avg total", value: formatMs(totals.avg), sub: "generation time" },
{ label: "Max total", value: formatMs(totals.max), sub: "slowest run" },
{ label: "P95 total", value: formatMs(totals.p95), sub: "tail latency" },
perArea ? { label: "Avg / 1k", value: formatSeconds(perArea.avg), sub: "seconds / 1k cells" } : null,
perArea ? { label: "P95 / 1k", value: formatSeconds(perArea.p95), sub: "area-normalized" } : null,
];
}
function recordGenerationRun(map, terrainType) {
if (!map) return;
const area = mapAreaCells(map);
const totalMs = Number.isFinite(map.generationTotalMs)
? map.generationTotalMs
: (map.generationTimings || []).reduce((sum, row) => sum + (Number(row?.ms) || 0), 0);
pushCapped(state.generationRuns, {
id: Date.now(),
createdAt: new Date(),
terrainType: terrainType || "auto",
areaCells: area,
totalMs,
secondsPerThousand: secondsPerThousandCells(totalMs, area),
timings: (map.generationTimings || []).map((row) => ({
label: row.label || row.key || "Stage",
ms: Number(row.ms) || 0,
})),
});
renderAdvancedData();
}
function recordPatchRun(result, terrainType, selectionRect, variant, meta = {}) {
if (!result) return;
const timings = (result.patchTimings || []).map((row) => ({
label: row.label || row.key || "Stage",
ms: Number(row.ms) || 0,
}));
const totalMs = Number.isFinite(meta.wallMs)
? meta.wallMs
: timings.reduce((sum, row) => sum + (Number(row.ms) || 0), 0);
const selectionArea = result.selectionShape?.areaCells || selectionRect?.areaCells || rectAreaCells(selectionRect);
const writeArea = rectAreaCells(result.writeRect || result.rects?.writeRect || selectionRect);
const area = Math.max(1, Math.round(selectionArea || writeArea || 1));
const writeRatio = writeArea / Math.max(1, area);
const usedWorker = meta.worker === true;
pushCapped(state.patchRuns, {
id: Date.now(),
createdAt: new Date(),
kind: meta.kind || "Patch preview",
terrainType: terrainType || result.terrainType || "auto",
variant: Number.isFinite(variant) ? variant : result.variant,
worker: usedWorker,
label: result.label || "candidate",
areaCells: area,
writeAreaCells: writeArea,
writeRatio,
totalMs,
secondsPerThousand: secondsPerThousandCells(totalMs, area),
timings,
});
state.diagnostics.lastWorkerUsed = usedWorker;
state.diagnostics.lastPatchWorkerKind = meta.kind || "Patch preview";
if (usedWorker) state.diagnostics.lastWorkerFallbackReason = null;
renderAdvancedData();
}
function interactionAction(kind = "") {
const label = String(kind).toLowerCase();
if (label.includes("pan")) return "pan";
if (label.includes("zoom")) return "zoom";
return label || "other";
}
function interactionPhase(run) {
return run.fast ? "fast" : "full";
}
function interactionGroupLabel(action, phase) {
const actionLabel = action === "pan" ? "Pan" : action === "zoom" ? "Zoom" : action;
return `${actionLabel} / ${phase === "fast" ? "fast redraw" : "full redraw"}`;
}
function recordInteractionLatency(kind, startedAt, meta = {}) {
if (!Number.isFinite(startedAt)) return;
const ms = performance.now() - startedAt;
if (!Number.isFinite(ms) || ms < 0) return;
const item = {
id: Date.now(),
createdAt: new Date(),
kind,
ms,
zoom: Number.isFinite(meta.zoom) ? meta.zoom : state.zoom,
fast: meta.fast === true,
};
item.action = interactionAction(kind);
item.phase = interactionPhase(item);
pushCapped(state.interactionRuns, item);
renderAdvancedData();
}
function renderEmptyState(container, text) {
if (!container) return;
container.innerHTML = "";
const empty = document.createElement("div");
empty.className = "perf-empty";
empty.textContent = text;
container.append(empty);
}
function appendRunHistory(container, runs, options = {}) {
if (!container) return;
container.innerHTML = "";
if (!runs.length) {
renderEmptyState(container, options.emptyText || "No runs recorded yet.");
return;
}
runs.forEach((run, index) => {
const item = document.createElement("details");
item.className = "perf-run";
if (index === 0) item.open = true;
const summary = document.createElement("summary");
summary.className = options.patch ? "perf-summary patch" : "perf-summary";
const name = options.patch
? `${run.kind || "Patch"} · ${terrainTypeLabel(run.terrainType)}`
: terrainTypeLabel(run.terrainType);
summary.innerHTML = `
<span class="perf-rank">#${index + 1}</span>
<span>${name}</span>
<strong>${formatMs(run.totalMs)}</strong>
<span>${formatAreaCells(run.areaCells)}${options.patch && run.writeAreaCells ? ` / write ${formatAreaCells(run.writeAreaCells)}` : ""}</span>
<span>${formatSeconds(run.secondsPerThousand)} / 1k cells</span>
`;
const body = document.createElement("div");
body.className = "timing-grid";
if (options.patch) {
const meta = document.createElement("div");
meta.className = "timing-pill meta";
meta.innerHTML = `<span>Variant</span><strong>${run.variant ?? "-"}</strong>`;
const worker = document.createElement("div");
worker.className = "timing-pill meta";
worker.innerHTML = `<span>Worker</span><strong>${run.worker ? "yes" : "no"}</strong>`;
const ratio = document.createElement("div");
ratio.className = "timing-pill meta";
ratio.innerHTML = `<span>Write / selection</span><strong>${formatPercent((run.writeRatio || 0) * 100)}</strong>`;
body.append(meta, worker, ratio);
}
for (const row of run.timings) {
const timing = document.createElement("div");
timing.className = "timing-pill";
timing.innerHTML = `<span>${row.label}</span><strong>${formatMs(row.ms)}</strong>`;
body.append(timing);
}
if (!run.timings.length) {
const empty = document.createElement("div");
empty.className = "perf-empty inline";
empty.textContent = "No stage breakdown available.";
body.append(empty);
}
item.append(summary, body);
container.append(item);
});
}
function renderGenerationHistory() {
renderMetricGrid(advancedGenerationStatsEl, performanceMetricsForRuns(state.generationRuns), "No generation runs recorded yet.");
appendRunHistory(advancedGenerationHistoryEl, state.generationRuns, {
emptyText: "No generation runs recorded yet.",
});
}
function renderPatchHistory() {
renderMetricGrid(advancedPatchStatsEl, performanceMetricsForRuns(state.patchRuns), "No patch generation runs recorded yet.");
appendRunHistory(advancedPatchHistoryEl, state.patchRuns, {
patch: true,
emptyText: "No patch generation runs recorded yet.",
});
}
function renderInteractionSummary() {
if (!advancedInteractionStatsEl) return;
advancedInteractionStatsEl.innerHTML = "";
if (!state.interactionRuns.length) {
renderEmptyState(advancedInteractionStatsEl, "No pan or zoom operations recorded yet.");
return;
}
const groups = new Map();
for (const run of state.interactionRuns) {
const action = run.action || interactionAction(run.kind);
const phase = run.phase || interactionPhase(run);
const key = `${action}:${phase}`;
if (!groups.has(key)) groups.set(key, { action, phase, values: [] });
groups.get(key).values.push(run.ms);
}
const table = document.createElement("div");
table.className = "aggregate-table";
for (const group of groups.values()) {
const stats = summarizeValues(group.values);
const row = document.createElement("div");
row.className = "aggregate-row";
row.innerHTML = `
<span>${interactionGroupLabel(group.action, group.phase)}</span>
<small>n=${stats.count}</small>
<strong>${formatMs(stats.avg)}</strong>
<strong>${formatMs(stats.max)}</strong>
<strong>${formatMs(stats.p95)}</strong>
`;
table.append(row);
}
advancedInteractionStatsEl.append(table);
}
function renderInteractionHistory() {
if (!advancedInteractionHistoryEl) return;
advancedInteractionHistoryEl.innerHTML = "";
if (!state.interactionRuns.length) {
renderEmptyState(advancedInteractionHistoryEl, "No pan or zoom operations recorded yet.");
return;
}
const table = document.createElement("div");
table.className = "interaction-table";
for (const run of state.interactionRuns) {
const row = document.createElement("div");
row.className = "interaction-row";
row.innerHTML = `
<span>${run.kind}</span>
<strong>${formatMs(run.ms)}</strong>
<span>${Number(run.zoom || 1).toFixed(2)}x</span>
<span>${run.fast ? "fast" : "full"}</span>
`;
table.append(row);
}
advancedInteractionHistoryEl.append(table);
}
function renderAdvancedData() {
renderGenerationHistory();
renderPatchHistory();
renderInteractionSummary();
renderInteractionHistory();
renderViewportDiagnostics();
renderPatchDiagnostics();
renderWorkerWorldDiagnostics();
renderWarningHistory();
}
function reportValue(value) {
return value == null || value === "" ? "-" : String(value);
}
function reportRows(title, rows = []) {
const lines = [`[${title}]`];
const visibleRows = (rows || []).filter(Boolean);
if (!visibleRows.length) {
lines.push("- none");
return lines;
}
for (const row of visibleRows) {
const sub = row.sub ? ` (${row.sub})` : "";
lines.push(`- ${row.label}: ${reportValue(row.value)}${sub}`);
}
return lines;
}
function reportMetrics(title, runs = []) {
const lines = reportRows(title, performanceMetricsForRuns(runs));
if (!runs.length) return lines;
lines.push("Runs:");
runs.forEach((run, index) => {
const prefix = run.kind ? `${run.kind} · ` : "";
const variant = run.kind ? `, variant=${run.variant ?? "-"}, worker=${run.worker ? "yes" : "no"}` : "";
const write = run.writeAreaCells ? `, write=${formatAreaCells(run.writeAreaCells)}, write/selection=${formatPercent((run.writeRatio || 0) * 100)}` : "";
lines.push(` ${index + 1}. ${prefix}${terrainTypeLabel(run.terrainType)}: total=${formatMs(run.totalMs)}, area=${formatAreaCells(run.areaCells)}, sec/1000 cells=${formatSeconds(run.secondsPerThousand)}${variant}${write}`);
if (run.timings?.length) {
lines.push(` breakdown: ${run.timings.map((row) => `${row.label}=${formatMs(row.ms)}`).join(", ")}`);
}
});
return lines;
}
function interactionGroupsForReport() {
const groups = new Map();
for (const run of state.interactionRuns) {
const action = run.action || interactionAction(run.kind);
const phase = run.phase || interactionPhase(run);
const key = `${action}:${phase}`;
if (!groups.has(key)) groups.set(key, { action, phase, values: [] });
groups.get(key).values.push(run.ms);
}
return Array.from(groups.values()).map((group) => {
const stats = summarizeValues(group.values);
return {
label: interactionGroupLabel(group.action, group.phase),
value: `n=${stats.count}, avg=${formatMs(stats.avg)}, max=${formatMs(stats.max)}, p95=${formatMs(stats.p95)}`,
};
});
}
function reportInteractions() {
const lines = reportRows("Viewport Interaction Latency", interactionGroupsForReport());
if (!state.interactionRuns.length) return lines;
lines.push("Recent operations:");
state.interactionRuns.forEach((run, index) => {
lines.push(` ${index + 1}. ${run.kind}: ${formatMs(run.ms)}, zoom=${Number(run.zoom || 1).toFixed(2)}x, phase=${run.fast ? "fast" : "full"}`);
});
return lines;
}
function viewportDiagnosticRowsForReport() {
const viewport = state.diagnostics.lastViewport;
return viewport ? [
{ label: "Viewport", value: `${viewport.viewWidth}×${viewport.viewHeight}`, sub: `${formatAreaCells(viewport.cells)} drawn` },
{ label: "Canvas CSS", value: `${Math.round(viewport.cssWidth)}×${Math.round(viewport.cssHeight)}`, sub: "display pixels" },
{ label: "Canvas bitmap", value: `${viewport.bitmapWidth}×${viewport.bitmapHeight}`, sub: "render target" },
{ label: "Zoom", value: `${Number(viewport.zoom || 1).toFixed(2)}x`, sub: viewport.fast ? "fast redraw" : "full redraw" },
{ label: "Viewport build", value: formatMs(viewport.viewportMs), sub: "getViewportMap" },
{ label: "Canvas draw", value: formatMs(viewport.drawMs), sub: "drawMap" },
{ label: "Hover index", value: formatMs(viewport.hoverMs), sub: "labels / hit targets" },
{ label: "Render total", value: formatMs(viewport.totalRenderMs), sub: `${viewport.mode} mode` },
] : [];
}
function featureCountRowsForReport() {
const counts = state.diagnostics.lastFeatureCounts;
return counts ? [
{ label: "Road paths", value: counts.roads.toLocaleString(), sub: `${counts.roadCells.toLocaleString()} path cells` },
{ label: "Rail paths", value: counts.railways.toLocaleString(), sub: `${counts.railwayCells.toLocaleString()} path cells` },
{ label: "River paths", value: counts.rivers.toLocaleString(), sub: `${counts.riverCells.toLocaleString()} path cells` },
{ label: "Settlements", value: counts.settlements.toLocaleString(), sub: "cities, towns, ports, castles" },
{ label: "Stations", value: counts.stations.toLocaleString(), sub: "rail markers" },
{ label: "Hover labels", value: counts.labels.toLocaleString(), sub: "active hit targets" },
{ label: "Admin centers", value: counts.adminCenters.toLocaleString(), sub: "municipality labels" },
{ label: "Admin borders", value: counts.adminBorders.toLocaleString(), sub: `${counts.adminBorderCells.toLocaleString()} path cells` },
{ label: "Industry / logistics", value: (counts.industrialZones + counts.logisticsParks).toLocaleString(), sub: `${counts.industrialZones} industrial, ${counts.logisticsParks} logistics` },
] : [];
}
function workerDiagnosticRowsForReport() {
const workerAvailable = typeof Worker !== "undefined";
return [
{ label: "Worker API", value: workerAvailable ? "available" : "unavailable", sub: "browser capability" },
{ label: "Patch worker object", value: patchWorker ? "active" : "not active", sub: patchWorker ? "created" : "created on demand" },
{ label: "Last patch worker", value: state.diagnostics.lastWorkerUsed == null ? "-" : (state.diagnostics.lastWorkerUsed ? "used" : "main thread"), sub: state.diagnostics.lastPatchWorkerKind || "no patch run" },
{ label: "Fallback reason", value: state.diagnostics.lastWorkerFallbackReason || "none", sub: "last worker fallback" },
];
}
function worldDiagnosticRowsForReport() {
const world = displayWorld();
const source = displaySourceMap();
const expansion = state.diagnostics.lastWorldExpansion;
return [
{ label: "World size", value: world ? `${world.width}×${world.height}` : "-", sub: world ? formatAreaCells(world.width * world.height) : "no world" },
{ label: "Source map", value: source ? `${source.width || MAP_W}×${source.height || MAP_H}` : "-", sub: source ? formatAreaCells((source.width || MAP_W) * (source.height || MAP_H)) : "no source" },
{ label: "Camera", value: state.camera ? `${Math.round(state.camera.x || 0)}, ${Math.round(state.camera.y || 0)}` : "-", sub: "world-space origin" },
{ label: "World expansions", value: state.diagnostics.worldExpansionCount.toLocaleString(), sub: expansion ? `last dx ${expansion.dx}, dy ${expansion.dy}` : "none yet" },
{ label: "Pending patch", value: state.pendingPatch ? "yes" : "no", sub: state.pendingPatch ? `${terrainTypeLabel(state.pendingPatch.terrainType)} · variant ${state.pendingPatch.variant}` : "committed world" },
];
}
function reportWarnings() {
const lines = ["[Warnings / Errors]"];
if (!state.diagnosticLog.length) {
lines.push("- none");
return lines;
}
state.diagnosticLog.forEach((entry, index) => {
const time = entry.createdAt instanceof Date ? entry.createdAt.toISOString() : "-";
lines.push(`- ${index + 1}. ${time} ${String(entry.level || "info").toUpperCase()} ${entry.title}: ${entry.message || "-"}`);
});
return lines;
}
function buildImportantDebugReport() {
const source = displaySourceMap();
const summaryRows = getStats(source).map(([label, value]) => ({ label, value }));
const contextRows = [
{ label: "Generated at", value: new Date().toISOString() },
{ label: "Seed", value: seedInput?.value || state.seedText || "-" },
{ label: "Generation type", value: terrainTypeLabel(generationTypeInput?.value || state.generationType), sub: generationTypeInput?.value || state.generationType },
{ label: "Patch terrain", value: terrainTypeLabel(patchTerrainTypeInput?.value || "auto"), sub: patchTerrainTypeInput?.value || "auto" },
{ label: "Tool mode", value: state.toolMode || "-" },
{ label: "Display mode", value: state.mode || "-" },
{ label: "Features", value: state.showFeatures ? "on" : "off" },
{ label: "Labels", value: state.showLabels ? "on" : "off" },
];
return [
"Prefecture Map Generator — Important Debug Data",
"===============================================",
...reportRows("Context", contextRows),
"",
...reportRows("Current Map Summary", summaryRows),
"",
...reportMetrics("Full Generation Performance", state.generationRuns),
"",
...reportMetrics("Patch / Additional Generation Performance", state.patchRuns),
"",
...reportInteractions(),
"",
...reportRows("Viewport Diagnostics", viewportDiagnosticRowsForReport()),
"",
...reportRows("Feature Counts", featureCountRowsForReport()),
"",
...reportRows("Selection / Write Ratio", selectionWriteDiagnostics()),
"",
...reportRows("Worker Diagnostics", workerDiagnosticRowsForReport()),
"",
...reportRows("World Diagnostics", worldDiagnosticRowsForReport()),
"",
...reportWarnings(),
].join("\n");
}
function setCopyDebugStatus(text, isError = false) {
if (!copyDebugStatusEl) return;
copyDebugStatusEl.textContent = text;
copyDebugStatusEl.classList.toggle("error", !!isError);
window.clearTimeout(setCopyDebugStatus.timer);
setCopyDebugStatus.timer = window.setTimeout(() => {
if (copyDebugStatusEl.textContent === text) copyDebugStatusEl.textContent = "";
copyDebugStatusEl.classList.remove("error");
}, 1800);
}
function copyTextFallback(text) {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "0";
document.body.append(textarea);
textarea.select();
const ok = document.execCommand("copy");
textarea.remove();
if (!ok) throw new Error("Copy command failed");
}
async function copyImportantDebugData(event) {
event?.preventDefault?.();
event?.stopPropagation?.();
const text = buildImportantDebugReport();
try {
if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(text);
else copyTextFallback(text);
setCopyDebugStatus("Copied");
} catch (error) {
console.warn("Failed to copy debug data", error);
try {
copyTextFallback(text);
setCopyDebugStatus("Copied");
} catch (fallbackError) {
console.warn("Fallback copy failed", fallbackError);
setCopyDebugStatus("Copy failed", true);
}
}
}
function renderTimingRows(timings = []) {
if (!progressTimingsEl) return;
progressTimingsEl.innerHTML = "";
for (const row of timings) {
const item = document.createElement("div");
item.className = "progress-timing-row";
const label = document.createElement("span");
label.textContent = row.label;
const value = document.createElement("strong");
value.textContent = formatMs(row.ms);
item.append(label, value);
progressTimingsEl.append(item);
}
}
function updateGenerationProgress(event) {
if (!progressEl) return;
progressEl.classList.remove("hidden");
if (event?.status === "start") generationCurrentStage = event.label || "Preparing";
if (progressStageEl) {
const elapsed = generationStartedAt ? ` / elapsed ${formatMs(performance.now() - generationStartedAt)}` : "";
progressStageEl.textContent = event?.status === "done"
? `Completed: ${event.label} / ${formatMs(event.ms)}${elapsed}`
: `Running: ${event?.label || generationCurrentStage || "Preparing"}${elapsed}`;
}
renderTimingRows(event?.timings || []);
}
function setProgressVisible(visible, message = "Preparing") {
if (!progressEl) return;
progressEl.classList.toggle("hidden", !visible);
if (visible) {
generationStartedAt = performance.now();
generationCurrentStage = message;
if (generationTimer) window.clearInterval(generationTimer);
generationTimer = window.setInterval(() => {
if (progressStageEl && !progressEl.classList.contains("hidden")) {
progressStageEl.textContent = `Running: ${generationCurrentStage || "Preparing"} / elapsed ${formatMs(performance.now() - generationStartedAt)}`;
}
}, 100);
} else if (generationTimer) {
window.clearInterval(generationTimer);
generationTimer = null;
}
if (progressStageEl) progressStageEl.textContent = message;
if (visible) renderTimingRows([]);
}
function nextFrame() {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
function countInside(items) {
return (items || []).filter((item) => item?.insidePrefecture !== false).length;
}
function totalRailLineCount(map) {
return (map.railways || []).length + (map.branchRailways || []).length + (map.ringRailways || []).length + (map.externalRailways || []).length;
}
function totalRoadLineCount(map) {
return (map.nationalRoads || []).length + (map.ringRoads || []).length + (map.externalRoads || []).length + (map.expressways || []).length + (map.externalExpressways || []).length;
}
function getStats(map) {
if (!map) return [];
return [
["Population", (map.totalPopulation || 0).toLocaleString()],
["Municipalities", (map.adminCenters || []).length.toLocaleString()],
["Major cities", countInside(map.modernCities).toLocaleString()],
["Ports", countInside(map.ports).toLocaleString()],
["Rail lines", totalRailLineCount(map).toLocaleString()],
["Generation", map.generationTotalMs ? formatMs(map.generationTotalMs) : "-"],
];
}
function renderStats(map) {
if (!statsEl) return;
statsEl.innerHTML = "";
for (const [labelText, valueText] of getStats(map)) {
const row = document.createElement("div");
row.className = "stat-row";
const label = document.createElement("span");
label.textContent = labelText;
const value = document.createElement("strong");
value.textContent = String(valueText);
row.append(label, value);
statsEl.append(row);
}
}
const legendItems = {
base: [
["terrain-swatch", "Terrain shading"],
["river-major", "Rivers and lakes", "line"],
["border-swatch", "Prefecture border"],
],
all: [
["city-icon", "City / major town", "icon"],
["rail-line", "Railway", "line"],
["road-line", "Major road", "line"],
["river-major", "River", "line"],
["border-swatch", "Prefecture border"],
],
terrain: [
["terrain-swatch", "Elevation and relief"],
["sea-swatch", "Sea / lake"],
["river-major", "River system", "line"],
["border-swatch", "Prefecture border"],
],
modern: [
["city-icon", "Modern city", "icon"],
["station-icon", "Station", "icon"],
["rail-line", "Railway", "line"],
["minor-road-line", "Local road", "line"],
],
history: [
["town-icon", "Market / village", "icon"],
["castle-icon", "Castle / ruins", "icon"],
["port-icon", "Historical port", "icon"],
["old-road-line", "Premodern road", "line"],
],
landuse: [
["urban-swatch", "Urban land use"],
["industry-icon", "Industry / logistics", "icon"],
["city-icon", "City core", "icon"],
["river-major", "Water body", "line"],
],
admin: [
["admin-swatch", "Municipal border"],
["border-swatch", "Prefecture border"],
["city-icon", "Admin center", "icon"],
],
};
function legendRowsForMode(mode) {
return legendItems[mode] || legendItems.all;
}
function renderLegendGrid(container, rows) {
if (!container) return;
container.innerHTML = "";
for (const [className, text, kind = "swatch"] of rows) {
const row = document.createElement("div");
row.className = "legend-row";
const mark = document.createElement("span");
mark.className = kind === "line" ? `legend-line ${className}` : kind === "icon" ? `legend-icon ${className}` : `legend-swatch ${className}`;
const label = document.createElement("span");
label.textContent = text;
row.append(mark, label);
container.append(row);
}
}
function renderLegend() {
const rows = legendRowsForMode(state.mode);
renderLegendGrid(mainLegendGrid, rows);
renderLegendGrid(floatingLegendGrid, rows.slice(0, 5));
}
function buildHoverEntities(map) {
return [
...(map.modernCities || []),
...(map.ports || []),
...(map.stations || []),
...(map.interchanges || []),
...(map.industrialZones || []),
...(map.logisticsParks || []),
...(map.newTowns || []),
...(map.castles || []),
...(map.markets || []),
...(map.villages || []),
...(map.adminCenters || []),
];
}
function nearestEntity(items, x, y, maxDistance = 5) {
let best = null;
let bestD = maxDistance;
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; }
}
return best;
}
function landuseName(value) {
return landuseLabel(value);
}
const ADMIN_ID_KEYS = ["adminId", "municipalityId", "adminNumericId", "id", "numericId"];
const PREFECTURE_ID_KEYS = ["prefectureRegionId", "prefectureId", "id", "numericId"];
const MUNICIPALITY_NAME_KEYS = ["municipalityName", "name", "canonicalSettlementName", "municipalityRootName", "generatedMunicipalityName", "label"];
const PREFECTURE_NAME_KEYS = ["prefectureName", "prefectureRegionName", "regionName", "name", "label"];
const POPULATION_KEYS = ["municipalityPopulation", "adminPopulation", "population", "estimatedPopulation"];
function numericIdOf(item, keys = ADMIN_ID_KEYS) {
for (const key of keys) {
const value = item?.[key];
if (Number.isFinite(value)) return Math.floor(value);
}
return null;
}
function 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 numericPrefectureId(item) {
const value = numericIdOf(item, PREFECTURE_ID_KEYS);
return value == null ? -1 : value;
}
function firstUsableText(item, keys) {
for (const key of keys) {
const value = item?.[key];
if (!looksNumericName(value)) return String(value).trim();
}
return "";
}
function firstPopulation(item) {
for (const key of POPULATION_KEYS) {
const value = item?.[key];
if (Number.isFinite(value) && value > 0) return Math.round(value);
}
return null;
}
function adminCenterForId(map, adminId) {
if (!map || adminId == null || adminId < 0) return null;
const centers = (map.adminCenters || []).filter(Boolean);
const exact = centers.find((center) => hasNumericId(center, adminId));
if (exact) return exact;
// Some legacy/admin debug arrays were once addressed by array index. Keep this
// only as a guarded fallback so numeric IDs are not mistaken for indexes.
const direct = map.adminCenters?.[adminId];
return hasNumericId(direct, adminId) ? direct : null;
}
function looksNumericName(name) {
if (!name) return true;
const text = String(name).trim();
return !text || /^県域\d*$/u.test(text) || /^-?\d+(?:\s*[,]\s*\d+)*$/u.test(text) || /^(Municipality|Prefecture|Admin|Region)\s*-?\d+/i.test(text);
}
function nearestNamedAdminCenter(map, cellIndex, maxDistance = 36, adminId = null) {
if (!map || cellIndex < 0) return null;
const x = cellIndex % map.width;
const y = Math.floor(cellIndex / map.width);
let best = null;
let bestD = maxDistance;
for (const center of map.adminCenters || []) {
if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y)) continue;
if (adminId != null && adminId >= 0 && !hasNumericId(center, adminId)) continue;
if (!firstUsableText(center, MUNICIPALITY_NAME_KEYS)) continue;
const d = Math.hypot(center.x - x, center.y - y);
if (d < bestD) { best = center; bestD = d; }
}
return best;
}
function adminName(map, adminId, cellIndex = -1) {
const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex, 54, adminId);
const name = firstUsableText(center, MUNICIPALITY_NAME_KEYS);
if (name) return name;
return adminId >= 0 ? "Unnamed municipality" : "-";
}
function adminPopulation(map, adminId, cellIndex = -1) {
const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex, 54, adminId);
const centerPop = firstPopulation(center);
if (centerPop !== null) return centerPop;
let sum = 0;
let found = false;
for (const key of ["modernCities", "satelliteCities", "ports", "markets", "villages"]) {
for (const p of map?.[key] || []) {
if (!hasNumericId(p, adminId)) continue;
const pop = firstPopulation(p);
if (pop !== null) { sum += pop; found = true; }
}
}
return found ? sum : null;
}
function worldSourceMap() {
return displayWorld()?.sourceMap || null;
}
function prefectureRegionById(id, maps = []) {
if (!Number.isFinite(id) || id < 0) return null;
for (const source of maps) {
const region = (source?.prefectureRegions || []).find((p) => hasNumericId(p, id, PREFECTURE_ID_KEYS));
if (region) return region;
}
return null;
}
function prefectureNameForId(id, adminId = -1) {
if (!Number.isFinite(id) || id < 0) return "";
const sources = [activeMap(), worldSourceMap()].filter(Boolean);
const region = prefectureRegionById(id, sources);
const regionName = firstUsableText(region, PREFECTURE_NAME_KEYS);
if (regionName) return regionName;
for (const source of sources) {
for (const center of source?.adminCenters || []) {
if (adminId >= 0 && !hasNumericId(center, adminId)) continue;
const centerPref = numericPrefectureId(center);
if (centerPref >= 0 && centerPref !== id) continue;
const name = firstUsableText(center, ["prefectureName", "prefectureRegionName", "regionName"]);
if (name) return name;
}
}
return "";
}
function prefectureNameForCell(map, i) {
const id = map.prefectureRegionId?.[i] ?? -1;
const direct = prefectureNameForId(id);
if (direct) return direct;
const adminId = map.adminId?.[i] ?? -1;
const mappedPref = adminId >= 0 ? map.municipalityToPrefectureId?.[adminId] ?? state.world?.sourceMap?.municipalityToPrefectureId?.[adminId] ?? -1 : -1;
const mapped = prefectureNameForId(mappedPref, adminId);
if (mapped) return mapped;
const center = mappedPref === id ? nearestNamedAdminCenter(map, i, 90, adminId) : null;
const fromCenter = firstUsableText(center, ["prefectureName", "prefectureRegionName", "regionName"]);
return fromCenter || (id >= 0 ? "Unnamed prefecture" : "-");
}
function updateTooltip(event) {
const map = activeMap();
if (!map || !tooltipEl || dragState.mode) return;
const rect = canvas.getBoundingClientRect();
const cell = mapClientToCell(event);
if (!cell) return;
const { x, y } = cell;
if (x < 0 || y < 0 || x >= map.width || y >= map.height) {
tooltipEl.classList.remove("visible");
return;
}
const i = y * map.width + x;
const worldCell = viewportCellToWorldCell({ x, y });
const entity = nearestEntity(state.hoverEntities, x, y);
const elevation = map.elevation?.[i] ?? 0;
const density = map.populationDensity?.[i] ?? map.settlementScore?.[i] ?? 0;
const hoveredAdminId = map.adminId?.[i] ?? -1;
const hoveredAdminPopulation = adminPopulation(map, hoveredAdminId, i);
const coordinateText = worldCell ? `World ${worldCell.x}, ${worldCell.y} / View ${x}, ${y}` : `${x}, ${y}`;
const entityName = firstUsableText(entity, ["name", "facilityLabel", "municipalityName", "canonicalSettlementName", "kind"]);
const entityTitle = entity
? `${entityName || adminName(map, hoveredAdminId, i) || entity.kind || "Feature"} / ${entity.kind || "Feature"}`
: coordinateText;
const lines = [
`<strong>${entityTitle}</strong>`,
`Prefecture: ${prefectureNameForCell(map, i)}`,
`Admin: ${adminName(map, hoveredAdminId, i)}`,
`Admin Pop: ${hoveredAdminPopulation === null ? "-" : hoveredAdminPopulation.toLocaleString()}`,
`Land: ${map.sea?.[i] ? "Sea" : landuseName(map.landuse?.[i])}`,
`Elevation: ${elevation.toFixed(3)} / Slope: ${(map.slope?.[i] ?? 0).toFixed(3)}`,
`River: ${(map.river?.[i] ?? 0).toFixed(2)} / Density: ${density.toFixed(2)}`,
];
if (entity?.population) lines.splice(1, 0, `Population: ${entity.population.toLocaleString()}`);
tooltipEl.innerHTML = lines.join("<br>");
const margin = 8;
const offset = 14;
const maxLeft = Math.max(margin, rect.width - tooltipEl.offsetWidth - margin);
const maxTop = Math.max(margin, rect.height - tooltipEl.offsetHeight - margin);
const desiredLeft = event.clientX - rect.left + offset;
const desiredTop = event.clientY - rect.top + offset;
tooltipEl.style.left = `${Math.min(Math.max(margin, desiredLeft), maxLeft)}px`;
tooltipEl.style.top = `${Math.min(Math.max(margin, desiredTop), maxTop)}px`;
tooltipEl.classList.add("visible");
}
function renderModeButtons() {
if (!modeGrid) return;
modeGrid.innerHTML = "";
for (const [key, label] of modes) {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.className = key === state.mode ? "mode-button active" : "mode-button";
button.addEventListener("click", () => {
state.mode = key;
renderModeButtons();
renderLegend();
redraw();
});
modeGrid.append(button);
}
renderLegend();
}
async function regenerate() {
state.seedText = seedInput.value;
state.generationType = generationTypeInput?.value || "auto";
setProgressVisible(true, "Preparing generation...");
await nextFrame();
try {
state.map = await generateMapAsync(parseSeed(state.seedText), { onProgress: updateGenerationProgress, terrainType: state.generationType });
state.world = createWorldMap(state.map);
state.camera = createInitialCamera(state.world);
state.lastPatchResult = null;
state.pendingPatch = null;
resetPatchVariant({ update: false });
hideSelectionOverlay({ discardPreview: true });
recordGenerationRun(state.map, state.generationType);
renderStats(state.map);
redraw();
if (progressStageEl) progressStageEl.textContent = `Done in ${formatMs(state.map.generationTotalMs || 0)}`;
renderTimingRows(state.map.generationTimings || []);
window.setTimeout(() => setProgressVisible(false), 900);
} catch (error) {
const reason = error?.message || String(error || "unknown generation error");
recordDiagnosticLog("error", "Full generation failed", reason, { terrainType: state.generationType });
if (progressStageEl) progressStageEl.textContent = `Generation failed: ${reason}`;
throw error;
}
}
function derivePatchSeed(rect, terrainType, variant = 0) {
let h = parseSeed(state.seedText) ^ 0x9e3779b9;
h = Math.imul(h ^ (rect.x0 | 0), 1664525) >>> 0;
h = Math.imul(h ^ (rect.y0 | 0), 1013904223) >>> 0;
h = Math.imul(h ^ (rect.x1 | 0), 2246822519) >>> 0;
h = Math.imul(h ^ (rect.y1 | 0), 3266489917) >>> 0;
h = Math.imul(h ^ normalizePatchVariant(variant), 668265263) >>> 0;
for (const ch of String(terrainType || "auto")) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
return h >>> 0;
}
function beginZoomVisual(oldZoom, event) {
if (zoomVisualState) return;
const rect = canvas.getBoundingClientRect();
zoomVisualState = {
baseRect: rect,
startZoom: clampZoom(oldZoom || state.zoom || 1),
originX: Math.min(Math.max(event.clientX - rect.left, 0), rect.width),
originY: Math.min(Math.max(event.clientY - rect.top, 0), rect.height),
};
canvas.style.transformOrigin = `${zoomVisualState.originX}px ${zoomVisualState.originY}px`;
canvas.style.willChange = "transform";
canvas.classList.add("is-zooming");
if (selectionSvgEl) selectionSvgEl.style.visibility = "hidden";
}
function scheduleZoomVisualUpdate() {
if (!zoomVisualState || zoomRedrawRaf != null) return;
zoomRedrawRaf = requestAnimationFrame(() => {
zoomRedrawRaf = null;
if (!zoomVisualState) return;
const scale = clampZoom(state.zoom || 1) / Math.max(1e-6, zoomVisualState.startZoom || 1);
canvas.style.transform = `scale(${scale})`;
});
}
function finishZoomVisual() {
if (zoomRedrawRaf != null) {
cancelAnimationFrame(zoomRedrawRaf);
zoomRedrawRaf = null;
}
if (zoomVisualState) {
canvas.style.transform = "";
canvas.style.transformOrigin = "";
canvas.style.willChange = "";
canvas.classList.remove("is-zooming");
if (selectionSvgEl) selectionSvgEl.style.visibility = "";
zoomVisualState = null;
}
}
function handleCanvasWheel(event) {
if (!state.world || !activeMap()) return;
event.preventDefault();
if (zoomLatencyStartedAt == null) zoomLatencyStartedAt = performance.now();
tooltipEl?.classList.remove("visible");
const beforeSize = viewportSizeForZoom(state.zoom);
const beforeCell = mapClientToCell(event, beforeSize);
const beforeWorld = beforeCell ? viewportCellToWorldCell(beforeCell) : null;
const oldZoom = clampZoom(state.zoom || 1);
const delta = event.deltaY < 0 ? 1.10 : 1 / 1.10;
const nextZoom = clampZoom(oldZoom * delta);
if (Math.abs(nextZoom - oldZoom) < 0.001) return;
state.zoom = nextZoom;
const nextSize = syncViewportSize();
if (beforeWorld) {
const afterCell = mapClientToCell(event, nextSize);
if (afterCell) {
state.camera = clampCameraForView({
x: beforeWorld.x - afterCell.x,
y: beforeWorld.y - afterCell.y,
}, nextSize);
}
}
// Wheel events can fire dozens of times per second. During the gesture, keep
// the last rendered bitmap and only transform it on the GPU; rebuild the
// viewport and labels once the gesture settles.
beginZoomVisual(oldZoom, event);
scheduleZoomVisualUpdate();
if (zoomSettledTimer != null) clearTimeout(zoomSettledTimer);
zoomSettledTimer = window.setTimeout(() => {
zoomSettledTimer = null;
const startedAt = zoomLatencyStartedAt || performance.now();
zoomLatencyStartedAt = null;
finishZoomVisual();
redraw({ fastTerrain: false, allowWorldExpand: false });
recordInteractionLatency("wheel zoom", startedAt, { zoom: state.zoom });
}, 170);
}
function cloneForPatchPreview(value, seen = new Map()) {
if (value == null || typeof value !== "object") return value;
if (ArrayBuffer.isView(value)) return new value.constructor(value);
if (value instanceof ArrayBuffer) return value.slice(0);
if (seen.has(value)) return seen.get(value);
if (value instanceof Map) {
const out = new Map();
seen.set(value, out);
for (const [k, v] of value.entries()) out.set(cloneForPatchPreview(k, seen), cloneForPatchPreview(v, seen));
return out;
}
if (Array.isArray(value)) {
const out = [];
seen.set(value, out);
for (const item of value) out.push(cloneForPatchPreview(item, seen));
return out;
}
const out = {};
seen.set(value, out);
for (const [key, item] of Object.entries(value)) out[key] = cloneForPatchPreview(item, seen);
return out;
}
function createPatchWorker() {
if (patchWorker) return patchWorker;
if (typeof Worker === "undefined") {
state.diagnostics.lastWorkerFallbackReason = "Worker API unavailable";
return null;
}
try {
patchWorker = new Worker(new URL("./mapPatchWorker.js", import.meta.url), { type: "module" });
patchWorker.addEventListener("error", (event) => {
const reason = event?.message || "Patch worker runtime error";
state.diagnostics.lastWorkerFallbackReason = reason;
recordDiagnosticLog("warning", "Patch worker reset", reason);
patchWorker?.terminate?.();
patchWorker = null;
});
} catch (error) {
state.diagnostics.lastWorkerFallbackReason = error?.message || "Patch worker creation failed";
patchWorker = null;
}
return patchWorker;
}
function runPatchInWorker(world, rect, options) {
const worker = createPatchWorker();
if (!worker) return null;
const id = ++patchJobSeq;
return new Promise((resolve, reject) => {
const cleanup = () => {
worker.removeEventListener("message", onMessage);
worker.removeEventListener("error", onError);
worker.removeEventListener("messageerror", onMessageError);
};
const onMessage = (event) => {
if (event.data?.id !== id) return;
cleanup();
if (event.data.ok) resolve({ world: event.data.world, result: event.data.result, worker: true });
else reject(new Error(event.data.error || "Patch worker failed"));
};
const onError = (event) => {
cleanup();
reject(new Error(event.message || "Patch worker error"));
};
const onMessageError = () => {
cleanup();
reject(new Error("Patch worker message clone failed"));
};
worker.addEventListener("message", onMessage);
worker.addEventListener("error", onError);
worker.addEventListener("messageerror", onMessageError);
worker.postMessage({ id, world, rect, options });
});
}
async function generatePatchPreviewWorld(baseWorld, rect, options) {
const workerPromise = runPatchInWorker(baseWorld, rect, options);
if (workerPromise) {
try {
return await workerPromise;
} catch (error) {
const reason = error?.message || String(error || "unknown worker failure");
console.warn("Patch worker unavailable; falling back to main-thread preview generation.", error);
state.diagnostics.lastWorkerFallbackReason = reason;
recordDiagnosticLog("warning", "Patch worker fallback", reason);
patchWorker?.terminate?.();
patchWorker = null;
}
} else if (state.diagnostics.lastWorkerFallbackReason) {
recordDiagnosticLog("info", "Patch generated on main thread", state.diagnostics.lastWorkerFallbackReason);
}
const previewWorld = cloneForPatchPreview(baseWorld);
const result = generatePatch(previewWorld, rect, options);
return { world: previewWorld, result, worker: false };
}
async function generateSelectedPatch(kind = "Patch preview") {
const validation = validatePatchRect(state.selectionRect, state.world);
if (!validation.ok) {
recordDiagnosticLog("warning", "Invalid patch selection", validation.reason || "Selection is not valid.");
updatePatchControls();
return;
}
const terrainType = patchTerrainTypeInput?.value || generationTypeInput?.value || "auto";
const variant = readPatchVariant();
const seed = derivePatchSeed(validation.rect, terrainType, variant);
setProgressVisible(true, "Generating preview patch...");
await nextFrame();
const patchStartedAt = performance.now();
try {
const job = await generatePatchPreviewWorld(state.world, validation.rect, { terrainType, seed, variant });
const result = job.result;
if (!result.ok) {
const reason = result.reason || "invalid selection";
recordDiagnosticLog("warning", "Patch failed", reason, { kind, terrainType, variant });
if (progressStageEl) progressStageEl.textContent = `Patch failed: ${reason}`;
updatePatchControls();
window.setTimeout(() => setProgressVisible(false), 1200);
return;
}
const patchWallMs = performance.now() - patchStartedAt;
state.pendingPatch = { world: job.world, result, rect: validation.rect, terrainType, seed, variant, worker: job.worker };
recordPatchRun(result, terrainType, validation.rect, variant, { kind, worker: job.worker, wallMs: patchWallMs });
// Keep the committed world untouched. The preview world is rendered until
// the user clicks once without dragging; Alternative replaces this preview
// from the same committed base, so old candidate artifacts cannot accumulate.
state.viewportMap = null;
redraw({ fastTerrain: true, allowWorldExpand: false });
window.setTimeout(() => redraw({ fastTerrain: false, allowWorldExpand: false }), 80);
renderStats(displaySourceMap());
updatePatchControls();
if (progressStageEl) progressStageEl.textContent = `Preview generated${job.worker ? " in worker" : ""}: ${result.label} / variant ${result.variant ?? variant} / write ${formatRectSize(result.rects.writeRect)}. Use Apply Preview to commit.`;
renderTimingRows(result.patchTimings || []);
window.setTimeout(() => setProgressVisible(false), 900);
} catch (error) {
const reason = error?.message || String(error || "unknown patch error");
recordDiagnosticLog("error", "Patch exception", reason, { kind, terrainType, variant });
if (progressStageEl) progressStageEl.textContent = `Patch failed: ${reason}`;
throw error;
}
}
async function generateAlternativePatch() {
const validation = validatePatchRect(state.selectionRect, state.world);
if (!validation.ok) {
updatePatchControls();
return;
}
setPatchVariant(readPatchVariant() + 1, { update: false });
await generateSelectedPatch("Patch alternative");
}
function redraw(options = {}) {
const redrawStartedAt = performance.now();
const renderWorld = displayWorld();
if (!renderWorld) return;
const viewSize = syncViewportSize();
const mayExpand = !state.pendingPatch && options.allowWorldExpand !== false;
const expansion = mayExpand ? ensureWorldPaddingForCamera(state.world, state.camera, viewSize.width, viewSize.height) : null;
if (expansion?.expanded) {
const ex = expansion.dx || 0;
const ey = expansion.dy || 0;
state.diagnostics.worldExpansionCount += 1;
state.diagnostics.lastWorldExpansion = {
at: new Date(),
dx: ex,
dy: ey,
worldWidth: state.world?.width || 0,
worldHeight: state.world?.height || 0,
viewWidth: viewSize.width,
viewHeight: viewSize.height,
};
state.camera = { x: (state.camera?.x || 0) + ex, y: (state.camera?.y || 0) + ey };
if (dragState.mode === "pan") {
dragState.startCameraX += ex;
dragState.startCameraY += ey;
if (dragState.pendingCamera) dragState.pendingCamera = { x: dragState.pendingCamera.x + ex, y: dragState.pendingCamera.y + ey };
}
if (state.selectionRect) {
state.selectionRect = {
...state.selectionRect,
x0: state.selectionRect.x0 + ex,
y0: state.selectionRect.y0 + ey,
x1: state.selectionRect.x1 + ex,
y1: state.selectionRect.y1 + ey,
polygon: Array.isArray(state.selectionRect.polygon) ? state.selectionRect.polygon.map((p) => ({ x: p.x + ex, y: p.y + ey })) : state.selectionRect.polygon,
};
}
}
const activeWorldForRender = displayWorld();
state.camera = clampCameraForView(state.camera, viewSize, activeWorldForRender);
const viewportStartedAt = performance.now();
state.viewportMap = getViewportMap(activeWorldForRender, state.camera, viewSize.width, viewSize.height, { light: !!options.fastTerrain });
const viewportMs = performance.now() - viewportStartedAt;
const hoverStartedAt = performance.now();
state.hoverEntities = options.fastTerrain ? [] : buildHoverEntities(state.viewportMap);
const hoverMs = performance.now() - hoverStartedAt;
const drawStartedAt = performance.now();
drawMap(canvas, state.viewportMap, {
mode: state.mode,
showFeatures: state.showFeatures && !options.fastTerrain,
showLabels: state.showLabels && !options.fastTerrain,
continuousTerrain: !options.fastTerrain,
fastTerrain: !!options.fastTerrain,
zoom: state.zoom || 1,
});
const drawMs = performance.now() - drawStartedAt;
applyCanvasZoom();
if (state.selectionRect && dragState.mode !== "select") updateSelectionOverlayFromWorldRect();
updateRenderDiagnostics(options, {
viewportMs,
hoverMs,
drawMs,
totalRenderMs: performance.now() - redrawStartedAt,
});
renderAdvancedData();
}
function init() {
renderModeButtons();
setToolMode("pan");
generateMapButton?.addEventListener("click", regenerate);
seedInput.addEventListener("change", regenerate);
seedInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") regenerate();
});
generationTypeInput?.addEventListener("change", regenerate);
patchTerrainTypeInput?.addEventListener("change", () => {
discardPendingPatch({ redrawAfter: true });
state.lastPatchResult = null;
resetPatchVariant({ update: false });
updatePatchControls();
});
patchVariantInput?.addEventListener("change", () => setPatchVariant(patchVariantInput.value));
patchVariantInput?.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
setPatchVariant(patchVariantInput.value);
generateSelectedPatch();
}
});
generatePatchButton?.addEventListener("click", () => generateSelectedPatch("Patch preview"));
alternativePatchButton?.addEventListener("click", generateAlternativePatch);
applyPatchButton?.addEventListener("click", () => hideSelectionOverlay({ commitPreview: true }));
discardPatchButton?.addEventListener("click", () => hideSelectionOverlay({ discardPreview: true }));
toolPanButton?.addEventListener("click", () => setToolMode("pan"));
toolPatchButton?.addEventListener("click", () => setToolMode("patch"));
copyImportantDataButton?.addEventListener("click", copyImportantDebugData);
zoomInButton?.addEventListener("click", () => setZoomKeepingCenter((state.zoom || 1) * 1.2));
zoomOutButton?.addEventListener("click", () => setZoomKeepingCenter((state.zoom || 1) / 1.2));
zoomResetButton?.addEventListener("click", () => setZoomKeepingCenter(1));
centerMapButton?.addEventListener("click", recenterMap);
randomSeedButton.addEventListener("click", () => {
seedInput.value = String(Math.floor(Math.random() * 9999999));
regenerate();
});
showFeaturesInput.addEventListener("change", () => {
state.showFeatures = showFeaturesInput.checked;
redraw();
});
showLabelsInput.addEventListener("change", () => {
state.showLabels = showLabelsInput.checked;
redraw();
});
canvasShell?.setAttribute("tabindex", "0");
canvas.addEventListener("contextmenu", (event) => event.preventDefault());
canvas.addEventListener("wheel", handleCanvasWheel, { passive: false });
canvas.addEventListener("pointerdown", handleMapPointerDown);
canvas.addEventListener("pointermove", handleMapPointerMove);
canvas.addEventListener("pointerup", handleMapPointerUp);
canvas.addEventListener("pointercancel", handleMapPointerUp);
canvas.addEventListener("mousemove", updateTooltip);
canvas.addEventListener("mouseleave", () => {
tooltipEl?.classList.remove("visible");
});
let resizeRaf = null;
window.addEventListener("resize", () => {
if (resizeRaf) cancelAnimationFrame(resizeRaf);
resizeRaf = requestAnimationFrame(() => {
resizeRaf = null;
if (state.world) redraw({ allowWorldExpand: false });
else applyCanvasZoom();
});
});
updatePatchControls();
renderAdvancedData();
regenerate();
}
init();