import { generateMapAsync } from "./mapGenerator.js"; import { drawMap } from "./renderer.js"; import { landuseLabel } from "./landuseCodes.js"; import { CELL_SIZE, MAP_H, MAP_W } from "./mapUtils.js"; import { clampCameraToWorld, createInitialCamera, createWorldMap, ensureWorldPaddingForCamera } from "./worldMap.js"; import { getViewportMap } from "./worldViewport.js"; import { PATCH_MIN_AREA, PATCH_MIN_HEIGHT, PATCH_MIN_WIDTH, buildPatchRects, generatePatch, validatePatchRect } from "./mapPatch.js"; const modes = [ ["all", "All"], ["terrain", "Terrain"], ["history", "Premodern"], ["modern", "Modern"], ["development", "Development"], ["landuse", "Land Use"], ["admin", "Municipal Borders"], ["borders-debug", "Borders Debug"], ["transport-debug", "Transport Debug"], ]; const state = { seedText: "114514", generationType: "auto", mode: "all", showFeatures: true, showLabels: true, map: null, world: null, camera: { x: 0, y: 0 }, viewportMap: null, viewWidth: MAP_W, viewHeight: MAP_H, hoverEntities: [], selectionRect: null, patchVariant: 0, zoom: 1, lastPatchResult: null, }; const canvas = document.getElementById("mapCanvas"); const canvasShell = document.querySelector(".canvas-shell"); const seedInput = document.getElementById("seed"); const generationTypeInput = document.getElementById("generationType"); const patchTerrainTypeInput = document.getElementById("patchTerrainType"); const patchVariantInput = document.getElementById("patchVariant"); const generatePatchButton = document.getElementById("generatePatch"); const alternativePatchButton = document.getElementById("alternativePatch"); const patchStatusEl = document.getElementById("patchStatus"); const randomSeedButton = document.getElementById("randomSeed"); const showFeaturesInput = document.getElementById("showFeatures"); const showLabelsInput = document.getElementById("showLabels"); const modeGrid = document.getElementById("modeGrid"); const statsEl = document.getElementById("stats"); const tooltipEl = document.getElementById("mapTooltip"); const selectionSvgEl = document.getElementById("mapSelectionSvg"); const selectionEl = document.getElementById("mapSelection"); const progressEl = document.getElementById("generationProgress"); const progressStageEl = document.getElementById("generationProgressStage"); const progressTimingsEl = document.getElementById("generationProgressTimings"); let generationStartedAt = 0; let generationCurrentStage = ""; let generationTimer = null; let zoomRedrawRaf = null; let zoomSettledTimer = null; const dragState = { mode: null, pointerId: null, startClientX: 0, startClientY: 0, startCameraX: 0, startCameraY: 0, selectStart: null, selectEnd: null, selectPath: null, pendingCamera: null, panRaf: null, }; function activeMap() { return state.viewportMap || state.map; } function clampZoom(value) { const parsed = Number(value); if (!Number.isFinite(parsed)) return 1; return Math.min(Math.max(parsed, 0.55), 2.8); } function viewportSizeForZoom(zoom = state.zoom) { const z = clampZoom(zoom || 1); return { width: Math.max(1, Math.ceil(MAP_W / z)), height: Math.max(1, Math.ceil(MAP_H / z)), }; } function clampCameraForView(camera, size = viewportSizeForZoom(state.zoom)) { return clampCameraToWorld(camera, state.world, size?.width || MAP_W, size?.height || MAP_H); } function syncViewportSize() { const size = viewportSizeForZoom(state.zoom); state.viewWidth = size.width; state.viewHeight = size.height; return size; } function mapCellScreenSize(map = activeMap()) { const rect = canvas.getBoundingClientRect(); const mapWidth = Math.max(1, map?.width || state.viewWidth || MAP_W); return rect.width ? rect.width / mapWidth : CELL_SIZE * clampZoom(state.zoom || 1); } function applyCanvasZoom() { if (!canvas) return; state.zoom = clampZoom(state.zoom || 1); // 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`; updateSelectionOverlayFromWorldRect(); } function displayedCellSize() { return mapCellScreenSize(); } function screenPointToMapPixel(clientX, clientY, sizeOverride = null) { const rect = canvas.getBoundingClientRect(); if (!rect.width || !rect.height) return null; const map = activeMap(); const viewWidth = Math.max(1, sizeOverride?.width || map?.width || state.viewWidth || MAP_W); const viewHeight = Math.max(1, sizeOverride?.height || map?.height || state.viewHeight || MAP_H); const canvasX = (clientX - rect.left) * ((canvas.width || MAP_W * CELL_SIZE) / rect.width); const canvasY = (clientY - rect.top) * ((canvas.height || MAP_H * CELL_SIZE) / rect.height); const cellX = canvasX / Math.max(1e-6, (canvas.width || MAP_W * CELL_SIZE) / viewWidth); const cellY = canvasY / Math.max(1e-6, (canvas.height || MAP_H * CELL_SIZE) / viewHeight); return { x: cellX * CELL_SIZE, y: cellY * CELL_SIZE }; } function mapPixelToScreenPoint(px, py) { const rect = canvas.getBoundingClientRect(); const map = activeMap(); const viewWidth = Math.max(1, map?.width || state.viewWidth || MAP_W); const viewHeight = Math.max(1, map?.height || state.viewHeight || MAP_H); const canvasX = (px / CELL_SIZE) * ((canvas.width || MAP_W * CELL_SIZE) / viewWidth); const canvasY = (py / CELL_SIZE) * ((canvas.height || MAP_H * CELL_SIZE) / viewHeight); return { x: canvas.offsetLeft + canvasX * (rect.width / Math.max(1, canvas.width || MAP_W * CELL_SIZE)), y: canvas.offsetTop + canvasY * (rect.height / Math.max(1, canvas.height || MAP_H * CELL_SIZE)), }; } function mapClientToCell(event, sizeOverride = null) { const map = activeMap(); if (!map && !sizeOverride) return null; const p = screenPointToMapPixel(event.clientX, event.clientY, sizeOverride); if (!p) return null; return { x: Math.floor(p.x / CELL_SIZE), y: Math.floor(p.y / CELL_SIZE), }; } function viewportCellToWorldCell(cell) { if (!cell || !state.camera) return null; return { x: Math.round(state.camera.x || 0) + cell.x, y: Math.round(state.camera.y || 0) + cell.y, }; } function clampCanvasPoint(event) { const rect = canvas.getBoundingClientRect(); return { x: Math.min(Math.max(event.clientX - rect.left, 0), rect.width), y: Math.min(Math.max(event.clientY - rect.top, 0), rect.height), }; } function screenPointToWorldCell(point) { const map = activeMap(); if (!map || !point) return null; const rect = canvas.getBoundingClientRect(); if (!rect.width || !rect.height) return null; const viewWidth = Math.max(1, map.width || state.viewWidth || MAP_W); const viewHeight = Math.max(1, map.height || state.viewHeight || MAP_H); const canvasX = point.x * ((canvas.width || MAP_W * CELL_SIZE) / rect.width); const canvasY = point.y * ((canvas.height || MAP_H * CELL_SIZE) / rect.height); const localX = Math.floor((canvasX / Math.max(1e-6, (canvas.width || MAP_W * CELL_SIZE) / viewWidth))); const localY = Math.floor((canvasY / Math.max(1e-6, (canvas.height || MAP_H * CELL_SIZE) / viewHeight))); const cameraX = Math.round(state.camera?.x || 0); const cameraY = Math.round(state.camera?.y || 0); return { x: cameraX + Math.min(Math.max(localX, 0), Math.max(0, map.width - 1)), y: cameraY + Math.min(Math.max(localY, 0), Math.max(0, map.height - 1)), }; } function worldCellToOverlayPoint(point) { const cameraX = Math.round(state.camera?.x || 0); const cameraY = Math.round(state.camera?.y || 0); const screen = mapPixelToScreenPoint((point.x - cameraX + 0.5) * CELL_SIZE, (point.y - cameraY + 0.5) * CELL_SIZE); return { x: screen.x - canvas.offsetLeft, y: screen.y - canvas.offsetTop }; } function simplifySelectionPath(points) { const out = []; for (const p of points || []) { if (!out.length || Math.hypot(out[out.length - 1].x - p.x, out[out.length - 1].y - p.y) >= 6) out.push(p); } return out; } function polygonArea(points) { let area = 0; for (let i = 0; i < points.length; i++) { const a = points[i]; const b = points[(i + 1) % points.length]; area += a.x * b.y - b.x * a.y; } return Math.abs(area) * 0.5; } function selectionPathToShape(points) { const simplified = simplifySelectionPath(points || []); if (simplified.length < 3) return null; const polygon = simplified.map(screenPointToWorldCell).filter(Boolean); if (polygon.length < 3) return null; const xs = polygon.map((p) => p.x); const ys = polygon.map((p) => p.y); return { kind: "lasso", polygon, x0: Math.min(...xs), y0: Math.min(...ys), x1: Math.max(...xs) + 1, y1: Math.max(...ys) + 1, areaCells: Math.max(1, Math.round(polygonArea(polygon))), }; } function drawSelectionSvg(points, invalid = false) { if (!selectionSvgEl) return; if (!points || points.length < 3) { selectionSvgEl.style.display = "none"; selectionSvgEl.innerHTML = ""; return; } const pts = points.map((p) => `${p.x},${p.y}`).join(" "); selectionSvgEl.setAttribute("viewBox", `0 0 ${canvas.clientWidth || canvas.width || 1} ${canvas.clientHeight || canvas.height || 1}`); selectionSvgEl.innerHTML = ``; selectionSvgEl.style.display = "block"; selectionSvgEl.classList.toggle("invalid", !!invalid); } function hideSelectionSvg() { if (!selectionSvgEl) return; selectionSvgEl.style.display = "none"; selectionSvgEl.innerHTML = ""; selectionSvgEl.classList.remove("invalid"); } function updateSelectionOverlay() { if (!dragState.selectPath?.length) return; const liveShape = selectionPathToShape(dragState.selectPath); const validation = validatePatchRect(liveShape, state.world); drawSelectionSvg(dragState.selectPath, !validation.ok); if (selectionEl) selectionEl.style.display = "none"; if (generatePatchButton) generatePatchButton.disabled = true; if (alternativePatchButton) alternativePatchButton.disabled = true; if (patchStatusEl) { const current = validation.rect || liveShape; patchStatusEl.textContent = validation.ok ? `Selected: ${formatRectSize(validation.rect)}. Release to confirm patch selection.` : `${validation.reason} Current: ${formatRectSize(current)}.`; patchStatusEl.classList.toggle("invalid", !validation.ok); } } function updateSelectionOverlayFromWorldRect() { if (!state.selectionRect || !state.camera || !activeMap()) return; const rect = canvas.getBoundingClientRect(); if (!rect.width || !rect.height) return; if (Array.isArray(state.selectionRect.polygon) && state.selectionRect.polygon.length >= 3) { const points = state.selectionRect.polygon.map(worldCellToOverlayPoint) .map((p) => ({ x: Math.min(Math.max(p.x, 0), rect.width), y: Math.min(Math.max(p.y, 0), rect.height) })); drawSelectionSvg(points, !validatePatchRect(state.selectionRect, state.world).ok); if (selectionEl) selectionEl.style.display = "none"; return; } const cameraX = Math.round(state.camera.x || 0); const cameraY = Math.round(state.camera.y || 0); const p0 = mapPixelToScreenPoint((state.selectionRect.x0 - cameraX) * CELL_SIZE, (state.selectionRect.y0 - cameraY) * CELL_SIZE); const p1 = mapPixelToScreenPoint((state.selectionRect.x1 - cameraX) * CELL_SIZE, (state.selectionRect.y1 - cameraY) * CELL_SIZE); const vx0 = p0.x - canvas.offsetLeft; const vy0 = p0.y - canvas.offsetTop; const vx1 = p1.x - canvas.offsetLeft; const vy1 = p1.y - canvas.offsetTop; const x0 = Math.min(Math.max(Math.min(vx0, vx1), 0), rect.width); const y0 = Math.min(Math.max(Math.min(vy0, vy1), 0), rect.height); const x1 = Math.min(Math.max(Math.max(vx0, vx1), 0), rect.width); const y1 = Math.min(Math.max(Math.max(vy0, vy1), 0), rect.height); if (x1 - x0 < 1 || y1 - y0 < 1) { selectionEl.style.display = "none"; return; } hideSelectionSvg(); selectionEl.style.display = "block"; selectionEl.style.left = `${canvas.offsetLeft + x0}px`; selectionEl.style.top = `${canvas.offsetTop + y0}px`; selectionEl.style.width = `${Math.max(1, x1 - x0)}px`; selectionEl.style.height = `${Math.max(1, y1 - y0)}px`; const validation = validatePatchRect(state.selectionRect, state.world); selectionEl.classList.toggle("invalid", !validation.ok); } function formatRectSize(rect) { if (!rect) return "-"; const w = Math.max(0, rect.x1 - rect.x0); const h = Math.max(0, rect.y1 - rect.y0); const area = Math.max(0, rect.areaCells || (w * h)); return `${w} x ${h} cells / ${area.toLocaleString()} cells`; } function normalizePatchVariant(value) { const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) ? Math.max(0, parsed) >>> 0 : 0; } function setPatchVariant(value, { update = true } = {}) { state.patchVariant = normalizePatchVariant(value); if (patchVariantInput && patchVariantInput.value !== String(state.patchVariant)) { patchVariantInput.value = String(state.patchVariant); } if (update) updatePatchControls(); return state.patchVariant; } function readPatchVariant() { return setPatchVariant(patchVariantInput?.value ?? state.patchVariant, { update: false }); } function resetPatchVariant({ update = true } = {}) { return setPatchVariant(0, { update }); } function updatePatchControls() { 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; 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.classList.toggle("invalid", false); return; } if (!validation.ok) { patchStatusEl.textContent = `${validation.reason} Current: ${formatRectSize(validation.rect || state.selectionRect)}.`; patchStatusEl.classList.toggle("invalid", true); return; } const rects = buildPatchRects(validation.rect, state.world); const patchText = state.lastPatchResult ? ` Last patch: ${state.lastPatchResult.label}, variant ${state.lastPatchResult.variant ?? "-"}, mode ${state.lastPatchResult.patchGenerationMode || "legacy-full-pipeline"}, terrain ${state.lastPatchResult.updatedCells.toLocaleString()} cells, coast ${state.lastPatchResult.coastCellsChanged || 0}, natural ${state.lastPatchResult.naturalRegionsUpdated || 0}${state.lastPatchResult.humanGeography?.ok ? `, connectors ${(state.lastPatchResult.humanGeography.roadConnectorsCreated || 0) + (state.lastPatchResult.humanGeography.railwayConnectorsCreated || 0)}, admin ${state.lastPatchResult.humanGeography.adminCellsReassigned || 0}, invalid ports ${state.lastPatchResult.humanGeography.invalidPortsRemoved || 0}` : ""}.` : ""; patchStatusEl.textContent = `Variant: ${variant}. Core: ${formatRectSize(rects.coreRect)}. Write: ${formatRectSize(rects.writeRect)}. Context: ${formatRectSize(rects.contextRect)}. Repair: ${formatRectSize(rects.repairRect)}.${patchText}`; patchStatusEl.classList.toggle("invalid", false); } function clearDragMode() { dragState.mode = null; dragState.pointerId = null; dragState.pendingCamera = null; if (dragState.panRaf != null) { cancelAnimationFrame(dragState.panRaf); dragState.panRaf = null; } canvasShell?.classList.remove("panning", "selecting"); } function schedulePanRedraw(camera) { dragState.pendingCamera = camera; if (dragState.panRaf != null) return; dragState.panRaf = requestAnimationFrame(() => { dragState.panRaf = null; if (!dragState.pendingCamera) return; const next = dragState.pendingCamera; dragState.pendingCamera = null; if (next.x === state.camera.x && next.y === state.camera.y) return; state.camera = next; redraw({ fastTerrain: true }); }); } function hideSelectionOverlay() { dragState.selectStart = null; dragState.selectEnd = null; dragState.selectPath = null; state.selectionRect = null; resetPatchVariant({ update: false }); hideSelectionSvg(); if (selectionEl) selectionEl.style.display = "none"; updatePatchControls(); } function selectionPixelsToCells(start, end) { const a = screenPointToWorldCell(start); const b = screenPointToWorldCell(end); if (!a || !b) return null; return { x0: Math.min(a.x, b.x), y0: Math.min(a.y, b.y), x1: Math.max(a.x, b.x) + 1, y1: Math.max(a.y, b.y) + 1, }; } function selectionPixelsToShape(start, end, path = null) { if (Array.isArray(path) && path.length >= 3) return selectionPathToShape(path); return selectionPixelsToCells(start, end); } function handleMapPointerDown(event) { if (!state.world || !canvasShell) return; if (event.button !== 0 && event.button !== 2) return; dragState.pointerId = event.pointerId; dragState.startClientX = event.clientX; dragState.startClientY = event.clientY; dragState.startCameraX = state.camera.x; dragState.startCameraY = state.camera.y; tooltipEl?.classList.remove("visible"); if (event.button === 0) { dragState.mode = "pan"; canvasShell.classList.add("panning"); } else { dragState.mode = "select"; dragState.selectStart = clampCanvasPoint(event); dragState.selectEnd = dragState.selectStart; dragState.selectPath = [dragState.selectStart]; canvasShell.classList.add("selecting"); updateSelectionOverlay(); } canvas.setPointerCapture?.(event.pointerId); event.preventDefault(); } function handleMapPointerMove(event) { if (!dragState.mode || dragState.pointerId !== event.pointerId || !canvasShell) return; tooltipEl?.classList.remove("visible"); if (dragState.mode === "pan") { const cellSize = Math.max(1, displayedCellSize()); const dxCells = Math.round((event.clientX - dragState.startClientX) / cellSize); const dyCells = Math.round((event.clientY - dragState.startClientY) / cellSize); const nextCamera = clampCameraForView({ x: dragState.startCameraX - dxCells, y: dragState.startCameraY - dyCells, }, viewportSizeForZoom(state.zoom)); schedulePanRedraw(nextCamera); } else if (dragState.mode === "select") { dragState.selectEnd = clampCanvasPoint(event); if (!dragState.selectPath || Math.hypot(dragState.selectEnd.x - dragState.selectPath[dragState.selectPath.length - 1].x, dragState.selectEnd.y - dragState.selectPath[dragState.selectPath.length - 1].y) >= 3) { dragState.selectPath = [...(dragState.selectPath || []), dragState.selectEnd]; } updateSelectionOverlay(); } event.preventDefault(); } function handleMapPointerUp(event) { if (dragState.pointerId !== event.pointerId) return; const wasPanning = dragState.mode === "pan"; if (dragState.mode === "select") { dragState.selectEnd = clampCanvasPoint(event); if (!dragState.selectPath || dragState.selectPath.length < 2) dragState.selectPath = [dragState.selectStart, dragState.selectEnd]; else dragState.selectPath = [...dragState.selectPath, dragState.selectEnd]; const shape = selectionPixelsToShape(dragState.selectStart, dragState.selectEnd, dragState.selectPath); const width = Math.abs(dragState.selectEnd.x - dragState.selectStart.x); const height = Math.abs(dragState.selectEnd.y - dragState.selectStart.y); if (shape && (dragState.selectPath.length >= 3 || (width >= 4 && height >= 4))) { state.selectionRect = shape; state.lastPatchResult = null; resetPatchVariant({ update: false }); updateSelectionOverlayFromWorldRect(); updatePatchControls(); } else { hideSelectionOverlay(); } } canvas.releasePointerCapture?.(event.pointerId); if (wasPanning && dragState.pendingCamera) { state.camera = dragState.pendingCamera; dragState.pendingCamera = null; } clearDragMode(); if (wasPanning) redraw({ fastTerrain: false }); event.preventDefault(); } function parseSeed(seedText) { const numeric = Number.parseInt(seedText, 10); if (Number.isFinite(numeric)) return numeric >>> 0; let hash = 2166136261; for (const ch of seedText) hash = Math.imul(hash ^ ch.charCodeAt(0), 16777619); return hash >>> 0; } function insideCount(items) { return items.filter((item) => item.insidePrefecture).length; } function outsideCount(items) { return items.length - insideCount(items); } function countText(items) { return `${insideCount(items)} / outside ${outsideCount(items)}`; } function formatMs(ms) { if (!Number.isFinite(ms)) return "-"; return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${Math.round(ms)}ms`; } function renderTimingRows(timings = []) { if (!progressTimingsEl) return; progressTimingsEl.innerHTML = ""; for (const row of timings) { const item = document.createElement("div"); item.className = "progress-timing-row"; const label = document.createElement("span"); label.textContent = row.label; const value = document.createElement("strong"); value.textContent = formatMs(row.ms); item.append(label, value); progressTimingsEl.append(item); } } function updateGenerationProgress(event) { if (!progressEl) return; progressEl.classList.remove("hidden"); if (event?.status === "start") generationCurrentStage = event.label || "Preparing"; if (progressStageEl) { const elapsed = generationStartedAt ? ` / elapsed ${formatMs(performance.now() - generationStartedAt)}` : ""; progressStageEl.textContent = event?.status === "done" ? `Completed: ${event.label} / ${formatMs(event.ms)}${elapsed}` : `Running: ${event?.label || generationCurrentStage || "Preparing"}${elapsed}`; } renderTimingRows(event?.timings || []); } function setProgressVisible(visible, message = "Preparing") { if (!progressEl) return; progressEl.classList.toggle("hidden", !visible); if (visible) { generationStartedAt = performance.now(); generationCurrentStage = message; if (generationTimer) window.clearInterval(generationTimer); generationTimer = window.setInterval(() => { if (progressStageEl && !progressEl.classList.contains("hidden")) { progressStageEl.textContent = `Running: ${generationCurrentStage || "Preparing"} / elapsed ${formatMs(performance.now() - generationStartedAt)}`; } }, 100); } else if (generationTimer) { window.clearInterval(generationTimer); generationTimer = null; } if (progressStageEl) progressStageEl.textContent = message; if (visible) renderTimingRows([]); } function nextFrame() { return new Promise((resolve) => requestAnimationFrame(() => resolve())); } function getStats(map) { 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 ?? "-"], ]; } function renderStats(map) { statsEl.innerHTML = ""; for (const [labelText, valueText] of getStats(map)) { const row = document.createElement("div"); row.className = "stat-row"; const label = document.createElement("span"); label.textContent = labelText; const value = document.createElement("strong"); value.textContent = String(valueText); row.append(label, value); statsEl.append(row); } } function buildHoverEntities(map) { return [ ...(map.modernCities || []), ...(map.ports || []), ...(map.stations || []), ...(map.interchanges || []), ...(map.industrialZones || []), ...(map.logisticsParks || []), ...(map.newTowns || []), ...(map.castles || []), ...(map.markets || []), ...(map.villages || []), ...(map.adminCenters || []), ]; } function nearestEntity(items, x, y, maxDistance = 5) { let best = null; let bestD = maxDistance; for (const item of items || []) { if (!item || !Number.isFinite(item.x) || !Number.isFinite(item.y)) continue; const d = Math.hypot(item.x - x, item.y - y); if (d < bestD) { best = item; bestD = d; } } return best; } function landuseName(value) { return landuseLabel(value); } const ADMIN_ID_KEYS = ["adminId", "municipalityId", "adminNumericId", "id", "numericId"]; const PREFECTURE_ID_KEYS = ["prefectureRegionId", "prefectureId", "id", "numericId"]; const MUNICIPALITY_NAME_KEYS = ["municipalityName", "name", "canonicalSettlementName", "municipalityRootName", "generatedMunicipalityName", "label"]; const PREFECTURE_NAME_KEYS = ["prefectureName", "prefectureRegionName", "regionName", "name", "label"]; const POPULATION_KEYS = ["municipalityPopulation", "adminPopulation", "population", "estimatedPopulation"]; function numericIdOf(item, keys = ADMIN_ID_KEYS) { for (const key of keys) { const value = item?.[key]; if (Number.isFinite(value)) return Math.floor(value); } return null; } function hasNumericId(item, id, keys = ADMIN_ID_KEYS) { if (!item || id == null || id < 0) return false; return keys.some((key) => Number.isFinite(item?.[key]) && Math.floor(item[key]) === Math.floor(id)); } function firstUsableText(item, keys) { for (const key of keys) { const value = item?.[key]; if (!looksNumericName(value)) return String(value).trim(); } return ""; } function firstPopulation(item) { for (const key of POPULATION_KEYS) { const value = item?.[key]; if (Number.isFinite(value) && value > 0) return Math.round(value); } return null; } function adminCenterForId(map, adminId) { if (!map || adminId == null || adminId < 0) return null; const centers = (map.adminCenters || []).filter(Boolean); const exact = centers.find((center) => hasNumericId(center, adminId)); if (exact) return exact; // Some legacy/admin debug arrays were once addressed by array index. Keep this // only as a guarded fallback so numeric IDs are not mistaken for indexes. const direct = map.adminCenters?.[adminId]; return hasNumericId(direct, adminId) ? direct : null; } function looksNumericName(name) { if (!name) return true; const text = String(name).trim(); return !text || /^-?\d+(?:\s*[,,]\s*\d+)*$/u.test(text) || /^(Municipality|Prefecture|Admin|Region)\s*-?\d+/i.test(text); } function nearestNamedAdminCenter(map, cellIndex, maxDistance = 36, adminId = null) { if (!map || cellIndex < 0) return null; const x = cellIndex % map.width; const y = Math.floor(cellIndex / map.width); let best = null; let bestD = maxDistance; for (const center of map.adminCenters || []) { if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y)) continue; if (adminId != null && adminId >= 0 && !hasNumericId(center, adminId)) continue; if (!firstUsableText(center, MUNICIPALITY_NAME_KEYS)) continue; const d = Math.hypot(center.x - x, center.y - y); if (d < bestD) { best = center; bestD = d; } } if (!best && adminId != null && adminId >= 0) return nearestNamedAdminCenter(map, cellIndex, maxDistance, null); return best; } function adminName(map, adminId, cellIndex = -1) { const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex, 54, adminId); const name = firstUsableText(center, MUNICIPALITY_NAME_KEYS); if (name) return name; return adminId >= 0 ? "Unnamed municipality" : "-"; } function adminPopulation(map, adminId, cellIndex = -1) { const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex, 54, adminId); const centerPop = firstPopulation(center); if (centerPop !== null) return centerPop; let sum = 0; let found = false; for (const key of ["modernCities", "satelliteCities", "ports", "markets", "villages"]) { for (const p of map?.[key] || []) { if (!hasNumericId(p, adminId)) continue; const pop = firstPopulation(p); if (pop !== null) { sum += pop; found = true; } } } return found ? sum : null; } function prefectureNameForCell(map, i) { const id = map.prefectureRegionId?.[i] ?? -1; const region = (map.prefectureRegions || []).find((p) => hasNumericId(p, id, PREFECTURE_ID_KEYS)); const regionName = firstUsableText(region, PREFECTURE_NAME_KEYS); if (regionName) return regionName; const center = nearestNamedAdminCenter(map, i, 90); const fromCenter = firstUsableText(center, ["prefectureName", "prefectureRegionName", "regionName"]); return fromCenter || (id >= 0 ? "Unnamed prefecture" : "-"); } function updateTooltip(event) { const map = activeMap(); if (!map || !tooltipEl || dragState.mode) return; const rect = canvas.getBoundingClientRect(); const cell = mapClientToCell(event); if (!cell) return; const { x, y } = cell; if (x < 0 || y < 0 || x >= map.width || y >= map.height) { tooltipEl.classList.remove("visible"); return; } const i = y * map.width + x; const worldCell = viewportCellToWorldCell({ x, y }); const entity = nearestEntity(state.hoverEntities, x, y); const elevation = map.elevation?.[i] ?? 0; const density = map.populationDensity?.[i] ?? map.settlementScore?.[i] ?? 0; const hoveredAdminId = map.adminId?.[i] ?? -1; const hoveredAdminPopulation = adminPopulation(map, hoveredAdminId, i); const coordinateText = worldCell ? `World ${worldCell.x}, ${worldCell.y} / View ${x}, ${y}` : `${x}, ${y}`; const entityName = firstUsableText(entity, ["name", "facilityLabel", "municipalityName", "canonicalSettlementName", "kind"]); const entityTitle = entity ? `${entityName || adminName(map, hoveredAdminId, i) || entity.kind || "Feature"} / ${entity.kind || "Feature"}` : coordinateText; const lines = [ `${entityTitle}`, `Prefecture: ${prefectureNameForCell(map, i)}`, `Admin: ${adminName(map, hoveredAdminId, i)}`, `Admin Pop: ${hoveredAdminPopulation === null ? "-" : hoveredAdminPopulation.toLocaleString()}`, `Land: ${map.sea?.[i] ? "Sea" : landuseName(map.landuse?.[i])}`, `Elevation: ${elevation.toFixed(3)} / Slope: ${(map.slope?.[i] ?? 0).toFixed(3)}`, `River: ${(map.river?.[i] ?? 0).toFixed(2)} / Density: ${density.toFixed(2)}`, ]; if (entity?.population) lines.splice(1, 0, `Population: ${entity.population.toLocaleString()}`); tooltipEl.innerHTML = lines.join("
"); const margin = 8; const offset = 14; const maxLeft = Math.max(margin, rect.width - tooltipEl.offsetWidth - margin); const maxTop = Math.max(margin, rect.height - tooltipEl.offsetHeight - margin); const desiredLeft = event.clientX - rect.left + offset; const desiredTop = event.clientY - rect.top + offset; tooltipEl.style.left = `${Math.min(Math.max(margin, desiredLeft), maxLeft)}px`; tooltipEl.style.top = `${Math.min(Math.max(margin, desiredTop), maxTop)}px`; tooltipEl.classList.add("visible"); } function renderModeButtons() { modeGrid.innerHTML = ""; for (const [key, label] of modes) { const button = document.createElement("button"); button.type = "button"; button.textContent = label; button.className = key === state.mode ? "mode-button active" : "mode-button"; button.addEventListener("click", () => { state.mode = key; renderModeButtons(); redraw(); }); modeGrid.append(button); } } async function regenerate() { state.seedText = seedInput.value; state.generationType = generationTypeInput?.value || "auto"; setProgressVisible(true, "Preparing generation..."); await nextFrame(); try { state.map = await generateMapAsync(parseSeed(state.seedText), { onProgress: updateGenerationProgress, terrainType: state.generationType }); state.world = createWorldMap(state.map); state.camera = createInitialCamera(state.world); state.lastPatchResult = null; resetPatchVariant({ update: false }); hideSelectionOverlay(); 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}`; throw error; } } function derivePatchSeed(rect, terrainType, variant = 0) { let h = parseSeed(state.seedText) ^ 0x9e3779b9; h = Math.imul(h ^ (rect.x0 | 0), 1664525) >>> 0; h = Math.imul(h ^ (rect.y0 | 0), 1013904223) >>> 0; h = Math.imul(h ^ (rect.x1 | 0), 2246822519) >>> 0; h = Math.imul(h ^ (rect.y1 | 0), 3266489917) >>> 0; h = Math.imul(h ^ normalizePatchVariant(variant), 668265263) >>> 0; for (const ch of String(terrainType || "auto")) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0; return h >>> 0; } function handleCanvasWheel(event) { if (!state.world || !activeMap()) return; event.preventDefault(); tooltipEl?.classList.remove("visible"); const beforeSize = viewportSizeForZoom(state.zoom); const beforeCell = mapClientToCell(event, beforeSize); const beforeWorld = beforeCell ? viewportCellToWorldCell(beforeCell) : null; const oldZoom = clampZoom(state.zoom || 1); const delta = event.deltaY < 0 ? 1.10 : 1 / 1.10; const nextZoom = clampZoom(oldZoom * delta); if (Math.abs(nextZoom - oldZoom) < 0.001) return; state.zoom = nextZoom; const nextSize = syncViewportSize(); if (beforeWorld) { const afterCell = mapClientToCell(event, nextSize); if (afterCell) { state.camera = clampCameraForView({ x: beforeWorld.x - afterCell.x, y: beforeWorld.y - afterCell.y, }, nextSize); } } // Wheel events can fire dozens of times per second. Do one lightweight redraw // per frame, then a full labeled/continuous redraw once zooming settles. if (zoomRedrawRaf == null) { zoomRedrawRaf = requestAnimationFrame(() => { zoomRedrawRaf = null; redraw({ fastTerrain: true, allowWorldExpand: false }); }); } if (zoomSettledTimer != null) clearTimeout(zoomSettledTimer); zoomSettledTimer = window.setTimeout(() => { zoomSettledTimer = null; redraw({ fastTerrain: false, allowWorldExpand: false }); }, 140); } async function generateSelectedPatch() { const validation = validatePatchRect(state.selectionRect, state.world); if (!validation.ok) { updatePatchControls(); return; } const terrainType = patchTerrainTypeInput?.value || generationTypeInput?.value || "auto"; const variant = readPatchVariant(); const seed = derivePatchSeed(validation.rect, terrainType, variant); setProgressVisible(true, "Generating selected patch..."); await nextFrame(); try { const result = generatePatch(state.world, validation.rect, { terrainType, seed, variant }); if (!result.ok) { if (progressStageEl) progressStageEl.textContent = `Patch failed: ${result.reason || "invalid selection"}`; updatePatchControls(); window.setTimeout(() => setProgressVisible(false), 1200); return; } state.lastPatchResult = result; redraw(); renderStats(state.map); 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 = `Patch generated: ${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}`; renderTimingRows([]); window.setTimeout(() => setProgressVisible(false), 900); } catch (error) { if (progressStageEl) progressStageEl.textContent = `Patch failed: ${error?.message || error}`; throw error; } } async function generateAlternativePatch() { const validation = validatePatchRect(state.selectionRect, state.world); if (!validation.ok) { updatePatchControls(); return; } setPatchVariant(readPatchVariant() + 1, { update: false }); await generateSelectedPatch(); } function redraw(options = {}) { if (!state.world) return; const viewSize = syncViewportSize(); const expansion = options.allowWorldExpand === false ? null : ensureWorldPaddingForCamera(state.world, state.camera, viewSize.width, viewSize.height); if (expansion?.expanded) { state.camera = { x: (state.camera?.x || 0) + (expansion.dx || 0), y: (state.camera?.y || 0) + (expansion.dy || 0) }; if (state.selectionRect) { const dx = expansion.dx || 0; const dy = expansion.dy || 0; state.selectionRect = { ...state.selectionRect, x0: state.selectionRect.x0 + dx, y0: state.selectionRect.y0 + dy, x1: state.selectionRect.x1 + dx, y1: state.selectionRect.y1 + dy, polygon: Array.isArray(state.selectionRect.polygon) ? state.selectionRect.polygon.map((p) => ({ x: p.x + dx, y: p.y + dy })) : state.selectionRect.polygon, }; } } state.camera = clampCameraForView(state.camera, viewSize); state.viewportMap = getViewportMap(state.world, state.camera, viewSize.width, viewSize.height, { light: !!options.fastTerrain }); state.hoverEntities = options.fastTerrain ? [] : buildHoverEntities(state.viewportMap); drawMap(canvas, state.viewportMap, { mode: state.mode, showFeatures: state.showFeatures && !options.fastTerrain, showLabels: state.showLabels && !options.fastTerrain, continuousTerrain: !options.fastTerrain, fastTerrain: !!options.fastTerrain, zoom: state.zoom || 1, }); applyCanvasZoom(); if (state.selectionRect && dragState.mode !== "select") updateSelectionOverlayFromWorldRect(); } function init() { renderModeButtons(); seedInput.addEventListener("change", regenerate); seedInput.addEventListener("keydown", (event) => { if (event.key === "Enter") regenerate(); }); generationTypeInput?.addEventListener("change", regenerate); patchTerrainTypeInput?.addEventListener("change", () => { state.lastPatchResult = null; resetPatchVariant({ update: false }); updatePatchControls(); }); patchVariantInput?.addEventListener("change", () => setPatchVariant(patchVariantInput.value)); patchVariantInput?.addEventListener("keydown", (event) => { if (event.key === "Enter") { setPatchVariant(patchVariantInput.value); generateSelectedPatch(); } }); generatePatchButton?.addEventListener("click", generateSelectedPatch); alternativePatchButton?.addEventListener("click", generateAlternativePatch); randomSeedButton.addEventListener("click", () => { seedInput.value = String(Math.floor(Math.random() * 9999999)); regenerate(); }); showFeaturesInput.addEventListener("change", () => { state.showFeatures = showFeaturesInput.checked; redraw(); }); showLabelsInput.addEventListener("change", () => { state.showLabels = showLabelsInput.checked; redraw(); }); canvasShell?.setAttribute("tabindex", "0"); canvas.addEventListener("contextmenu", (event) => event.preventDefault()); canvas.addEventListener("wheel", handleCanvasWheel, { passive: false }); canvas.addEventListener("pointerdown", handleMapPointerDown); canvas.addEventListener("pointermove", handleMapPointerMove); canvas.addEventListener("pointerup", handleMapPointerUp); canvas.addEventListener("pointercancel", handleMapPointerUp); canvas.addEventListener("mousemove", updateTooltip); canvas.addEventListener("mouseleave", () => { tooltipEl?.classList.remove("visible"); }); updatePatchControls(); regenerate(); } init();