Prefecture Map Generator v17
-Terrain, municipalities, transport, viewport panning, patch terrain generation, and hover inspection in one generated map.
-diff --git a/app.js b/app.js index a11e37d..79292b3 100644 --- a/app.js +++ b/app.js @@ -9,19 +9,17 @@ import { PATCH_MIN_AREA, PATCH_MIN_HEIGHT, PATCH_MIN_WIDTH, buildPatchRects, gen const modes = [ ["all", "All"], ["terrain", "Terrain"], - ["history", "Premodern"], ["modern", "Modern"], - ["development", "Development"], + ["history", "Premodern"], ["landuse", "Land Use"], - ["admin", "Municipal Borders"], - ["borders-debug", "Borders Debug"], - ["transport-debug", "Transport Debug"], + ["admin", "Admin"], ]; const state = { seedText: "114514", generationType: "auto", mode: "all", + toolMode: "pan", showFeatures: true, showLabels: true, map: null, @@ -36,6 +34,19 @@ const state = { 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"); @@ -47,11 +58,37 @@ 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"); @@ -64,6 +101,7 @@ let generationTimer = null; let zoomRedrawRaf = null; let zoomSettledTimer = null; let zoomVisualState = null; +let zoomLatencyStartedAt = null; let patchWorker = null; let patchJobSeq = 0; @@ -83,6 +121,7 @@ const dragState = { selectPath: null, pendingCamera: null, panRaf: null, + panLatencyStartedAt: null, }; function displayWorld() { @@ -135,10 +174,15 @@ function mapCellScreenSize(map = activeMap()) { function applyCanvasZoom() { if (!canvas) return; state.zoom = clampZoom(state.zoom || 1); - // Keep the canvas element at a stable size. Zoom is applied inside the - // renderer transform, not by resizing the scrollable shell. - canvas.style.width = `${MAP_W * CELL_SIZE}px`; - canvas.style.height = `${MAP_H * CELL_SIZE}px`; + 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(); } @@ -386,14 +430,19 @@ function resetPatchVariant({ update = true } = {}) { } function updatePatchControls() { - if (!patchStatusEl && !generatePatchButton) return; - const validation = validatePatchRect(state.selectionRect, state.world); const variant = readPatchVariant(); - if (generatePatchButton) generatePatchButton.disabled = !validation.ok; - if (alternativePatchButton) alternativePatchButton.disabled = !validation.ok; + 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 = `Right-drag a freeform area. Minimum: ${PATCH_MIN_WIDTH} x ${PATCH_MIN_HEIGHT} cells and ${PATCH_MIN_AREA.toLocaleString()} cells. Current variant: ${variant}.`; + 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; } @@ -404,13 +453,50 @@ function updatePatchControls() { } const rects = buildPatchRects(validation.rect, state.world); const shownPatch = state.pendingPatch?.result || state.lastPatchResult; - const patchText = shownPatch - ? ` ${state.pendingPatch ? "Preview" : "Last applied"}: ${shownPatch.label}, variant ${shownPatch.variant ?? "-"}, mode ${shownPatch.patchGenerationMode || "legacy-full-pipeline"}, terrain ${shownPatch.updatedCells.toLocaleString()} cells, coast ${shownPatch.coastCellsChanged || 0}, natural ${shownPatch.naturalRegionsUpdated || 0}${shownPatch.humanGeography?.ok ? `, connectors ${(shownPatch.humanGeography.roadConnectorsCreated || 0) + (shownPatch.humanGeography.railwayConnectorsCreated || 0)}, admin ${shownPatch.humanGeography.adminCellsReassigned || 0}, invalid ports ${shownPatch.humanGeography.invalidPortsRemoved || 0}` : ""}.${state.pendingPatch ? " Click the map without dragging to apply; generate Alternative to replace the preview." : ""}` - : ""; - patchStatusEl.textContent = `Variant: ${variant}. Core: ${formatRectSize(rects.coreRect)}. Write: ${formatRectSize(rects.writeRect)}. Context: ${formatRectSize(rects.contextRect)}. Repair: ${formatRectSize(rects.repairRect)}.${patchText}`; + 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; @@ -421,11 +507,13 @@ function clearDragMode() { 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; @@ -433,11 +521,14 @@ function schedulePanRedraw(camera) { 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 }); }); } @@ -519,7 +610,11 @@ function handleMapPointerDown(event) { dragState.mode = "pan"; canvasShell.classList.add("panning"); } else { - if (state.selectionRect) commitPendingPatch({ redrawAfter: false }); + 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; @@ -601,11 +696,12 @@ function handleMapPointerUp(event) { state.camera = dragState.pendingCamera; dragState.pendingCamera = null; } - const clickDistance = Math.hypot(event.clientX - dragState.startClientX, event.clientY - dragState.startClientY); - const shouldClearSelectionByClick = wasPanning && clickDistance <= 5 && !!state.selectionRect; clearDragMode(); - if (shouldClearSelectionByClick) hideSelectionOverlay({ commitPreview: true }); - else if (wasPanning) redraw({ fastTerrain: false }); + if (wasPanning) { + const startedAt = performance.now(); + redraw({ fastTerrain: false }); + recordInteractionLatency("pan settle", startedAt, { zoom: state.zoom, fast: false }); + } event.preventDefault(); } @@ -635,6 +731,791 @@ function formatMs(ms) { 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 = ` + ${metric.label} + ${metric.value} + ${metric.sub ? `${metric.sub}` : ""} + `; + 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 = ` + ${row.label} + ${row.value} + ${row.sub ? `${row.sub}` : ""} + `; + 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 = ` + ${time} + ${entry.title} +
${entry.message || "-"}
+ `; + 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 = ` + #${index + 1} + ${name} + ${formatMs(run.totalMs)} + ${formatAreaCells(run.areaCells)}${options.patch && run.writeAreaCells ? ` / write ${formatAreaCells(run.writeAreaCells)}` : ""} + ${formatSeconds(run.secondsPerThousand)} / 1k cells + `; + + const body = document.createElement("div"); + body.className = "timing-grid"; + if (options.patch) { + const meta = document.createElement("div"); + meta.className = "timing-pill meta"; + meta.innerHTML = `Variant${run.variant ?? "-"}`; + const worker = document.createElement("div"); + worker.className = "timing-pill meta"; + worker.innerHTML = `Worker${run.worker ? "yes" : "no"}`; + const ratio = document.createElement("div"); + ratio.className = "timing-pill meta"; + ratio.innerHTML = `Write / selection${formatPercent((run.writeRatio || 0) * 100)}`; + body.append(meta, worker, ratio); + } + for (const row of run.timings) { + const timing = document.createElement("div"); + timing.className = "timing-pill"; + timing.innerHTML = `${row.label}${formatMs(row.ms)}`; + 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 = ` + ${interactionGroupLabel(group.action, group.phase)} + n=${stats.count} + ${formatMs(stats.avg)} + ${formatMs(stats.max)} + ${formatMs(stats.p95)} + `; + 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 = ` + ${run.kind} + ${formatMs(run.ms)} + ${Number(run.zoom || 1).toFixed(2)}x + ${run.fast ? "fast" : "full"} + `; + 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 = ""; @@ -687,44 +1568,32 @@ 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 [ - ["Map Type", map.terrainTemplate?.terrainTypeLabel || map.terrainDebug?.terrainTypeLabel || "-"], - ["Terrain ID", map.terrainTemplate?.terrainType || map.terrainDebug?.terrainType || "-"], - ["Generation Total", map.generationTotalMs ? formatMs(map.generationTotalMs) : "-"], - ["Geography Basis", map.geographyDebug?.version ? `${map.geographyDebug.version} / ${map.geographyDebug.stage || "-"}` : "-"], - ...(map.generationTimings || []).map((row) => [`Time: ${row.label}`, formatMs(row.ms)]), - ["Villages", countText(map.villages)], - ["Market Towns", countText(map.markets)], - ["Castles", countText(map.castles)], - ["Premodern Roads", map.premodernRoads.length], - ["Minor Roads", map.minorRoads.length], - ["Prefecture", map.prefectureName || "-"], - ["Neighbor Prefectures", (map.neighborPrefectures || []).map((p) => p.name).join(" / ") || "-"], - ["Neighbor Features", map.neighborPrefectureDetails ? `${map.neighborPrefectureDetails.cities?.length || 0} cities / ${map.neighborPrefectureDetails.adminCenters?.length || 0} municipalities / ${map.neighborPrefectureDetails.roads?.length || 0} roads` : "-"], - ["Prefectural Capital", map.prefecturalCapital?.name || "-"], - ["Regional Capitals", (map.modernCities || []).filter((p) => p.isRegionalCapital).length], - ["Modern Cities", countText(map.modernCities)], - ["Ports", `${map.ports.filter((p) => p.portClass === "major").length} major / ${map.ports.filter((p) => p.portClass === "regional").length} regional / ${map.ports.filter((p) => p.portClass === "fishing").length} fishing / ${map.ports.filter((p) => p.portClass === "lake").length} lake`], - ["Satellite Cities", countText(map.satelliteCities || [])], ["Population", (map.totalPopulation || 0).toLocaleString()], - ["Rivers", `${map.mainRivers.length} main / ${(map.tributaryRivers || []).length} tributary / ${(map.smallStreams || []).length} hidden streams`], - ["Neighbor Prefecture Borders", (map.regionalPrefectureBorders || []).length], - ["Rail Lines", map.railways.length + map.branchRailways.length + (map.ringRailways || []).length + map.externalRailways.length], - ["Industrial Zones", countText(map.industrialZones)], - ["National Roads", `${map.nationalRoads.length} / pop cover ${Math.round((map.transportDebug?.nationalRoadPopulationCoverage || 0) * 100)}% / uncovered ${(map.transportDebug?.nationalRoadUncoveredPopulation || 0).toLocaleString()}`], - ["General Ring Roads", (map.ringRoads || []).length], - ["Expressways", map.expressways.length + map.externalExpressways.length], - ["External Gateways", map.externalGateways.length], - ["Interchanges", countText(map.interchanges)], - ["Logistics Parks", countText(map.logisticsParks)], - ["New Towns", countText(map.newTowns)], - ["Municipalities", map.adminCenters.length], - ["Prefecture source", map.regionalDebug?.prefectureSource ?? "-"], + ["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"); @@ -741,6 +1610,75 @@ function renderStats(map) { } } +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 || []), @@ -959,6 +1897,7 @@ function updateTooltip(event) { } function renderModeButtons() { + if (!modeGrid) return; modeGrid.innerHTML = ""; for (const [key, label] of modes) { const button = document.createElement("button"); @@ -968,10 +1907,12 @@ function renderModeButtons() { button.addEventListener("click", () => { state.mode = key; renderModeButtons(); + renderLegend(); redraw(); }); modeGrid.append(button); } + renderLegend(); } async function regenerate() { @@ -987,13 +1928,16 @@ async function regenerate() { 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) { - if (progressStageEl) progressStageEl.textContent = `Generation failed: ${error?.message || 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; } } @@ -1055,6 +1999,7 @@ function finishZoomVisual() { 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); @@ -1083,8 +2028,11 @@ function handleCanvasWheel(event) { 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); } @@ -1112,14 +2060,22 @@ function cloneForPatchPreview(value, seen = new Map()) { } function createPatchWorker() { - if (patchWorker || typeof Worker === "undefined") return patchWorker; + 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", () => { + 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 (_) { + } catch (error) { + state.diagnostics.lastWorkerFallbackReason = error?.message || "Patch worker creation failed"; patchWorker = null; } return patchWorker; @@ -1162,19 +2118,25 @@ async function generatePatchPreviewWorld(baseWorld, rect, options) { 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() { +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; } @@ -1183,16 +2145,21 @@ async function generateSelectedPatch() { 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) { - if (progressStageEl) progressStageEl.textContent = `Patch failed: ${result.reason || "invalid selection"}`; + 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. @@ -1201,13 +2168,13 @@ async function generateSelectedPatch() { window.setTimeout(() => redraw({ fastTerrain: false, allowWorldExpand: false }), 80); renderStats(displaySourceMap()); updatePatchControls(); - const human = result.humanGeography; - const humanText = human?.ok ? ` / human: ${human.modernCities || 0} cities, ${human.ports || 0} ports, ${human.villages || 0} villages, ${(human.roadConnectorsCreated || 0) + (human.railwayConnectorsCreated || 0)} connectors, admin ${human.adminCellsReassigned || 0}, invalid ports ${human.invalidPortsRemoved || 0}` : ""; - if (progressStageEl) progressStageEl.textContent = `Preview generated${job.worker ? " in worker" : ""}: ${result.label} / variant ${result.variant ?? variant} / mode ${result.patchGenerationMode || "legacy-full-pipeline"} / core ${formatRectSize(result.rects.coreRect)} / write ${formatRectSize(result.rects.writeRect)} / terrain ${result.updatedCells.toLocaleString()} cells / coast ${result.coastCellsChanged || 0} / natural ${result.naturalRegionsUpdated || 0}${humanText}. Click the map without dragging to apply.`; + 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) { - if (progressStageEl) progressStageEl.textContent = `Patch failed: ${error?.message || 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; } } @@ -1219,10 +2186,11 @@ async function generateAlternativePatch() { return; } setPatchVariant(readPatchVariant() + 1, { update: false }); - await generateSelectedPatch(); + await generateSelectedPatch("Patch alternative"); } function redraw(options = {}) { + const redrawStartedAt = performance.now(); const renderWorld = displayWorld(); if (!renderWorld) return; const viewSize = syncViewportSize(); @@ -1231,6 +2199,16 @@ function redraw(options = {}) { 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; @@ -1250,8 +2228,13 @@ function redraw(options = {}) { } 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, @@ -1260,13 +2243,23 @@ function redraw(options = {}) { 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(); @@ -1286,8 +2279,17 @@ function init() { generateSelectedPatch(); } }); - generatePatchButton?.addEventListener("click", 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)); @@ -1316,7 +2318,18 @@ function init() { 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(); } diff --git a/index.html b/index.html index 638d91b..2ba6ff7 100644 --- a/index.html +++ b/index.html @@ -8,115 +8,279 @@Terrain, municipalities, transport, viewport panning, patch terrain generation, and hover inspection in one generated map.
-