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

Prefecture Map Generator v17

-

Terrain, municipalities, transport, viewport panning, patch terrain generation, and hover inspection in one generated map.

-
-
+
+
+
+
+
+ +
+ + -
- - - - -
-
+
+ +
- + +
diff --git a/mapPatch.js b/mapPatch.js index 68baa85..3406180 100644 --- a/mapPatch.js +++ b/mapPatch.js @@ -1,5 +1,6 @@ -import { MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, lerp, smoothstep, valueNoise } from "./mapUtils.js"; +import { MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, lerp, pickEntities, smoothstep, valueNoise } from "./mapUtils.js"; import { generateMap } from "./mapPipeline.js"; +import { generateTerrainRect } from "./mapTerrain.js"; import { LANDUSE } from "./landuseCodes.js"; import { reconcileMunicipalMetadata, refreshPrefectureRegionsMetadata } from "./mapMunicipalCoherence.js"; @@ -222,7 +223,7 @@ function defaultForField(name, Constructor) { } function ensureWorldField(world, name, source) { - if (!source || !isCellField(source)) return null; + if (!source || !ArrayBuffer.isView(source)) return null; const Constructor = source.constructor; const expected = world.width * world.height; if (!world.fields[name] || world.fields[name].length !== expected) { @@ -547,12 +548,36 @@ function angleDistance(a, b) { return d; } +function pointToSegmentDistance2(px, py, ax, ay, bx, by) { + const dx = bx - ax; + const dy = by - ay; + const len2 = dx * dx + dy * dy; + if (len2 <= 1e-9) return Math.hypot(px - ax, py - ay); + const t = clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0, 1); + return Math.hypot(px - (ax + dx * t), py - (ay + dy * t)); +} + function segmentNear(a, b, tolerance = 0.60) { if (!a || !b) return false; const am = segmentMidpoint(a); const bm = segmentMidpoint(b); - if (Math.hypot(am.x - bm.x, am.y - bm.y) > tolerance) return false; - return angleDistance(segmentOrientation(a), segmentOrientation(b)) < 0.55; + const orientationClose = angleDistance(segmentOrientation(a), segmentOrientation(b)) < 0.68; + if (!orientationClose) return false; + const ax = a?.[0]?.[0] || 0, ay = a?.[0]?.[1] || 0; + const bx = a?.[1]?.[0] || 0, by = a?.[1]?.[1] || 0; + const cx = b?.[0]?.[0] || 0, cy = b?.[0]?.[1] || 0; + const dx = b?.[1]?.[0] || 0, dy = b?.[1]?.[1] || 0; + if (Math.hypot(am.x - bm.x, am.y - bm.y) <= tolerance) return true; + // Adjacent raster/vector borders often have slightly different segment lengths, + // so midpoint-only filtering misses them and both municipal/prefecture casings + // are drawn. Test endpoints against the other segment as well. + const best = Math.min( + pointToSegmentDistance2(ax, ay, cx, cy, dx, dy), + pointToSegmentDistance2(bx, by, cx, cy, dx, dy), + pointToSegmentDistance2(cx, cy, ax, ay, bx, by), + pointToSegmentDistance2(dx, dy, ax, ay, bx, by) + ); + return best <= tolerance; } function filterSupplementalSegments(primary, supplemental, tolerance = 0.60) { @@ -572,8 +597,8 @@ function filterSupplementalSegments(primary, supplemental, tolerance = 0.60) { const m = segmentMidpoint(seg); let near = false; const gx = cell(m.x), gy = cell(m.y); - for (let yy = gy - 1; yy <= gy + 1 && !near; yy++) { - for (let xx = gx - 1; xx <= gx + 1 && !near; xx++) { + for (let yy = gy - 2; yy <= gy + 2 && !near; yy++) { + for (let xx = gx - 2; xx <= gx + 2 && !near; xx++) { for (const other of grid.get(`${xx},${yy}`) || []) { if (segmentNear(seg, other, tolerance)) { near = true; break; } } @@ -591,6 +616,7 @@ function removeSegmentsNearSegments(segments, blockers, tolerance = 0.68) { function sourceWindowForRects(rects) { + if (rects?.candidateWindow) return rects.candidateWindow; const cx = (rects.coreRect.x0 + rects.coreRect.x1 - 1) / 2; const cy = (rects.coreRect.y0 + rects.coreRect.y1 - 1) / 2; return { @@ -598,9 +624,21 @@ function sourceWindowForRects(rects) { worldCenterY: cy, sourceCenterX: (MAP_W - 1) / 2, sourceCenterY: (MAP_H - 1) / 2, + originX: Math.round(cx - (MAP_W - 1) / 2), + originY: Math.round(cy - (MAP_H - 1) / 2), + width: MAP_W, + height: MAP_H, + variable: false, }; } +function sourceWindowIndex(window, sx, sy) { + const width = Math.max(1, Math.floor(window?.width || MAP_W)); + const height = Math.max(1, Math.floor(window?.height || MAP_H)); + if (sx < 0 || sy < 0 || sx >= width || sy >= height) return -1; + return sy * width + sx; +} + function sourceCoordForWorld(window, x, y) { return { x: Math.round(x - window.worldCenterX + window.sourceCenterX), @@ -608,6 +646,67 @@ function sourceCoordForWorld(window, x, y) { }; } +function candidateCellSize(candidate, window = null) { + const width = Math.max(1, Math.floor(candidate?.width || window?.width || MAP_W)); + const height = Math.max(1, Math.floor(candidate?.height || window?.height || MAP_H)); + return { width, height, size: width * height }; +} + +function isCandidateCellField(candidate, value, window = null) { + if (!ArrayBuffer.isView(value) || typeof value.length !== "number") return false; + const { size } = candidateCellSize(candidate, window); + return value.length === size || value.length === SIZE; +} + +function buildPatchCandidateWindow(rects, world, options = {}) { + const forceLegacy = options.variableCandidate === false || options.legacyCandidate === true; + const base = sourceWindowForRects({ ...rects, candidateWindow: null }); + const write = rects.writeRect; + if (forceLegacy || !write) return base; + const writeW = rectWidth(write); + const writeH = rectHeight(write); + const coreW = rectWidth(rects.coreRect); + const coreH = rectHeight(rects.coreRect); + const margin = Math.max(18, Math.min(48, Math.floor(Math.min(coreW, coreH) * 0.22), rects.writeMargin || 24)); + let bounds = expandRect(write, margin, world); + let width = rectWidth(bounds); + let height = rectHeight(bounds); + const minW = Math.min(MAP_W, Math.max(96, writeW + 16)); + const minH = Math.min(MAP_H, Math.max(96, writeH + 16)); + const cx = (write.x0 + write.x1 - 1) / 2; + const cy = (write.y0 + write.y1 - 1) / 2; + const growTo = (targetW, targetH) => { + const x0 = Math.floor(cx - targetW / 2); + const y0 = Math.floor(cy - targetH / 2); + return { x0, y0, x1: x0 + targetW, y1: y0 + targetH }; + }; + width = Math.max(width, minW); + height = Math.max(height, minH); + width = Math.min(MAP_W, Math.max(1, width)); + height = Math.min(MAP_H, Math.max(1, height)); + bounds = growTo(width, height); + // Keep the candidate anchored to the world when possible, but allow negative + // origins near the world edge. World-native terrain noise remains stable for + // negative coordinates and the copied writeRect still maps inside the window. + const originX = Math.floor(bounds.x0); + const originY = Math.floor(bounds.y0); + const areaRatio = (width * height) / SIZE; + const useVariable = areaRatio < 0.82; + if (!useVariable) return base; + return { + worldCenterX: originX + (width - 1) / 2, + worldCenterY: originY + (height - 1) / 2, + sourceCenterX: (width - 1) / 2, + sourceCenterY: (height - 1) / 2, + originX, + originY, + width, + height, + variable: true, + areaRatio: Math.round(areaRatio * 1000) / 1000, + }; +} + function getPatchSourceIndexCache(rects, window) { const writeRect = rects?.writeRect; if (!writeRect || !window) return null; @@ -631,7 +730,7 @@ function getPatchSourceIndexCache(rects, window) { for (let x = 0; x < width; x++) { const sx = Math.round(writeRect.x0 + x - window.worldCenterX + window.sourceCenterX); const sy = Math.round(writeRect.y0 + y - window.worldCenterY + window.sourceCenterY); - data[y * width + x] = sourceIndex(sx, sy); + data[y * width + x] = sourceWindowIndex(window, sx, sy); } } rects.patchSourceIndexCache = { @@ -658,7 +757,7 @@ function sourceIndexForWorld(rects, window, x, y) { && y < cache.y0 + cache.height ) return cache.data[(y - cache.y0) * cache.width + (x - cache.x0)]; const s = sourceCoordForWorld(window, x, y); - return sourceIndex(s.x, s.y); + return sourceWindowIndex(window, s.x, s.y); } @@ -858,7 +957,7 @@ export function buildAdminIdMapping({ candidateMap, world, writeRect, seamBand = const edge = distanceToRectEdge(x, y, writeRect); if (edge > band) continue; const s = sourceCoordForWorld(actualWindow, x, y); - const si = sourceIndex(s.x, s.y); + const si = sourceWindowIndex(actualWindow, s.x, s.y); const wi = worldIndex(world, x, y); if (si < 0 || wi < 0) continue; const cAdmin = candidateAdmin?.[si] ?? -1; @@ -1283,7 +1382,7 @@ function copyFullPipelineFields(world, candidate, rects, seed, sourceMap = null, let landUseCellsUpdated = 0; for (const [name, source] of Object.entries(candidate || {})) { - if (SKIP_CELL_FIELDS.has(name) || !isCellField(source)) continue; + if (SKIP_CELL_FIELDS.has(name) || !isCandidateCellField(candidate, source, window)) continue; const dest = ensureWorldField(world, name, source); if (!dest) continue; const isFloat = source.constructor === Float32Array || source.constructor === Float64Array; @@ -1492,20 +1591,28 @@ function featherTerrainSeam(world, rects, seed = 0) { return { terrainFeatherCells, terrainFeatherValues }; } -function smoothExtremeElevationSeams(world, rects, seed = 0) { - const elevation = world.fields?.elevation; - const sea = world.fields?.sea; - if (!elevation || !rects?.writeRect) return { elevationCliffCellsSmoothed: 0, elevationCliffMaxDelta: 0 }; +function smoothExtremeElevationSeams(world, rects, seed = 0, seaLevel = 0.30) { + const fields = world.fields || {}; + const elevation = fields.elevation; + const sea = fields.sea; + if (!elevation || !rects?.writeRect) return { elevationCliffCellsSmoothed: 0, elevationCliffMaxDelta: 0, elevationBridgePasses: 0 }; const rect = rects.writeRect; const sampleRect = expandRect(rect, 1, world); const sw = rectWidth(sampleRect); const sh = rectHeight(sampleRect); const offset = (x, y) => (y - sampleRect.y0) * sw + (x - sampleRect.x0); + const dirs4 = [[1,0],[-1,0],[0,1],[0,-1]]; + const dirs8 = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]; let cells = 0; let maxDelta = 0; - const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; + let elevationBridgePasses = 0; - for (let pass = 0; pass < 3; pass++) { + // This pass is intentionally stronger than the previous cliff-only filter. + // The seam can connect high mountains, low hills, plains, and sea in one patch; + // a local threshold leaves visible walls. We smooth the generated side across + // the whole inward feather band, with larger force near preserved cells and + // near coastlines. The outside/preserved side is never written. + for (let pass = 0; pass < 8; pass++) { const old = new Float32Array(sw * sh); for (let y = sampleRect.y0; y < sampleRect.y1; y++) { for (let x = sampleRect.x0; x < sampleRect.x1; x++) { @@ -1517,44 +1624,80 @@ function smoothExtremeElevationSeams(world, rects, seed = 0) { for (let y = rect.y0; y < rect.y1; y++) { for (let x = rect.x0; x < rect.x1; x++) { const i = worldIndex(world, x, y); - if (i < 0 || sea?.[i]) continue; + if (i < 0) continue; const a = patchAlpha(x, y, rects, seed); - if (a <= 0.005 || a >= 0.82) continue; + if (a <= 0.005 || a >= 0.94) continue; const here = old[offset(x, y)] || 0; + const hereSea = !!sea?.[i]; let sum = 0; - let count = 0; + let wsum = 0; + let lessGeneratedContacts = 0; + let seaContacts = 0; + let landContacts = 0; let strongest = 0; - for (const [dx, dy] of dirs) { + for (const [dx, dy] of dirs8) { const nx = x + dx, ny = y + dy; const ni = worldIndex(world, nx, ny); - if (ni < 0 || sea?.[ni]) continue; + if (ni < 0) continue; const na = patchAlpha(nx, ny, rects, seed); - // Only pull the generated seam toward less-generated or preserved - // neighbors. This smooths cliffs at the patch boundary without - // blurring the interior terrain that the candidate intentionally made. - if (na > a + 0.08 && insideRect(nx, ny, rect)) continue; + // We can pull the generated seam toward preserved or less-generated + // neighbors. Pulling toward deeper core cells would blur intentional + // candidate landforms, so keep that side weak. + const lessGenerated = na < a + 0.10 || !insideRect(nx, ny, rect); const nv = insideRect(nx, ny, sampleRect) ? old[offset(nx, ny)] : (elevation[ni] || 0); - const d = Math.abs(here - nv); - strongest = Math.max(strongest, d); - sum += nv; - count++; + const nSea = !!sea?.[ni]; + if (nSea) seaContacts++; else landContacts++; + if (lessGenerated) lessGeneratedContacts++; + const diag = Math.abs(dx) + Math.abs(dy) === 2; + const w = (lessGenerated ? 1.35 : 0.32) * (diag ? 0.72 : 1.0) * (nSea === hereSea ? 1.0 : 0.82); + sum += nv * w; + wsum += w; + strongest = Math.max(strongest, Math.abs(here - nv)); } - if (!count || strongest < 0.075) continue; - const mean = sum / count; - const diff = Math.abs(here - mean); - if (diff < 0.055) continue; - const severity = clamp((diff - 0.045) / 0.20); - const seamBias = clamp(1 - a * 0.72, 0.14, 0.88); - const weight = clamp(0.18 + severity * 0.48, 0.18, 0.62) * seamBias; - elevation[i] = clamp(lerp(elevation[i], mean, weight), 0, 1); - maxDelta = Math.max(maxDelta, diff); + if (!wsum || !lessGeneratedContacts) continue; + let target = sum / wsum; + const coastalMix = seaContacts > 0 && landContacts > 0; + if (coastalMix) { + // Avoid mountain/sea hard cuts. Land near a preserved sea seam becomes + // low coastal ground; sea near land becomes a shallow shelf. + const coastalLand = seaLevel + 0.030 + valueNoise(x, y, seed ^ 0x8ac3f51, 13) * 0.035; + const coastalSea = seaLevel - 0.035 - valueNoise(x, y, seed ^ 0x1c69b3e, 17) * 0.030; + target = hereSea ? lerp(target, coastalSea, 0.55) : lerp(target, coastalLand, 0.48); + } + const diff = Math.abs(here - target); + const severity = clamp((Math.max(diff, strongest * 0.7) - 0.018) / 0.22); + if (severity <= 0.01 && !coastalMix) continue; + const edgeForce = clamp(1.08 - a * 0.82, 0.18, 1.0); + const coastalForce = coastalMix ? 0.22 : 0; + const weight = clamp(0.16 + severity * 0.58 + coastalForce, 0.16, 0.78) * edgeForce; + elevation[i] = clamp(lerp(elevation[i], target, weight), 0, 1); + maxDelta = Math.max(maxDelta, diff, strongest); passCells++; } } - cells += passCells; if (!passCells) break; + elevationBridgePasses++; + cells += passCells; } - return { elevationCliffCellsSmoothed: cells, elevationCliffMaxDelta: Math.round(maxDelta * 10000) / 10000 }; + + // Reconcile visible lowland fields after the elevation bridge. These fields + // are continuous display/settlement aids; keeping the pre-bridge values is a + // common cause of highland colors ending abruptly at the patch edge. + for (let y = rect.y0; y < rect.y1; y++) { + for (let x = rect.x0; x < rect.x1; x++) { + const i = worldIndex(world, x, y); + if (i < 0) continue; + const a = patchAlpha(x, y, rects, seed); + if (a <= 0.005 || a >= 0.94) continue; + const e = elevation[i] || 0; + const lowland = clamp((seaLevel + 0.16 - e) * 2.2); + const oldPlain = fields.plain?.[i] || 0; + if (fields.coastalLowland) fields.coastalLowland[i] = clamp(lerp(fields.coastalLowland[i] || 0, lowland, 0.42 * (1 - a * 0.55))); + if (fields.plain) fields.plain[i] = clamp(lerp(oldPlain, lowland * (1 - (fields.slope?.[i] || 0) * 0.55), 0.38 * (1 - a * 0.45))); + if (fields.agriculture && fields.plain) fields.agriculture[i] = clamp(lerp(fields.agriculture[i] || 0, fields.plain[i], 0.24 * (1 - a * 0.45))); + } + } + return { elevationCliffCellsSmoothed: cells, elevationCliffMaxDelta: Math.round(maxDelta * 10000) / 10000, elevationBridgePasses }; } function averageNearbyLandElevation(world, x, y, maxRadius = 5) { @@ -1576,14 +1719,20 @@ function averageNearbyLandElevation(world, x, y, maxRadius = 5) { return null; } -function fillTinyResidualSeas(world, rects, seaLevel = 0.30, seed = 0) { +function fillTinyResidualSeas(world, rects, seaLevel = 0.30, seed = 0, options = {}) { const sea = world.fields?.sea; if (!sea || !rects?.writeRect) return { residualSeaPatchesFilled: 0, residualSeaCellsFilled: 0 }; const rect = rects.writeRect; - const visited = new Uint8Array(rectWidth(rect) * rectHeight(rect)); - const local = (x, y) => (y - rect.y0) * rectWidth(rect) + (x - rect.x0); - const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; - const maxComponent = Math.max(8, Math.min(72, Math.floor((rectWidth(rect) * rectHeight(rect)) * 0.0025))); + const rw = rectWidth(rect); + const rh = rectHeight(rect); + const visited = new Uint8Array(rw * rh); + const local = (x, y) => (y - rect.y0) * rw + (x - rect.x0); + const dirs4 = [[1,0],[-1,0],[0,1],[0,-1]]; + const dirs8 = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]; + const aggressive = !!options.aggressive; + const preservePockets = !!options.preservePockets; + const baseMax = Math.floor((rw * rh) * (aggressive ? 0.0075 : 0.0025)); + const maxComponent = Math.max(aggressive ? 36 : 8, Math.min(aggressive ? 260 : 72, baseMax)); let patches = 0; let cellsFilled = 0; @@ -1598,18 +1747,22 @@ function fillTinyResidualSeas(world, rects, seaLevel = 0.30, seed = 0) { const cells = []; visited[startLocal] = 1; let touchesOutside = false; - let landContacts = 0; - let waterContacts = 0; + let landContacts4 = 0; + let waterContacts4 = 0; + let landContacts8 = 0; + let waterContacts8 = 0; let maxAlpha = 0; + let minAlpha = 1; let seamTouches = 0; for (let qi = 0; qi < queue.length; qi++) { const [x, y] = queue[qi]; cells.push([x, y]); const a = patchAlpha(x, y, rects, seed); maxAlpha = Math.max(maxAlpha, a); - if (a < 0.34) seamTouches++; + minAlpha = Math.min(minAlpha, a); + if (a < (aggressive ? 0.52 : 0.34)) seamTouches++; if (x <= rect.x0 || y <= rect.y0 || x >= rect.x1 - 1 || y >= rect.y1 - 1) touchesOutside = true; - for (const [dx, dy] of dirs) { + for (const [dx, dy] of dirs4) { const nx = x + dx, ny = y + dy; if (!insideRect(nx, ny, rect)) { touchesOutside = true; continue; } const ni = worldIndex(world, nx, ny); @@ -1617,18 +1770,29 @@ function fillTinyResidualSeas(world, rects, seaLevel = 0.30, seed = 0) { if (sea[ni]) { const li = local(nx, ny); if (!visited[li]) { visited[li] = 1; queue.push([nx, ny]); } - waterContacts++; + waterContacts4++; } else { - landContacts++; + landContacts4++; } } - if (cells.length > maxComponent * 3) break; + for (const [dx, dy] of dirs8) { + const ni = worldIndex(world, x + dx, y + dy); + if (ni < 0) continue; + if (sea[ni]) waterContacts8++; else landContacts8++; + } + if (cells.length > maxComponent * 4) break; } - // Fill only tiny enclosed sea remnants near the inward feather band. This - // is meant to remove lasso hand-jitter pinholes, not real bays, lakes, or - // island channels created by the candidate terrain. - if (touchesOutside || cells.length > maxComponent || maxAlpha > 0.70 || seamTouches < Math.max(1, Math.floor(cells.length * 0.30)) || landContacts < waterContacts * 2.2) continue; + // Fill small enclosed water remnants at or near the inward seam. Stage 12 + // was intentionally conservative and therefore missed the common hand-jitter + // case: a tiny ungenerated sea pocket just inside the blue outline. This + // pass is still topological: components touching the patch edge, long bays, + // and real channels remain water. + const surrounded4 = landContacts4 >= waterContacts4 * (aggressive ? 1.12 : 2.2); + const surrounded8 = landContacts8 >= waterContacts8 * (aggressive ? 0.92 : 1.65); + const seamish = seamTouches >= Math.max(1, Math.floor(cells.length * (aggressive ? 0.12 : 0.30))) || minAlpha < 0.18; + const alphaOk = preservePockets ? maxAlpha <= 0.88 : maxAlpha <= (aggressive ? 0.84 : 0.70); + if (touchesOutside || cells.length > maxComponent || !alphaOk || !seamish || !(surrounded4 || surrounded8)) continue; for (const [x, y] of cells) { const i = worldIndex(world, x, y); @@ -1636,20 +1800,16 @@ function fillTinyResidualSeas(world, rects, seaLevel = 0.30, seed = 0) { sea[i] = 0; if (world.fields.ocean) world.fields.ocean[i] = 0; if (world.fields.lake) world.fields.lake[i] = 0; - const landElevation = averageNearbyLandElevation(world, x, y, 6); - if (world.fields.elevation) world.fields.elevation[i] = clamp(Math.max(seaLevel + 0.012, landElevation ?? (seaLevel + 0.018)), 0, 1); - if (world.fields.landuse) { - const lu = nearestLandFieldValue(world, x, y, "landuse", expandRect({ x0: x, y0: y, x1: x + 1, y1: y + 1 }, 8, world), { maxRadius: 8 }); - world.fields.landuse[i] = lu >= 0 ? lu : (LANDUSE.FOREST || 1); - } - for (const key of ["adminId", "municipalityId", "prefectureRegionId"]) { - if (!world.fields[key]) continue; - const value = nearestLandFieldValue(world, x, y, key, expandRect({ x0: x, y0: y, x1: x + 1, y1: y + 1 }, 12, world), { maxRadius: 12 }); - if (value >= 0) world.fields[key][i] = value; + if (world.fields.elevation) { + const nearby = averageNearbyLandElevation(world, x, y, aggressive ? 9 : 5); + const target = nearby == null ? seaLevel + 0.035 : Math.max(seaLevel + 0.025, nearby * 0.72 + (seaLevel + 0.05) * 0.28); + world.fields.elevation[i] = clamp(target, seaLevel + 0.012, seaLevel + 0.28); } + if (world.fields.plain) world.fields.plain[i] = Math.max(world.fields.plain[i] || 0, 0.38); + if (world.fields.coastalLowland) world.fields.coastalLowland[i] = Math.max(world.fields.coastalLowland[i] || 0, 0.46); + cellsFilled++; } patches++; - cellsFilled += cells.length; } } return { residualSeaPatchesFilled: patches, residualSeaCellsFilled: cellsFilled }; @@ -1943,6 +2103,8 @@ function repairPatchAdministrativeTopology(world, rects) { }; } + + function smoothWaterTopology(world, rect, seaLevel = 0.30, rects = null, seed = 0) { const sea = world.fields.sea; const ocean = world.fields.ocean; @@ -2091,6 +2253,8 @@ function repairWaterComponentTopology(world, rects, seaLevel = 0.30, seed = 0) { return { waterComponentsScanned, tinyWaterComponentsRemoved, tinyLandIslandsRemoved, waterTopologyCellsFlipped }; } + + function smoothPatchedWaterElevation(world, rects, seaLevel = 0.30, seed = 0) { const elevation = world.fields?.elevation; const sea = world.fields?.sea; @@ -2729,7 +2893,7 @@ function buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, s const queue = [li]; const cells = []; seen[li] = 1; - let sx = 0, sy = 0, score = 0, patchCells = 0, nearWriteCells = 0, exteriorCells = 0; + let sx = 0, sy = 0, score = 0, patchCells = 0, nearWriteCells = 0, exteriorCells = 0, trunkCells = 0; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (let q = 0; q < queue.length; q++) { const cur = queue[q]; @@ -2739,6 +2903,7 @@ function buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, s const x = graphRect.x0 + lx; const y = graphRect.y0 + ly; sx += x; sy += y; score += Math.max(1, weight[cur] || 1); + if ((weight[cur] || 0) >= 1.8) trunkCells++; minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); if (patchAffected(x, y, rects, seed, 0.24)) patchCells++; if (rectDistance(x, y, rects.writeRect) <= 8) nearWriteCells++; @@ -2771,6 +2936,7 @@ function buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, s patchCells, nearWriteCells, exteriorCells, + trunkCells, }); } comps.sort((a, b) => b.score - a.score); @@ -2865,6 +3031,25 @@ function bestTransportComponentPair(snapshot, compA, compB, maxDistance, mode = return best; } +function isStrongExternalTransportContext(comp, mode = "road") { + if (!comp) return false; + // Pure external components are only context. They should be regional trunks or + // meaningful nearby networks, not tiny roads in otherwise empty old terrain; + // otherwise graph repair grows many spokes toward places with no transport + // context. + if ((comp.patchCells || comp.nearWriteCells) > 0) return true; + const minSize = mode === "rail" ? 18 : 42; + if (comp.size < minSize) return false; + if ((comp.trunkCells || 0) >= (mode === "rail" ? 3 : 5)) return true; + if (comp.exteriorCells >= minSize * 1.8 && comp.score >= minSize * (mode === "rail" ? 1.4 : 1.25)) return true; + return false; +} + +function transportComponentTouchesVoid(comp, mode = "road") { + if (!comp) return true; + return (comp.patchCells || comp.nearWriteCells) > 0 && comp.size < (mode === "rail" ? 8 : 13) && (comp.trunkCells || 0) <= 0; +} + function buildTransportPairCandidates(snapshot, comps, maxDistance, mode, rejectedPairs) { const limit = mode === "rail" ? 12 : 18; const pool = comps.slice(0, Math.min(comps.length, limit)); @@ -2879,14 +3064,19 @@ function buildTransportPairCandidates(snapshot, comps, maxDistance, mode, reject const aDirty = (aComp.patchCells || aComp.nearWriteCells) > 0; const bDirty = (bComp.patchCells || bComp.nearWriteCells) > 0; if (!aDirty && !bDirty) continue; + if (transportComponentTouchesVoid(aComp, mode) && !bDirty) continue; + if (transportComponentTouchesVoid(bComp, mode) && !aDirty) continue; + if (!aDirty && !isStrongExternalTransportContext(aComp, mode)) continue; + if (!bDirty && !isStrongExternalTransportContext(bComp, mode)) continue; const pair = bestTransportComponentPair(snapshot, aComp, bComp, maxDistance, mode); if (!pair) continue; const sig = transportPairSignature(pair.a, pair.b, mode); const rev = transportPairSignature(pair.b, pair.a, mode); if (rejectedPairs.has(sig) || rejectedPairs.has(rev)) continue; const bothPatch = aComp.patchCells > 0 && bComp.patchCells > 0 ? 0.74 : 1.0; - const oneExternal = (aComp.exteriorCells > 0 || bComp.exteriorCells > 0) ? 1.03 : 1.0; - const score = pair.score * bothPatch * oneExternal; + const weakExternalPenalty = (!aDirty && (aComp.trunkCells || 0) <= 0) || (!bDirty && (bComp.trunkCells || 0) <= 0) ? 1.55 : 1.0; + const oneExternal = (aComp.exteriorCells > 0 || bComp.exteriorCells > 0) ? 1.12 : 1.0; + const score = pair.score * bothPatch * oneExternal * weakExternalPenalty; candidates.push({ compA: aComp, compB: bComp, pair, score }); } } @@ -3182,6 +3372,35 @@ function mergePointLayers(world, sourceMap, candidate, rects, window, seed, admi return { preservedExternalEntities, regeneratedInternalEntities, invalidPortsRemoved }; } +function seamTransportInfluence(world, x, y, mode = "road") { + const i = worldIndex(world, Math.round(x), Math.round(y)); + if (i < 0) return 0; + const road = world.fields.roadInfluence?.[i] || 0; + const rail = world.fields.railInfluence2?.[i] || 0; + return mode === "rail" ? rail : Math.max(road, rail * 0.35); +} + +function trimCandidateTransportVoidEnds(world, chunk, rects, seed, mode = "road") { + if (!Array.isArray(chunk) || chunk.length < 2 || (mode !== "road" && mode !== "rail")) return chunk || []; + const minInf = mode === "rail" ? 0.038 : 0.052; + const edgeAlpha = mode === "rail" ? 0.54 : 0.58; + const maxTrim = Math.min(chunk.length - 2, mode === "rail" ? 10 : 14); + const shouldTrim = ([x, y]) => { + const a = patchAlpha(x, y, rects, seed); + if (a <= 0.005 || a > edgeAlpha) return false; + if (rectDistance(x, y, rects.writeRect) > 16) return false; + return seamTransportInfluence(world, x, y, mode) < minInf; + }; + let start = 0; + let end = chunk.length; + let trimmed = 0; + while (end - start > 2 && trimmed < maxTrim && shouldTrim(chunk[start])) { start++; trimmed++; } + trimmed = 0; + while (end - start > 2 && trimmed < maxTrim && shouldTrim(chunk[end - 1])) { end--; trimmed++; } + const out = chunk.slice(start, end); + return out.length >= 2 ? out : []; +} + function mergePathLayers(world, sourceMap, candidate, rects, window, seed) { let roadAnchors = []; let railAnchors = []; @@ -3199,6 +3418,7 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) { const worldPath = transformCandidatePath(window, path); const chunks = splitWorldPathByPatch(worldPath, rects, seed, true, 0.40) .map((chunk) => chunk.filter(([x, y]) => mode === "river" || isLand(world, x, y))) + .map((chunk) => trimCandidateTransportVoidEnds(world, chunk, rects, seed, mode)) .filter((chunk) => chunk.length >= 2); for (const chunk of chunks) { if (chunk.some(([x, y]) => patchAlpha(x, y, rects, seed) >= 0.40)) { @@ -3233,8 +3453,8 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) { const graphRect = strictMask ? transportRect : transportRect; - const roadGraph = reconnectTransportGraph(world, sourceMap, rects, seed, "road", graphRect, { maxAdds: strictMask ? 12 : 9, maxDistance: strictMask ? 340 : 290, maxAttempts: strictMask ? 8 : 6 }); - const railGraph = reconnectTransportGraph(world, sourceMap, rects, seed, "rail", graphRect, { maxAdds: strictMask ? 6 : 4, maxDistance: strictMask ? 245 : 210, maxAttempts: strictMask ? 4 : 3 }); + const roadGraph = reconnectTransportGraph(world, sourceMap, rects, seed, "road", graphRect, { maxAdds: strictMask ? 7 : 6, maxDistance: strictMask ? 310 : 270, maxAttempts: strictMask ? 7 : 5 }); + const railGraph = reconnectTransportGraph(world, sourceMap, rects, seed, "rail", graphRect, { maxAdds: strictMask ? 4 : 3, maxDistance: strictMask ? 225 : 195, maxAttempts: strictMask ? 4 : 3 }); return { roadsClipped, railsClipped, @@ -3320,7 +3540,7 @@ function transformCandidateBoundarySegments(world, candidate, key, rects, window function mergeSegmentLayers(world, sourceMap, rects, seed = 0, candidate = null, window = null) { const segmentRect = rects.writeRect || rects.repairRect; - const strongAlpha = 0.72; + const strongAlpha = 0.18; // Keep outside prepared boundaries, but treat the patch interior as a single // replacement zone. Mixing candidate vector boundaries with raster-rebuilt @@ -3333,25 +3553,26 @@ function mergeSegmentLayers(world, sourceMap, rects, seed = 0, candidate = null, keptByKey.set(key, kept); } - const candidateAdmin = transformCandidateBoundarySegments(world, candidate, "adminBorders", rects, window, seed, 0.76); - const candidatePref = transformCandidateBoundarySegments(world, candidate, "regionalPrefectureBorders", rects, window, seed, 0.78); - const rebuiltAdminRaw = buildBoundarySegmentsFromField(world, "adminId", segmentRect, { rects, seed, minAlpha: 0.76 }); - const rebuiltPrefRaw = buildBoundarySegmentsFromField(world, "prefectureRegionId", segmentRect, { rects, seed, minAlpha: 0.82 }); + const candidateAdmin = transformCandidateBoundarySegments(world, candidate, "adminBorders", rects, window, seed, 0.68); + const candidatePref = transformCandidateBoundarySegments(world, candidate, "regionalPrefectureBorders", rects, window, seed, 0.70); + const rebuiltAdminRaw = buildBoundarySegmentsFromField(world, "adminId", segmentRect, { rects, seed, minAlpha: 0.88 }); + const rebuiltPrefRaw = buildBoundarySegmentsFromField(world, "prefectureRegionId", segmentRect, { rects, seed, minAlpha: 0.90 }); // Candidate boundaries come from the full generator and usually have the same // visual semantics as the initial map. Raster-rebuilt segments are fallback // only, and are rejected when they are near an existing candidate segment. - const rebuiltPref = filterSupplementalSegments(candidatePref, rebuiltPrefRaw, 0.72); + const rebuiltPref = filterSupplementalSegments(candidatePref, rebuiltPrefRaw, 1.18); const patchPref = dedupeSegments([...candidatePref, ...rebuiltPref]); - const rebuiltAdmin = filterSupplementalSegments(candidateAdmin, rebuiltAdminRaw, 0.64); + const rebuiltAdmin = filterSupplementalSegments(candidateAdmin, rebuiltAdminRaw, 1.05); let patchAdmin = dedupeSegments([...candidateAdmin, ...rebuiltAdmin]); // A prefecture border is also a municipal border in the raw rasters. Do not // draw both visual layers on the same line; the thicker prefecture styling wins. - patchAdmin = removeSegmentsNearSegments(patchAdmin, patchPref, 0.76); + patchAdmin = removeSegmentsNearSegments(patchAdmin, patchPref, 1.42); - sourceMap.adminBorders = dedupeSegments([...(keptByKey.get("adminBorders") || []), ...patchAdmin]); - sourceMap.regionalPrefectureBorders = dedupeSegments([...(keptByKey.get("regionalPrefectureBorders") || []), ...patchPref]); + const mergedPrefectureBorders = dedupeSegments([...(keptByKey.get("regionalPrefectureBorders") || []), ...patchPref]); + sourceMap.adminBorders = removeSegmentsNearSegments(dedupeSegments([...(keptByKey.get("adminBorders") || []), ...patchAdmin]), mergedPrefectureBorders, 1.35); + sourceMap.regionalPrefectureBorders = mergedPrefectureBorders; sourceMap.prefectureBorder = dedupeSegments([...(keptByKey.get("prefectureBorder") || []), ...(sourceMap.prefectureBorder || []).filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, strongAlpha))]); const debug = sourceMap.adminDebug || {}; @@ -3527,8 +3748,8 @@ function rectKey(rect) { return rect ? `${rect.x0},${rect.y0},${rect.x1},${rect.y1}` : "-"; } -function patchCandidateCacheKey({ seed, terrainType, variant, candidateOriginX, candidateOriginY, contextRect, serial = 0 }) { - return [serial, seed >>> 0, terrainType || "auto", variant >>> 0, candidateOriginX | 0, candidateOriginY | 0, rectKey(contextRect)].join("|"); +function patchCandidateCacheKey({ seed, terrainType, variant, candidateOriginX, candidateOriginY, candidateWidth = MAP_W, candidateHeight = MAP_H, mode = "legacy-full-pipeline", contextRect, serial = 0 }) { + return [serial, mode, seed >>> 0, terrainType || "auto", variant >>> 0, candidateOriginX | 0, candidateOriginY | 0, candidateWidth | 0, candidateHeight | 0, rectKey(contextRect)].join("|"); } function getPatchCandidateCache(world) { @@ -3556,6 +3777,361 @@ function getOrGeneratePatchCandidate(world, key, create) { return { candidate, cacheHit: false, cacheSize: getPatchCandidateCache(world).size }; } +function localCandidateIndex(candidate, x, y) { + const width = Math.max(1, Math.floor(candidate?.width || MAP_W)); + const height = Math.max(1, Math.floor(candidate?.height || MAP_H)); + if (x < 0 || y < 0 || x >= width || y >= height) return -1; + return y * width + x; +} + +function localCandidateInside(candidate, x, y) { + return localCandidateIndex(candidate, x, y) >= 0; +} + +function terrainCellScore(candidate, x, y, seed = 0) { + const i = localCandidateIndex(candidate, x, y); + if (i < 0 || candidate.sea?.[i]) return -Infinity; + const plain = candidate.plain?.[i] || 0; + const agri = candidate.agriculture?.[i] || 0; + const coast = candidate.coastalLowland?.[i] || 0; + const slope = candidate.slope?.[i] || 0; + const elev = candidate.elevation?.[i] || 0; + const river = candidate.river?.[i] || 0; + const n = valueNoise((candidate.originX || 0) + x, (candidate.originY || 0) + y, seed ^ 0x97d4a7c1, 13) - 0.5; + return plain * 1.5 + agri * 1.2 + coast * 0.42 + river * 0.34 - slope * 1.15 - Math.max(0, elev - 0.58) * 0.8 + n * 0.12; +} + +function chooseCandidateCenters(candidate, seed, count, minDistance, stride = 2) { + const candidates = []; + const width = candidate.width || MAP_W; + const height = candidate.height || MAP_H; + for (let y = 2; y < height - 2; y += stride) { + for (let x = 2; x < width - 2; x += stride) { + const score = terrainCellScore(candidate, x, y, seed); + if (score > 0.12) candidates.push({ x, y, score }); + } + } + let picked = pickEntities(candidates, { max: count, minDistance, threshold: 0.12, seed: seed ^ 0x5f356495, jitter: 0.08 }); + if (!picked.length) { + for (let y = 1; y < height - 1; y++) { + for (let x = 1; x < width - 1; x++) { + const i = localCandidateIndex(candidate, x, y); + if (i >= 0 && !candidate.sea?.[i]) picked.push({ x, y, score: 0.1 }); + if (picked.length >= Math.max(1, Math.min(4, count))) break; + } + if (picked.length) break; + } + } + return picked; +} + +function nearestCandidateSeedIndex(seeds, x, y, candidate, seed = 0, spacingPenalty = 1) { + let best = -1; + let bestScore = Infinity; + for (let k = 0; k < seeds.length; k++) { + const s = seeds[k]; + const dx = x - s.x; + const dy = y - s.y; + const noise = valueNoise((candidate.originX || 0) + x + k * 11, (candidate.originY || 0) + y - k * 7, seed ^ 0xe2c1b3f5, 24) - 0.5; + const d = dx * dx + dy * dy + noise * 42 * spacingPenalty - (s.score || 0) * 8; + if (d < bestScore) { bestScore = d; best = k; } + } + return best; +} + +function deriveVariableCandidateAdmin(candidate, seed = 0) { + const width = candidate.width || MAP_W; + const height = candidate.height || MAP_H; + const size = width * height; + let landCount = 0; + for (let i = 0; i < size; i++) if (!candidate.sea?.[i]) landCount++; + const adminCount = clamp(Math.round(landCount / 260), 4, 72); + const prefCount = clamp(Math.round(landCount / 2100), 1, 8); + const adminSeeds = chooseCandidateCenters(candidate, seed ^ 0x209f3d91, adminCount, Math.max(7, Math.round(Math.sqrt(size / Math.max(1, adminCount)) * 0.75)), 2); + const prefSeeds = pickEntities( + adminSeeds.map((p) => ({ ...p, score: (p.score || 0) + valueNoise((candidate.originX || 0) + p.x, (candidate.originY || 0) + p.y, seed ^ 0x06a09e66, 34) * 0.18 })), + { max: prefCount, minDistance: Math.max(20, Math.round(Math.sqrt(size / Math.max(1, prefCount)) * 0.72)), threshold: -1, seed: seed ^ 0xb0f7c3d2, jitter: 0.04 } + ); + if (!prefSeeds.length && adminSeeds.length) prefSeeds.push(adminSeeds[0]); + + const adminId = new Int32Array(size); adminId.fill(-1); + const municipalityId = new Int32Array(size); municipalityId.fill(-1); + const prefectureRegionId = new Int32Array(size); prefectureRegionId.fill(-1); + const populationDensity = new Float32Array(size); + const landuse = new Uint8Array(size); + const settlementScore = new Float32Array(size); + const municipalityToPrefectureId = {}; + const adminBest = new Map(); + const prefBest = new Map(); + + const adminToPref = new Map(); + for (let a = 0; a < adminSeeds.length; a++) { + const p = adminSeeds[a]; + const pref = nearestCandidateSeedIndex(prefSeeds, p.x, p.y, candidate, seed ^ 0xc314d1ba, 0.5); + adminToPref.set(a, Math.max(0, pref)); + municipalityToPrefectureId[a] = Math.max(0, pref); + } + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = y * width + x; + if (candidate.sea?.[i]) { + landuse[i] = LANDUSE.RURAL; + continue; + } + const a = nearestCandidateSeedIndex(adminSeeds, x, y, candidate, seed ^ 0x86ab1c2d, 1.0); + const pref = adminToPref.get(a) ?? 0; + adminId[i] = a; + municipalityId[i] = a; + prefectureRegionId[i] = pref; + const score = terrainCellScore(candidate, x, y, seed); + const urban = clamp((score - 0.20) * 0.95 + valueNoise((candidate.originX || 0) + x, (candidate.originY || 0) + y, seed ^ 0x4d765f31, 18) * 0.18); + settlementScore[i] = clamp(score * 0.55 + urban * 0.45); + populationDensity[i] = clamp(urban * 0.62 + (candidate.plain?.[i] || 0) * 0.16); + landuse[i] = candidate.slope?.[i] > 0.34 || candidate.elevation?.[i] > 0.58 + ? LANDUSE.FOREST + : urban > 0.72 ? LANDUSE.OLD_URBAN + : urban > 0.48 ? LANDUSE.SUBURB + : (candidate.agriculture?.[i] || 0) > 0.38 ? LANDUSE.FARMLAND : LANDUSE.RURAL; + const curAdmin = adminBest.get(a); + if (!curAdmin || score > curAdmin.score) adminBest.set(a, { x, y, score, adminId: a, municipalityId: a, prefectureRegionId: pref }); + const curPref = prefBest.get(pref); + if (!curPref || score > curPref.score) prefBest.set(pref, { x, y, score, id: pref, prefectureRegionId: pref }); + } + } + + const adminCenters = [...adminBest.values()].map((p, idx) => ({ + x: p.x, y: p.y, score: p.score, adminId: p.adminId, municipalityId: p.municipalityId, + adminNumericId: p.adminId, prefectureRegionId: p.prefectureRegionId, + population: Math.round(900 + clamp(p.score) * 19000 + hash2(p.x, p.y, seed ^ 0x1523) * 6000), + kind: "Municipal Center", + patchGenerated: true, + })); + const prefectureRegions = [...prefBest.values()].map((p) => ({ + x: p.x, y: p.y, id: p.id, prefectureRegionId: p.prefectureRegionId, + population: Math.round(60000 + clamp(p.score) * 260000), + kind: "Prefecture Region", + patchGenerated: true, + })); + const urbanCandidates = adminCenters + .map((p) => ({ ...p, score: p.score + (p.population || 0) / 50000 })) + .sort((a, b) => b.score - a.score); + const topLimit = Math.max(1, Math.min(8, Math.round(landCount / 1400))); + const modernCities = urbanCandidates.slice(0, topLimit).map((p, idx) => ({ + ...p, + kind: idx === 0 && landCount > 7000 ? "Regional Center" : "City", + population: Math.min(idx === 0 ? 110000 : 68000, Math.round((p.population || 12000) * (idx === 0 ? 4.2 : 2.5))), + })); + const markets = urbanCandidates.slice(Math.max(1, topLimit - 1), topLimit + Math.max(2, Math.round(landCount / 1800))).map((p) => ({ ...p, kind: "Market Town", population: Math.round((p.population || 8000) * 0.65) })); + const villages = chooseCandidateCenters(candidate, seed ^ 0x51b1, Math.max(8, Math.min(70, Math.round(landCount / 180))), 7, 3) + .map((p) => ({ x: p.x, y: p.y, score: p.score, kind: "Village", population: Math.round(120 + clamp(p.score) * 1500), patchGenerated: true })); + const ports = chooseCandidatePorts(candidate, seed, Math.max(2, Math.min(18, Math.round(landCount / 900)))); + return { adminId, municipalityId, prefectureRegionId, populationDensity, landuse, settlementScore, adminCenters, prefectureRegions, modernCities, markets, villages, ports, municipalityToPrefectureId }; +} + +function candidateSeaNeighbors(candidate, x, y, radius = 1) { + let n = 0; + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + if (!dx && !dy) continue; + const i = localCandidateIndex(candidate, x + dx, y + dy); + if (i >= 0 && candidate.sea?.[i]) n++; + } + } + return n; +} + +function chooseCandidatePorts(candidate, seed, maxPorts = 8) { + const width = candidate.width || MAP_W; + const height = candidate.height || MAP_H; + const out = []; + for (let y = 2; y < height - 2; y += 2) { + for (let x = 2; x < width - 2; x += 2) { + const i = localCandidateIndex(candidate, x, y); + if (i < 0 || candidate.sea?.[i]) continue; + const seas = candidateSeaNeighbors(candidate, x, y, 2); + if (seas < 3) continue; + const score = seas * 0.12 + (candidate.coastalLowland?.[i] || 0) * 0.7 + (candidate.plain?.[i] || 0) * 0.25 - (candidate.slope?.[i] || 0) * 0.8; + if (score > 0.42) out.push({ x, y, score, kind: "Port", population: 0, patchGenerated: true }); + } + } + return pickEntities(out, { max: maxPorts, minDistance: 12, threshold: 0.42, seed: seed ^ 0x7303, jitter: 0.03 }); +} + +function candidateLineCrossesOpenWater(candidate, a, b, maxWater = 2) { + const dx = b.x - a.x, dy = b.y - a.y; + const steps = Math.max(1, Math.ceil(Math.hypot(dx, dy))); + let water = 0; + for (let k = 0; k <= steps; k++) { + const t = k / steps; + const x = Math.round(a.x + dx * t); + const y = Math.round(a.y + dy * t); + const i = localCandidateIndex(candidate, x, y); + if (i < 0 || candidate.sea?.[i]) water++; + if (water > maxWater) return true; + } + return false; +} + +function nearestCandidateLand(candidate, x, y, radius = 5) { + if (localCandidateInside(candidate, x, y) && !candidate.sea?.[localCandidateIndex(candidate, x, y)]) return { x, y }; + for (let r = 1; r <= radius; r++) { + let best = null, bestScore = Infinity; + for (let yy = y - r; yy <= y + r; yy++) { + for (let xx = x - r; xx <= x + r; xx++) { + if (Math.abs(xx - x) !== r && Math.abs(yy - y) !== r) continue; + const i = localCandidateIndex(candidate, xx, yy); + if (i < 0 || candidate.sea?.[i]) continue; + const score = Math.hypot(xx - x, yy - y) + (candidate.slope?.[i] || 0) * 2; + if (score < bestScore) { bestScore = score; best = { x: xx, y: yy }; } + } + } + if (best) return best; + } + return null; +} + +function buildCandidateRoute(candidate, a, b, seed = 0) { + if (!a || !b) return []; + if (candidateLineCrossesOpenWater(candidate, a, b, 2)) return []; + const dx = b.x - a.x, dy = b.y - a.y; + const dist = Math.hypot(dx, dy); + const steps = Math.max(2, Math.ceil(dist / 3)); + const out = []; + let prev = null; + for (let k = 0; k <= steps; k++) { + const t = k / steps; + const curve = Math.sin(t * Math.PI) * (valueNoise((candidate.originX || 0) + a.x + b.x, (candidate.originY || 0) + a.y + b.y, seed ^ 0x8ac1, 19) - 0.5) * Math.min(9, dist * 0.10); + const nx = -dy / Math.max(1, dist); + const ny = dx / Math.max(1, dist); + let x = Math.round(a.x + dx * t + nx * curve); + let y = Math.round(a.y + dy * t + ny * curve); + const land = nearestCandidateLand(candidate, x, y, 4); + if (!land) return []; + x = land.x; y = land.y; + if (!prev || Math.hypot(prev[0] - x, prev[1] - y) >= 1.5) { + out.push([x, y]); + prev = [x, y]; + } + } + return out.length >= 2 ? out : []; +} + +function deriveVariableCandidateTransport(candidate, seed = 0) { + const centers = [...(candidate.modernCities || []), ...(candidate.markets || [])] + .filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y)) + .sort((a, b) => (b.population || 0) - (a.population || 0)); + const nationalRoads = []; + const minorRoads = []; + const railways = []; + const stations = []; + const usedPairs = new Set(); + const connect = (arr, a, b, salt) => { + const key = a && b ? `${Math.min(a.x, b.x)},${Math.min(a.y, b.y)}:${Math.max(a.x, b.x)},${Math.max(a.y, b.y)}` : ""; + if (!key || usedPairs.has(key)) return false; + usedPairs.add(key); + const path = buildCandidateRoute(candidate, a, b, seed ^ salt); + if (path.length >= 2) { arr.push(path); return true; } + return false; + }; + for (let i = 0; i < Math.min(centers.length - 1, 10); i++) connect(nationalRoads, centers[i], centers[i + 1], 0x1100 + i); + for (let i = 0; i < Math.min(centers.length, 12); i++) { + const a = centers[i]; + const nearest = centers + .filter((p) => p !== a) + .map((p) => ({ p, d: Math.hypot(p.x - a.x, p.y - a.y) })) + .filter((r) => r.d >= 9 && r.d < 62) + .sort((x, y) => x.d - y.d) + .slice(0, 2); + for (const r of nearest) connect(minorRoads, a, r.p, 0x2200 + i); + } + if (centers.length >= 3) { + for (let i = 0; i < Math.min(centers.length - 1, 4); i++) { + if (Math.hypot(centers[i].x - centers[i + 1].x, centers[i].y - centers[i + 1].y) < 72 && connect(railways, centers[i], centers[i + 1], 0x3300 + i)) { + stations.push({ x: centers[i].x, y: centers[i].y, kind: "Station", patchGenerated: true }); + stations.push({ x: centers[i + 1].x, y: centers[i + 1].y, kind: "Station", patchGenerated: true }); + } + } + } + return { nationalRoads, minorRoads, railways, branchRailways: [], stations }; +} + +function extractVariableBoundarySegments(candidate, fieldName, options = {}) { + const field = candidate?.[fieldName]; + const sea = candidate?.sea; + if (!field) return []; + const width = candidate.width || MAP_W; + const height = candidate.height || MAP_H; + const segments = []; + const minBothLand = options.minBothLand !== false; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = y * width + x; + if (field[i] < 0 || (minBothLand && sea?.[i])) continue; + if (x + 1 < width) { + const ni = y * width + x + 1; + if (field[ni] >= 0 && field[ni] !== field[i] && (!minBothLand || !sea?.[ni])) segments.push([[x + 1, y], [x + 1, y + 1]]); + } + if (y + 1 < height) { + const ni = (y + 1) * width + x; + if (field[ni] >= 0 && field[ni] !== field[i] && (!minBothLand || !sea?.[ni])) segments.push([[x, y + 1], [x + 1, y + 1]]); + } + } + } + return segments; +} + +function generateVariablePatchCandidate(seed, options = {}) { + const window = options.window; + const width = Math.max(32, Math.floor(window?.width || MAP_W)); + const height = Math.max(32, Math.floor(window?.height || MAP_H)); + const originX = Math.floor(window?.originX ?? Math.round((window?.worldCenterX || 0) - (width - 1) / 2)); + const originY = Math.floor(window?.originY ?? Math.round((window?.worldCenterY || 0) - (height - 1) / 2)); + const terrain = generateTerrainRect({ + ...options, + seed, + originX, + originY, + width, + height, + variant: options.variant || 0, + worldNative: true, + patchMode: true, + name: "variable-patch-candidate", + }); + const candidate = { ...terrain, originX, originY, width, height, size: width * height }; + const admin = deriveVariableCandidateAdmin(candidate, seed ^ 0x43a1b991); + Object.assign(candidate, admin); + const transport = deriveVariableCandidateTransport(candidate, seed ^ 0x7408d3a9); + Object.assign(candidate, transport); + candidate.prefectureMask = new Uint8Array(candidate.size); + candidate.landMask = new Uint8Array(candidate.size); + for (let i = 0; i < candidate.size; i++) { + candidate.landMask[i] = candidate.sea?.[i] ? 0 : 1; + candidate.prefectureMask[i] = candidate.landMask[i]; + } + candidate.adminBorders = extractVariableBoundarySegments(candidate, "adminId", { minBothLand: true }); + candidate.regionalPrefectureBorders = extractVariableBoundarySegments(candidate, "prefectureRegionId", { minBothLand: true }); + candidate.prefectureBorder = []; + candidate.mainRivers ||= []; + candidate.tributaryRivers ||= []; + candidate.smallStreams ||= []; + candidate.riverPaths ||= [...(candidate.mainRivers || []), ...(candidate.tributaryRivers || []), ...(candidate.smallStreams || [])]; + candidate.generationTimings = [ + { key: "terrainRect", label: "Variable rect terrain", ms: 0 }, + { key: "localAdmin", label: "Variable local admin and settlements", ms: 0 }, + { key: "localTransport", label: "Variable local transport", ms: 0 }, + ]; + candidate.generationContext = { ...(candidate.generationContext || {}), originX, originY, width, height, variant: options.variant || 0, worldNative: true, variableCandidate: true }; + candidate.terrainDebug = { ...(candidate.terrainDebug || {}), variablePatchCandidate: true, width, height, originX, originY }; + return candidate; +} + +function generatePatchCandidate(seed, options = {}) { + if (options?.window?.variable) return generateVariablePatchCandidate(seed, options); + return generateMap(seed, options); +} + function clonePointForPatch(point) { return point ? { ...point } : point; } @@ -3641,36 +4217,45 @@ export function generatePatch(world, userRectInput, options = {}) { const seed = Number.isFinite(options.seed) ? options.seed >>> 0 : ((world?.seed || 0) + 1013904223) >>> 0; const strictFieldSnapshot = captureStrictSelectionFieldSnapshot(world, rects, seed); const variant = Number.isFinite(options.variant) ? Math.max(0, Math.floor(options.variant)) >>> 0 : 0; - const candidateWindow = sourceWindowForRects(rects); - const candidateOriginX = Math.round(candidateWindow.worldCenterX - candidateWindow.sourceCenterX); - const candidateOriginY = Math.round(candidateWindow.worldCenterY - candidateWindow.sourceCenterY); + const candidateWindow = buildPatchCandidateWindow(rects, world, options); + rects.candidateWindow = candidateWindow; + const candidateOriginX = Math.round(candidateWindow.originX ?? (candidateWindow.worldCenterX - candidateWindow.sourceCenterX)); + const candidateOriginY = Math.round(candidateWindow.originY ?? (candidateWindow.worldCenterY - candidateWindow.sourceCenterY)); + const candidateWidth = Math.max(1, Math.floor(candidateWindow.width || MAP_W)); + const candidateHeight = Math.max(1, Math.floor(candidateWindow.height || MAP_H)); const patchTimer = createPatchTimer(); - const patchGenerationMode = "legacy-full-pipeline"; + const patchGenerationMode = candidateWindow.variable ? "variable-rect-candidate" : "legacy-full-pipeline"; const cacheKey = patchCandidateCacheKey({ seed, terrainType, variant, candidateOriginX, candidateOriginY, + candidateWidth, + candidateHeight, + mode: patchGenerationMode, contextRect: rects.contextRect, serial: world.patchGenerationSerial || 0, }); - const { candidate, cacheHit, cacheSize } = getOrGeneratePatchCandidate(world, cacheKey, () => generateMap(seed, { + const { candidate, cacheHit, cacheSize } = getOrGeneratePatchCandidate(world, cacheKey, () => generatePatchCandidate(seed, { terrainType, - legacyTerrain: true, + legacyTerrain: !candidateWindow.variable, worldNative: true, variant, originX: candidateOriginX, originY: candidateOriginY, - width: MAP_W, - height: MAP_H, + width: candidateWidth, + height: candidateHeight, + window: candidateWindow, contextRect: rects.contextRect, boundaryWorld: world, patchMode: true, topCenterSuppression: 0.72, onProgress: () => {}, })); - patchTimer.mark("candidate", cacheHit ? "Full candidate generation (cached)" : "Full candidate generation"); + patchTimer.mark("candidate", cacheHit + ? (candidateWindow.variable ? "Variable candidate generation (cached)" : "Full candidate generation (cached)") + : (candidateWindow.variable ? "Variable candidate generation" : "Full candidate generation")); getPatchAlphaCache(rects, seed); getPatchSourceIndexCache(rects, candidateWindow); const sourceMap = world.sourceMap || (world.sourceMap = {}); @@ -3681,10 +4266,10 @@ export function generatePatch(world, userRectInput, options = {}) { const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed, sourceMap, seaLevel); patchTimer.mark("fields", "Field copy and alpha blend"); const terrainSeamDebug = featherTerrainSeam(world, rects, seed); - const elevationCliffDebug = smoothExtremeElevationSeams(world, rects, seed); + const elevationCliffDebug = smoothExtremeElevationSeams(world, rects, seed, seaLevel); const waterDebug = smoothWaterTopology(world, rects.writeRect, seaLevel, rects, seed); const waterComponentDebug = repairWaterComponentTopology(world, rects, seaLevel, seed); - const residualSeaDebug = fillTinyResidualSeas(world, rects, seaLevel, seed); + const residualSeaDebug = fillTinyResidualSeas(world, rects, seaLevel, seed, { aggressive: true }); const waterElevationDebug = smoothPatchedWaterElevation(world, rects, seaLevel, seed); const maskDebug = repairDisplayMasks(world, rects, seed); recomputeSlopeAndWaterDependentFields(world, rects.repairRect, seaLevel); @@ -3729,6 +4314,11 @@ export function generatePatch(world, userRectInput, options = {}) { }); sourceMap.prefectureRegions = prefectureCoherence.prefectureRegions; const strictMaskDebugPostCoherence = restoreOutsideStrictSelectionFields(world, rects, strictFieldSnapshot, seed); + const residualSeaStrictDebug = fillTinyResidualSeas(world, rects, seaLevel, seed, { aggressive: true, preservePockets: true }); + if ((residualSeaStrictDebug.residualSeaCellsFilled || 0) > 0) { + repairDisplayMasks(world, rects, seed); + recomputeSlopeAndWaterDependentFields(world, rects.repairRect, seaLevel); + } const strictMetadataDebug = restoreOutsideStrictMetadata(world, sourceMap, rects, strictMetadataSnapshot, seed); sourceMap.adminDebug = { ...(sourceMap.adminDebug || {}), @@ -3781,6 +4371,8 @@ export function generatePatch(world, userRectInput, options = {}) { ...elevationCliffDebug, ...waterComponentDebug, ...residualSeaDebug, + residualSeaPostRestorePatchesFilled: residualSeaStrictDebug.residualSeaPatchesFilled, + residualSeaPostRestoreCellsFilled: residualSeaStrictDebug.residualSeaCellsFilled, ...waterElevationDebug, landUseCellsUpdated: fieldDebug.landUseCellsUpdated + landDebug.landUseCellsUpdated, displayMaskUpdated: maskDebug.displayMaskUpdated || 0, @@ -3811,11 +4403,14 @@ export function generatePatch(world, userRectInput, options = {}) { variant, candidateOriginX, candidateOriginY, + candidateWidth, + candidateHeight, + candidateAreaRatio: candidateWindow.areaRatio ?? 1, patchGenerationMode, patchTimings, updatedCells: fieldDebug.updatedCells, terrainCellsFullyReplaced: fieldDebug.terrainCellsFullyReplaced, - coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged + waterComponentDebug.waterTopologyCellsFlipped + residualSeaDebug.residualSeaCellsFilled, + coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged + waterComponentDebug.waterTopologyCellsFlipped + residualSeaDebug.residualSeaCellsFilled + residualSeaStrictDebug.residualSeaCellsFilled, naturalRegionsUpdated: fieldDebug.naturalRegionsUpdated, naturalRegionFragmentsMerged: 0, adminIdMapping: fieldDebug.adminIdMappingDebug, @@ -3846,6 +4441,9 @@ export function generatePatch(world, userRectInput, options = {}) { variant, candidateOriginX, candidateOriginY, + candidateWidth, + candidateHeight, + candidateAreaRatio: candidateWindow.areaRatio ?? 1, patchGenerationMode, patchTimings, updatedCells: record.updatedCells, diff --git a/renderer.js b/renderer.js index 8704b6e..8433891 100644 --- a/renderer.js +++ b/renderer.js @@ -645,6 +645,71 @@ function getDisplayBorderSegments(map, fieldName, precomputedSegments, mode) { return Array.isArray(precomputedSegments) ? precomputedSegments : []; } +function borderSegmentMidpoint(seg) { + return { x: ((seg?.[0]?.[0] || 0) + (seg?.[1]?.[0] || 0)) * 0.5, y: ((seg?.[0]?.[1] || 0) + (seg?.[1]?.[1] || 0)) * 0.5 }; +} +function borderSegmentOrientation(seg) { + return Math.atan2((seg?.[1]?.[1] || 0) - (seg?.[0]?.[1] || 0), (seg?.[1]?.[0] || 0) - (seg?.[0]?.[0] || 0)); +} +function borderAngleDistance(a, b) { + let d = Math.abs(a - b) % Math.PI; + if (d > Math.PI / 2) d = Math.PI - d; + return d; +} +function borderPointSegmentDistance(px, py, ax, ay, bx, by) { + const dx = bx - ax, dy = by - ay; + const len2 = dx * dx + dy * dy; + if (len2 <= 1e-9) return Math.hypot(px - ax, py - ay); + const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2)); + return Math.hypot(px - (ax + dx * t), py - (ay + dy * t)); +} +function borderSegmentsNear(a, b, tolerance = 1.35) { + if (!a || !b) return false; + if (borderAngleDistance(borderSegmentOrientation(a), borderSegmentOrientation(b)) > 0.72) return false; + const am = borderSegmentMidpoint(a); + const bm = borderSegmentMidpoint(b); + if (Math.hypot(am.x - bm.x, am.y - bm.y) <= tolerance) return true; + const ax = a?.[0]?.[0] || 0, ay = a?.[0]?.[1] || 0; + const bx = a?.[1]?.[0] || 0, by = a?.[1]?.[1] || 0; + const cx = b?.[0]?.[0] || 0, cy = b?.[0]?.[1] || 0; + const dx = b?.[1]?.[0] || 0, dy = b?.[1]?.[1] || 0; + return Math.min( + borderPointSegmentDistance(ax, ay, cx, cy, dx, dy), + borderPointSegmentDistance(bx, by, cx, cy, dx, dy), + borderPointSegmentDistance(cx, cy, ax, ay, bx, by), + borderPointSegmentDistance(dx, dy, ax, ay, bx, by) + ) <= tolerance; +} +function suppressMunicipalBordersNearPrefectures(adminSegments, prefectureSegments) { + if (!adminSegments?.length || !prefectureSegments?.length) return adminSegments || []; + const tolerance = 1.35; + const cellSize = tolerance; + const grid = new Map(); + const cell = (v) => Math.floor(v / cellSize); + for (const seg of prefectureSegments) { + const m = borderSegmentMidpoint(seg); + const key = `${cell(m.x)},${cell(m.y)}`; + const bucket = grid.get(key) || []; + bucket.push(seg); + grid.set(key, bucket); + } + const out = []; + for (const seg of adminSegments) { + const m = borderSegmentMidpoint(seg); + const gx = cell(m.x), gy = cell(m.y); + let near = false; + for (let yy = gy - 2; yy <= gy + 2 && !near; yy++) { + for (let xx = gx - 2; xx <= gx + 2 && !near; xx++) { + for (const pref of grid.get(`${xx},${yy}`) || []) { + if (borderSegmentsNear(seg, pref, tolerance)) { near = true; break; } + } + } + } + if (!near) out.push(seg); + } + return out; +} + function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) { if (!path || path.length < 2) return; ctx.save(); @@ -1123,8 +1188,9 @@ export function drawMap(canvas, map, options) { const showPrefectureRegions = ["all", "admin", "borders-debug"].includes(mode); if (showPrefectureRegions) drawPrefectureRegionFill(ctx, map, mode); - const adminBorderSegments = getDisplayBorderSegments(map, "adminId", map.adminBorders, mode); const prefectureBorderSegments = getDisplayBorderSegments(map, "prefectureRegionId", map.regionalPrefectureBorders, mode); + const rawAdminBorderSegments = getDisplayBorderSegments(map, "adminId", map.adminBorders, mode); + const adminBorderSegments = mode === "borders-debug" ? rawAdminBorderSegments : suppressMunicipalBordersNearPrefectures(rawAdminBorderSegments, prefectureBorderSegments); if (showAdmin && adminBorderSegments?.length) { drawVectorSegments(ctx, adminBorderSegments, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 }); drawVectorSegments(ctx, adminBorderSegments, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 }); diff --git a/styles.css b/styles.css index c2e3ac8..41c0427 100644 --- a/styles.css +++ b/styles.css @@ -1,94 +1,532 @@ -*{box-sizing:border-box} -body{margin:0;background:#f0f2f5;color:#2c2c2c;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +*{box-sizing:border-box} +:root{ + --bg:#090b10; + --bg-2:#0d1118; + --surface:#11161f; + --surface-2:#151d28; + --surface-3:#1a2431; + --surface-soft:rgba(20,28,39,.88); + --panel:rgba(20,28,39,.9); + --line:rgba(173,190,211,.12); + --line-strong:rgba(173,190,211,.24); + --text:#e8eef8; + --muted:#97a6bb; + --muted-2:#6f8199; + --accent:#4f8cff; + --accent-strong:#3e75d9; + --accent-soft:rgba(79,140,255,.14); + --danger:#ff6861; + --shadow:0 24px 60px rgba(0,0,0,.36); + --shadow-soft:0 10px 26px rgba(0,0,0,.24); +} +html,body{height:100%} +body{ + margin:0; + background:radial-gradient(circle at top left,#131a25 0,#0d1118 38%,#090b10 100%); + color:var(--text); + font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; +} button,input,select{font:inherit} -code{background:#e8e8e8;border-radius:4px;padding:1px 4px} -.app{min-height:100vh;padding:16px} -.layout{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:16px;max-width:1360px;margin:0 auto} -.header{margin-bottom:12px} -.header h1{margin:0 0 6px;font-size:24px;letter-spacing:-0.02em;color:#1a1a1a;font-weight:700} -.header p{margin:0;color:#5a5a5a;line-height:1.6;font-size:14px} -.canvas-shell,.card{background:#ffffff;border:1px solid rgba(0,0,0,0.08);border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.04)} -.canvas-shell{padding:12px;overflow:auto;position:relative;cursor:grab;user-select:none;touch-action:none} -.map-canvas{display:block;border-radius:8px;background:#f8f9fa} -.sidebar{display:flex;flex-direction:column;gap:12px} -.card{padding:14px} -.label,.card-title{display:block;margin-bottom:12px;color:#1a1a1a;font-size:14px;font-weight:600} -.inline-label{margin-top:12px} -.input{width:100%;border:1px solid rgba(0,0,0,0.15);background:#fff;color:#2c2c2c;border-radius:8px;padding:10px 12px;outline:none;transition:border-color 0.2s} -.input:focus{border-color:#1a73e8;box-shadow:0 0 0 3px rgba(26,115,232,0.15)} -.primary-button,.mode-button{border:0;border-radius:8px;padding:10px 12px;cursor:pointer;font-weight:600;transition:background 0.2s} -.primary-button{margin-top:12px;width:100%;background:#1a73e8;color:#fff} -.primary-button:hover{background:#1557b0} -.mode-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px} -.mode-button{background:#f1f3f4;color:#3c4043;border:1px solid transparent} -.mode-button:hover{background:#e8eaed} -.mode-button.active{background:#e8f0fe;color:#1a73e8;border:1px solid #1a73e8} -.checkbox-row{display:flex;gap:8px;align-items:center;margin-top:12px;color:#3c4043;font-size:14px;cursor:pointer} -.stats{display:flex;flex-direction:column;gap:8px} -.stat-row{display:flex;justify-content:space-between;gap:12px;color:#5f6368;font-size:13px;align-items:baseline} -.stat-row strong{color:#202124;font-family:ui-monospace,monospace} -.legend{color:#5f6368;font-size:13px;line-height:1.6} -.legend p{margin:8px 0 0} - -@media (max-width:1100px){.layout{grid-template-columns:1fr}} - -.legend-grid{display:flex;flex-direction:column;gap:8px;margin-top:12px} -.legend-row{display:grid;grid-template-columns:32px 1fr;gap:8px;align-items:center;min-height:22px} - -/* Layered GIS style CSS equivalents */ -.legend-line{display:inline-block;width:28px;height:4px;border-radius:2px;} -.express-line{background:#6eb982; border:1px solid #508c64;} -.road-line{background:#f5e182; border:1px solid #beaf8c;} - -/* Fishbone Railway Style */ -.rail-line{background:#6e6e6e; height:1.5px; position:relative; border:none; margin-top:2px; border-radius:0} -.rail-line::after{content:"";position:absolute;top:-2.5px;left:0;right:0;height:7px;background:repeating-linear-gradient(90deg, transparent, transparent 5px, #6e6e6e 5px, #6e6e6e 6px);} - -.river-major{background:#a0cdf0; border:none; height:3px;} -.old-road-line{background:#fff; border:1px solid #dcdcdc; height:3px; border-top:none;} - -.legend-swatch{display:inline-block;width:24px;height:14px;border-radius:4px;background:#f5f5f5} -.border-swatch{border:2px dashed rgba(110,90,110,1);box-shadow:inset 0 0 0 1px rgba(255,255,255,1), 0 0 0 1px rgba(255,255,255,1)} -.cbd-swatch{background:#f0dccd; border:1px solid rgba(0,0,0,0.1)} - -.legend-icon{display:inline-block;width:14px;height:14px;justify-self:center;border:2px solid #fff;border-radius:50%;box-shadow:0 0 0 1px rgba(0,0,0,0.15)} -.city-icon{background:#f06e6e;} -.satellite-icon{background:#d75f91;} -.station-icon{background:#fff;border-color:#444;} -.industry-icon{background:#8caaa0; border-radius:3px;} -.castle-icon{background:#b44646; border-radius:3px;} - -.port-icon{width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #4682c8;border-top:0;box-shadow:none;background:transparent;border-radius:0} -.newtown-icon{width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #b4d2f0;border-top:0;box-shadow:none;background:transparent;border-radius:0} - -.map-tooltip{position:absolute;z-index:20;pointer-events:none;min-width:200px;max-width:280px;background:rgba(255,255,255,0.74);border:1px solid rgba(0,0,0,0.10);border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,0.10);backdrop-filter:blur(4px);padding:10px 12px;color:#2c2c2c;font-size:12px;line-height:1.5;opacity:0;transform:translateY(4px);transition:opacity 0.15s ease, transform 0.15s ease;font-weight:500} -.map-tooltip.visible{opacity:1;transform:translateY(0)} - -.generation-progress{position:absolute;inset:24px auto auto 24px;z-index:30;min-width:300px;max-width:440px;background:rgba(255,255,255,0.96);border:1px solid rgba(0,0,0,0.12);border-radius:12px;box-shadow:0 14px 36px rgba(0,0,0,0.14);padding:14px 16px;color:#202124;font-size:13px;line-height:1.5} -.generation-progress.hidden{display:none} -.progress-title{font-weight:700;margin-bottom:4px} -.progress-stage{color:#5f6368;margin-bottom:10px} -.progress-timings{display:flex;flex-direction:column;gap:4px;font-family:ui-monospace,monospace;font-size:12px;color:#3c4043} -.progress-timing-row{display:flex;justify-content:space-between;gap:16px;border-top:1px solid rgba(0,0,0,0.06);padding-top:4px} - +button{user-select:none} +code{background:#121820;border-radius:4px;padding:1px 4px} +.app{min-height:100dvh} +.page-shell{width:100%} +.top-stage{ + height:100dvh; + display:grid; + grid-template-columns:minmax(0,1fr) 320px; + gap:12px; + padding:12px 12px 0; +} +.map-section,.generation-section{min-height:0} +.map-section{min-width:0} +.canvas-shell{ + position:relative; + height:100%; + border:1px solid var(--line); + border-radius:18px; + overflow:hidden; + background:linear-gradient(180deg,#0d131c 0,#0b1017 100%); + box-shadow:var(--shadow); + cursor:grab; + user-select:none; + touch-action:none; +} +.canvas-shell::before{ + content:""; + position:absolute; + inset:0; + background:linear-gradient(180deg,rgba(255,255,255,.02),rgba(255,255,255,0)); + pointer-events:none; +} .canvas-shell.panning{cursor:grabbing} -.map-selection-svg{position:absolute;left:0;top:0;width:0;height:0;z-index:18;display:none;pointer-events:none;overflow:visible} -.map-selection-svg polygon{fill:rgba(26,115,232,0.16);stroke:rgba(26,115,232,0.88);stroke-width:2;vector-effect:non-scaling-stroke;stroke-linejoin:round} -.map-selection-svg.invalid polygon{fill:rgba(179,38,30,0.14);stroke:rgba(179,38,30,0.88)} -.canvas-shell.selecting{cursor:crosshair} -.map-selection{position:absolute;z-index:18;display:none;pointer-events:none;border:2px solid rgba(26,115,232,0.86);background:rgba(26,115,232,0.16);box-shadow:0 0 0 1px rgba(255,255,255,0.70) inset,0 8px 22px rgba(26,115,232,0.20)} -.primary-button:disabled{background:#a8b6c8;color:#eef3f8;cursor:not-allowed} -.patch-status{margin:10px 0 0;color:#5f6368;font-size:12px;line-height:1.45} -.patch-status.invalid{color:#b3261e;font-weight:600} -.map-selection.invalid{border-color:rgba(179,38,30,0.88);background:rgba(179,38,30,0.14);box-shadow:0 0 0 1px rgba(255,255,255,0.70) inset,0 8px 22px rgba(179,38,30,0.18)} +.canvas-shell.selecting,.canvas-shell.patch-intent{cursor:crosshair} +.canvas-stage{ + position:absolute; + inset:0; + display:flex; + align-items:flex-end; + justify-content:center; + padding:12px; +} +.map-canvas{ + display:block; + background:#0c1118; + border-radius:16px; + box-shadow:0 0 0 1px rgba(255,255,255,.06),0 12px 40px rgba(0,0,0,.28); +} +.generation-section{ + display:flex; + flex-direction:column; + gap:10px; + overflow:auto; + padding-bottom:0; +} +.settings-card, +.summary-section, +.advanced-details{ + background:var(--surface); + border:1px solid var(--line); + border-radius:16px; + box-shadow:var(--shadow-soft); +} +.settings-card{padding:12px} +.settings-header-card{padding:10px 12px} +.section-title-row{display:flex;align-items:end;justify-content:space-between;gap:14px;margin:0 0 10px} +.section-title-row.compact{align-items:center;margin-bottom:10px} +.card-heading{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:10px} +.card-heading.tight{margin-bottom:0} +.eyebrow{font-size:10px;line-height:1;text-transform:uppercase;letter-spacing:.12em;color:var(--muted-2);font-weight:900;margin-bottom:5px} +h2,h3{margin:0;letter-spacing:-.02em}h2{font-size:16px}h3{font-size:14px} +.field{display:block;margin-bottom:10px}.field:last-child{margin-bottom:0} +.field-label{display:block;margin:0 0 6px;color:#cad5e5;font-size:12px;font-weight:850;letter-spacing:.01em} +.input{ + width:100%; + border:1px solid var(--line-strong); + background:var(--surface-3); + color:var(--text); + border-radius:10px; + padding:9px 10px; + outline:none; + transition:border-color .16s,box-shadow .16s,background .16s; + min-height:38px; +} +.input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(79,140,255,.18)} +.primary-button,.secondary-button,.ghost-button,.mode-button,.segmented-button{ + border-radius:10px; + padding:9px 10px; + cursor:pointer; + font-weight:800; + transition:background .16s,border-color .16s,color .16s,transform .16s, box-shadow .16s; + min-height:38px; +} +.primary-button{border:1px solid var(--accent);background:var(--accent);color:#fff;box-shadow:0 8px 16px rgba(79,140,255,.18)} +.primary-button:hover{background:var(--accent-strong);border-color:var(--accent-strong)} +.secondary-button{border:1px solid rgba(79,140,255,.42);background:rgba(79,140,255,.10);color:#dbe7ff} +.secondary-button:hover{background:rgba(79,140,255,.16);border-color:rgba(79,140,255,.55)} +.ghost-button{border:1px solid var(--line-strong);background:transparent;color:#c8d2e1}.ghost-button:hover{background:rgba(255,255,255,.04);border-color:rgba(173,190,211,.32)} +.primary-button:disabled,.secondary-button:disabled,.ghost-button:disabled{background:#1a2029;color:#76859a;border-color:rgba(173,190,211,.08);box-shadow:none;cursor:not-allowed} +.button-stack{display:grid;gap:8px;margin-top:8px}.button-stack.two-col{grid-template-columns:1fr 1fr}.button-stack button{width:100%;font-size:12px;white-space:nowrap} +.segmented-group{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:6px;width:100%} +.segmented-button,.mode-button{border:1px solid var(--line-strong);background:var(--surface-3);color:#d2dbeb;box-shadow:none;white-space:nowrap} +.segmented-button{width:100%;display:flex;align-items:center;justify-content:center;text-align:center;padding-left:8px;padding-right:8px} +.segmented-button:hover,.mode-button:hover{background:#202b3a} +.segmented-button.active,.mode-button.active{background:var(--accent);border-color:var(--accent);color:#fff;box-shadow:0 8px 16px rgba(79,140,255,.16)} +.microcopy{margin:9px 0 0;color:var(--muted);font-size:11px;line-height:1.45} +.mode-grid{display:grid;grid-template-columns:1fr;gap:6px}.mode-button{min-height:32px;padding:7px 9px;font-size:12px;border-radius:9px;text-align:left} +.checkbox-row{display:flex;gap:8px;align-items:center;margin-top:9px;color:#d2dbeb;font-size:13px;cursor:pointer}.checkbox-row input{accent-color:var(--accent)} +.patch-status{margin:10px 0 0;color:var(--muted);font-size:12px;line-height:1.45}.patch-status.invalid{color:var(--danger);font-weight:750} -.patch-variant-row{display:grid;grid-template-columns:1fr 92px;gap:8px;align-items:end;margin-top:12px} -.patch-variant-label{margin-bottom:0;align-self:center} -.patch-variant-input{padding:8px 10px;text-align:right;font-family:ui-monospace,monospace} -.patch-button-row{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:12px} -.patch-button-row .primary-button,.patch-button-row .secondary-button{margin-top:0;width:100%} -.secondary-button{border:1px solid rgba(26,115,232,0.35);border-radius:8px;padding:10px 12px;cursor:pointer;font-weight:600;background:#eef4ff;color:#1557b0;transition:background 0.2s,border-color 0.2s} -.secondary-button:hover{background:#e1edff;border-color:rgba(26,115,232,0.55)} -.secondary-button:disabled{background:#eef1f4;color:#8a98a8;border-color:rgba(0,0,0,0.08);cursor:not-allowed} +.map-dock{ + position:absolute; + top:16px; + right:16px; + z-index:24; + width:min(240px,28vw); + display:flex; + flex-direction:column; + gap:10px; +} +.dock-card{ + background:rgba(17,22,31,.86); + border:1px solid rgba(173,190,211,.16); + border-radius:14px; + box-shadow:0 16px 34px rgba(0,0,0,.26); + overflow:hidden; + backdrop-filter:blur(12px); +} +.dock-card summary{ + list-style:none; + cursor:pointer; + display:flex; + align-items:center; + justify-content:space-between; + gap:8px; + min-height:42px; + padding:10px 10px; + font-size:13px; + font-weight:900; + color:var(--text); + border-bottom:1px solid transparent; +} +.dock-card summary::-webkit-details-marker{display:none} +.dock-card[open] summary{border-bottom-color:rgba(173,190,211,.10)} +.summary-button{ + border:1px solid rgba(173,190,211,.24); + background:rgba(255,255,255,.04); + color:var(--muted); + border-radius:999px; + padding:2px 7px; + font-size:10px; + font-weight:850; + line-height:1.4; +} +.dock-card:not([open]) .summary-button::before{content:"Open"} +.dock-card[open] .summary-button::before{content:"Close"} +.summary-button{font-size:0}.summary-button::before{font-size:10px} +.dock-body{padding:10px}.divider{height:1px;background:rgba(173,190,211,.10);margin:10px 0} +.stats{display:flex;flex-direction:column;gap:0} +.summary-section{margin:12px;padding:12px} +.summary-stats{display:grid;grid-template-columns:repeat(6,minmax(120px,1fr));gap:0;border:1px solid var(--line);border-radius:12px;overflow:hidden;background:var(--surface-2)} +.stat-row{display:flex;justify-content:space-between;gap:12px;color:var(--muted);font-size:13px;align-items:baseline;padding:9px 10px;border-bottom:1px solid rgba(173,190,211,.08)} +.summary-stats .stat-row{display:grid;grid-template-columns:1fr;gap:4px;border-bottom:0;border-right:1px solid rgba(173,190,211,.08);min-height:58px}.summary-stats .stat-row:last-child{border-right:0} +.stat-row strong{color:#edf3fe;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;text-align:right;white-space:nowrap}.summary-stats .stat-row strong{text-align:left;font-size:15px} +.legend-grid{display:flex;flex-direction:column;gap:8px}.legend-row{display:grid;grid-template-columns:28px 1fr;gap:8px;align-items:center;min-height:20px;color:#d0dae9;font-size:12px;line-height:1.35} +.legend-line{display:inline-block;width:24px;height:4px;border-radius:999px;justify-self:center}.express-line{background:#87a087;border:1px solid #697d69}.road-line{background:#f5e182;border:1px solid #beaf8c}.river-major{background:#74a5ca;border:none;height:3px}.old-road-line{background:#fff;border:1px solid #8f8f86;height:3px}.minor-road-line{background:#fff;border:1px solid #a7a7a0;height:3px}.rail-line{background:#e8e8e8;height:1.5px;position:relative;border:none;margin-top:2px;border-radius:0}.rail-line::after{content:"";position:absolute;top:-2.5px;left:0;right:0;height:7px;background:repeating-linear-gradient(90deg,transparent,transparent 5px,#343a43 5px,#343a43 6px)} +.legend-swatch{display:inline-block;width:24px;height:14px;border-radius:4px;background:#f5f5f5;justify-self:center}.border-swatch{border:2px dashed rgba(171,145,171,1);box-shadow:inset 0 0 0 1px rgba(255,255,255,1),0 0 0 1px rgba(255,255,255,1)}.admin-swatch{border:2px dashed rgba(190,180,190,.9);background:#fff}.terrain-swatch{background:linear-gradient(90deg,#7da564,#e3daa2,#98a58f)}.urban-swatch{background:#f0dccd;border:1px solid rgba(0,0,0,.1)}.sea-swatch{background:#9fc7df;border:1px solid rgba(70,120,160,.35)} +.legend-icon{display:inline-block;width:14px;height:14px;justify-self:center;border:2px solid #fff;border-radius:50%;box-shadow:0 0 0 1px rgba(0,0,0,.15)}.city-icon{background:#f06e6e}.town-icon{width:10px;height:10px;border-radius:3px;background:#f06e6e}.station-icon{background:#fff;border-color:#444}.industry-icon{background:#8caaa0;border-radius:3px}.port-icon{width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #4682c8;border-top:0;box-shadow:none;background:transparent;border-radius:0}.castle-icon{background:#b44646;border-radius:3px} + +.zoom-control{position:absolute;left:18px;bottom:18px;z-index:25;display:flex;flex-direction:column;overflow:hidden;border-radius:14px;background:rgba(12,17,24,.84);border:1px solid rgba(173,190,211,.16);box-shadow:0 14px 30px rgba(0,0,0,.26);backdrop-filter:blur(10px)} +.zoom-control button{min-width:58px;height:40px;border:0;border-bottom:1px solid rgba(173,190,211,.10);background:transparent;color:var(--text);font-weight:850;cursor:pointer}.zoom-control button:last-child{border-bottom:0;font-size:12px}.zoom-control button:hover{background:rgba(79,140,255,.12);color:#fff} +.map-hint{position:absolute;left:18px;top:18px;z-index:25;max-width:440px;background:rgba(14,27,50,.86);border:1px solid rgba(79,140,255,.36);color:#dce8ff;border-radius:13px;box-shadow:0 10px 26px rgba(0,0,0,.22);padding:10px 12px;font-size:13px;font-weight:800;backdrop-filter:blur(8px)} +.hidden{display:none!important} +.map-tooltip{position:absolute;z-index:26;pointer-events:none;min-width:200px;max-width:280px;background:rgba(17,22,31,.92);border:1px solid rgba(173,190,211,.16);border-radius:10px;box-shadow:0 10px 30px rgba(0,0,0,.28);backdrop-filter:blur(10px);padding:10px 12px;color:var(--text);font-size:12px;line-height:1.5;opacity:0;transform:translateY(4px);transition:opacity .15s ease,transform .15s ease;font-weight:500}.map-tooltip.visible{opacity:1;transform:translateY(0)} +.generation-progress{position:absolute;inset:18px auto auto 18px;z-index:30;min-width:300px;max-width:440px;background:rgba(17,22,31,.96);border:1px solid rgba(173,190,211,.16);border-radius:14px;box-shadow:0 14px 36px rgba(0,0,0,.34);padding:14px 16px;color:var(--text);font-size:13px;line-height:1.5}.progress-title{font-weight:850;margin-bottom:4px}.progress-stage{color:var(--muted);margin-bottom:10px}.progress-timings{display:flex;flex-direction:column;gap:4px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:#cfdbef}.progress-timing-row{display:flex;justify-content:space-between;gap:16px;border-top:1px solid rgba(173,190,211,.08);padding-top:4px} +.map-selection-svg{position:absolute;left:0;top:0;width:0;height:0;z-index:18;display:none;pointer-events:none;overflow:visible}.map-selection-svg polygon{fill:rgba(79,140,255,.16);stroke:rgba(79,140,255,.92);stroke-width:2;vector-effect:non-scaling-stroke;stroke-linejoin:round}.map-selection-svg.invalid polygon{fill:rgba(255,104,97,.14);stroke:rgba(255,104,97,.92)}.map-selection{position:absolute;z-index:18;display:none;pointer-events:none;border:2px solid rgba(79,140,255,.88);background:rgba(79,140,255,.16);box-shadow:0 0 0 1px rgba(255,255,255,.20) inset,0 8px 22px rgba(79,140,255,.20)}.map-selection.invalid{border-color:rgba(255,104,97,.92);background:rgba(255,104,97,.14);box-shadow:0 0 0 1px rgba(255,255,255,.20) inset,0 8px 22px rgba(255,104,97,.18)} .map-canvas.is-zooming{image-rendering:auto;pointer-events:auto} + +.advanced-section{padding:0 12px 12px} +.advanced-details{overflow:hidden} +.advanced-details summary{list-style:none;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 12px;color:var(--text);font-size:13px;font-weight:900} +.advanced-details summary::-webkit-details-marker{display:none} +.advanced-summary-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;min-width:0} +.copy-debug-button{ + border:1px solid rgba(79,140,255,.42); + background:rgba(79,140,255,.10); + color:#dbe7ff; + border-radius:999px; + padding:4px 10px; + font-size:11px; + font-weight:850; + line-height:1.2; + cursor:pointer; +} +.copy-debug-button:hover{background:rgba(79,140,255,.18);border-color:rgba(79,140,255,.58)} +.copy-debug-status{min-width:48px;color:var(--muted-2);font-size:11px;font-weight:800;text-align:right} +.copy-debug-status.error{color:var(--danger)} +.advanced-toggle{display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--line-strong);background:rgba(255,255,255,.04);color:var(--muted);border-radius:999px;padding:3px 10px;font-size:11px;font-weight:850;min-width:52px} +.advanced-details[open] .advanced-toggle{font-size:0}.advanced-details[open] .advanced-toggle::before{content:"Close";font-size:11px} +.advanced-body{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;border-top:1px solid var(--line);padding:12px;background:var(--surface-2)} +.debug-note{background:var(--surface-3);border:1px solid var(--line);border-radius:12px;padding:10px}.debug-note strong{display:block;font-size:12px;color:var(--text);margin-bottom:4px}.debug-note p{margin:0;color:var(--muted);font-size:12px;line-height:1.5} + +@media (max-width:1280px){ + .top-stage{grid-template-columns:minmax(0,1fr) 292px} + .summary-stats{grid-template-columns:repeat(3,minmax(120px,1fr))} +} +@media (max-width:980px){ + .top-stage{height:auto;grid-template-columns:1fr;padding-top:10px} + .map-section{min-height:72dvh} + .generation-section{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-items:start} + .settings-header-card{grid-column:1/-1} + .map-dock{width:min(220px,38vw)} + .advanced-body{grid-template-columns:1fr} +} +@media (max-width:720px){ + .generation-section{grid-template-columns:1fr} + .summary-stats{grid-template-columns:1fr} + .summary-stats .stat-row{border-right:0;border-bottom:1px solid rgba(173,190,211,.08)} + .summary-stats .stat-row:last-child{border-bottom:0} + .map-dock{top:12px;right:12px;left:auto;width:min(210px,48vw)} + .map-hint{left:12px;right:12px;top:12px;max-width:none} + .zoom-control{left:12px;bottom:12px} +} +@media (max-width:560px){ + .top-stage{padding:8px 8px 0;gap:8px} + .summary-section{margin:8px;padding:10px} + .advanced-section{padding:0 8px 8px} + .canvas-stage{padding:8px} + .map-dock{position:absolute;width:min(200px,58vw)} + .button-stack.two-col,.segmented-group{grid-template-columns:1fr} +} + +.debug-panel{ + background:var(--surface-3); + border:1px solid var(--line); + border-radius:14px; + padding:12px; + min-width:0; +} +.debug-panel.wide{grid-column:span 2} +.debug-panel-header{ + display:flex; + align-items:flex-start; + justify-content:space-between; + gap:16px; + margin-bottom:10px; +} +.debug-panel-header p{ + margin:0; + color:var(--muted); + font-size:12px; + line-height:1.45; + max-width:360px; + text-align:right; +} +.debug-note.flat{ + background:rgba(255,255,255,.03); + box-shadow:none; + margin-top:8px; +} +.perf-history{ + display:flex; + flex-direction:column; + gap:8px; +} +.perf-empty{ + border:1px dashed rgba(173,190,211,.18); + border-radius:12px; + padding:12px; + color:var(--muted); + font-size:12px; + text-align:center; +} +.perf-empty.inline{ + grid-column:1/-1; + text-align:left; +} +.perf-run{ + background:rgba(10,14,20,.34); + border:1px solid rgba(173,190,211,.12); + border-radius:12px; + overflow:hidden; +} +.perf-run summary::-webkit-details-marker{display:none} +.perf-summary{ + list-style:none; + cursor:pointer; + display:grid; + grid-template-columns:42px 1.2fr .7fr 1fr 1fr; + gap:10px; + align-items:center; + padding:9px 10px; + color:#dbe5f4; + font-size:12px; +} +.perf-summary strong{ + color:#fff; + font-family:ui-monospace,SFMono-Regular,Menlo,monospace; + font-size:12px; +} +.perf-summary span:last-child{color:#b8c8dd} +.perf-rank{ + display:inline-flex; + align-items:center; + justify-content:center; + min-width:32px; + border-radius:999px; + padding:3px 7px; + background:rgba(79,140,255,.14); + color:#dce8ff; + font-weight:900; +} +.timing-grid{ + display:grid; + grid-template-columns:repeat(3,minmax(0,1fr)); + gap:6px; + padding:0 10px 10px; +} +.timing-pill{ + display:flex; + align-items:center; + justify-content:space-between; + gap:8px; + padding:7px 8px; + border-radius:9px; + background:rgba(255,255,255,.04); + color:var(--muted); + font-size:11px; + min-width:0; +} +.timing-pill span{ + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap; +} +.timing-pill strong{ + color:#edf3fe; + font-family:ui-monospace,SFMono-Regular,Menlo,monospace; + font-size:11px; + white-space:nowrap; +} +.interaction-table{ + display:flex; + flex-direction:column; + overflow:hidden; + border:1px solid rgba(173,190,211,.10); + border-radius:12px; +} +.interaction-row{ + display:grid; + grid-template-columns:1.1fr .75fr .55fr .55fr; + gap:8px; + align-items:center; + min-height:34px; + padding:7px 9px; + border-bottom:1px solid rgba(173,190,211,.08); + color:var(--muted); + font-size:12px; +} +.interaction-row:last-child{border-bottom:0} +.interaction-row strong{ + color:#fff; + font-family:ui-monospace,SFMono-Regular,Menlo,monospace; + font-size:12px; +} +@media (max-width:1280px){.debug-panel.wide{grid-column:1/-1}.timing-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} +@media (max-width:720px){.advanced-summary-actions{gap:6px}.copy-debug-status{display:none}.copy-debug-button{padding:4px 8px}.debug-panel-header{display:block}.debug-panel-header p{text-align:left;margin-top:6px}.perf-summary{grid-template-columns:36px 1fr;gap:7px}.perf-summary strong,.perf-summary span:nth-child(n+4){grid-column:2}.timing-grid{grid-template-columns:1fr}.interaction-row{grid-template-columns:1fr .8fr}} +.metric-grid{ + display:grid; + grid-template-columns:repeat(6,minmax(0,1fr)); + gap:6px; + margin-bottom:10px; +} +.metric-card{ + display:flex; + flex-direction:column; + gap:3px; + min-width:0; + padding:8px 9px; + border:1px solid rgba(173,190,211,.10); + border-radius:10px; + background:rgba(255,255,255,.035); +} +.metric-card span{ + color:var(--muted); + font-size:10px; + font-weight:800; + text-transform:uppercase; + letter-spacing:.06em; +} +.metric-card strong{ + color:#fff; + font-family:ui-monospace,SFMono-Regular,Menlo,monospace; + font-size:13px; + white-space:nowrap; +} +.metric-card small{ + color:var(--muted-2); + font-size:10px; + line-height:1.25; +} +.perf-summary.patch{ + grid-template-columns:42px 1.5fr .7fr 1.25fr 1fr; +} +.timing-pill.meta{ + background:rgba(79,140,255,.08); + border:1px solid rgba(79,140,255,.12); +} +.aggregate-header, +.aggregate-row{ + display:grid; + grid-template-columns:minmax(160px,1.3fr) .55fr .7fr .7fr .7fr; + gap:8px; + align-items:center; +} +.aggregate-header{ + padding:0 9px 6px; + color:var(--muted-2); + font-size:10px; + font-weight:900; + text-transform:uppercase; + letter-spacing:.06em; +} +.aggregate-table{ + display:flex; + flex-direction:column; + overflow:hidden; + border:1px solid rgba(173,190,211,.10); + border-radius:12px; + margin-bottom:10px; +} +.aggregate-row{ + min-height:34px; + padding:7px 9px; + border-bottom:1px solid rgba(173,190,211,.08); + color:var(--muted); + font-size:12px; +} +.aggregate-row:last-child{border-bottom:0} +.aggregate-row span{color:#dbe5f4;font-weight:800} +.aggregate-row small{color:var(--muted-2)} +.aggregate-row strong{ + color:#fff; + font-family:ui-monospace,SFMono-Regular,Menlo,monospace; + font-size:12px; +} +@media (max-width:1280px){.metric-grid{grid-template-columns:repeat(3,minmax(0,1fr))}} +@media (max-width:720px){.metric-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.aggregate-header,.aggregate-row{grid-template-columns:1fr .55fr}.aggregate-header strong,.aggregate-row strong{grid-column:2}.aggregate-header strong:nth-of-type(1)::before,.aggregate-row strong:nth-of-type(1)::before{content:"Avg ";color:var(--muted-2);font-family:inherit}.aggregate-header strong:nth-of-type(2)::before,.aggregate-row strong:nth-of-type(2)::before{content:"Max ";color:var(--muted-2);font-family:inherit}.aggregate-header strong:nth-of-type(3)::before,.aggregate-row strong:nth-of-type(3)::before{content:"P95 ";color:var(--muted-2);font-family:inherit}} +.diagnostic-grid{margin-bottom:10px} +.diagnostic-table{ + display:flex; + flex-direction:column; + overflow:hidden; + border:1px solid rgba(173,190,211,.10); + border-radius:12px; + background:rgba(10,14,20,.22); +} +.diagnostic-table.stacked{margin-top:10px} +.diagnostic-row{ + display:grid; + grid-template-columns:minmax(130px,1fr) minmax(90px,.75fr) minmax(120px,1fr); + gap:8px; + align-items:center; + min-height:34px; + padding:7px 9px; + border-bottom:1px solid rgba(173,190,211,.08); + color:var(--muted); + font-size:12px; +} +.diagnostic-row:last-child{border-bottom:0} +.diagnostic-row span{color:#dbe5f4;font-weight:800} +.diagnostic-row strong{color:#fff;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.diagnostic-row small{color:var(--muted-2);line-height:1.25} +.diagnostic-log{ + display:flex; + flex-direction:column; + overflow:hidden; + border:1px solid rgba(173,190,211,.10); + border-radius:12px; + background:rgba(10,14,20,.22); +} +.diagnostic-log-row{ + display:grid; + grid-template-columns:86px minmax(140px,.7fr) minmax(0,1.8fr); + gap:10px; + align-items:start; + padding:8px 10px; + border-bottom:1px solid rgba(173,190,211,.08); + color:var(--muted); + font-size:12px; +} +.diagnostic-log-row:last-child{border-bottom:0} +.diagnostic-log-row span{color:var(--muted-2);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;white-space:nowrap} +.diagnostic-log-row strong{color:#edf3fe;font-size:12px} +.diagnostic-log-row p{margin:0;color:var(--muted);line-height:1.35;overflow-wrap:anywhere} +.diagnostic-log-row.warning strong{color:#ffd38a} +.diagnostic-log-row.error strong{color:#ff9c96} +.diagnostic-log-row.info strong{color:#b8d0ff} +@media (max-width:720px){ + .diagnostic-row{grid-template-columns:1fr;gap:3px} + .diagnostic-log-row{grid-template-columns:1fr;gap:4px} +}