2336 lines
92 KiB
JavaScript
2336 lines
92 KiB
JavaScript
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();
|