diff --git a/adminRegionsCore.js b/adminRegionsCore.js index 04f902c..2dd8ebc 100644 --- a/adminRegionsCore.js +++ b/adminRegionsCore.js @@ -302,6 +302,101 @@ export function removeMunicipalExclaves(adminId, prefectureMask, sea, adminCente } } + +export function enforceMunicipalityConnectivityStrict(adminId, prefectureMask, sea, adminCenters = [], protectedPoints = [], maxPasses = 6) { + // Final cell-level invariant: each municipality should be one contiguous land + // component. Earlier stages are allowed to leave sizeable satellite pieces + // while boundaries are still being snapped; this pass removes the remaining + // visual exclaves by attaching every non-primary component to the neighboring + // municipality with the largest shared boundary. A component that contains a + // protected point may become the primary component, but it no longer protects + // additional detached pieces. + const protectedByAdmin = new Map(); + for (const p of [...(adminCenters || []), ...(protectedPoints || [])]) { + if (!p || !inside(p.x, p.y)) continue; + const i = indexOf(Math.round(p.x), Math.round(p.y)); + const id = adminId[i]; + if (id < 0) continue; + if (!protectedByAdmin.has(id)) protectedByAdmin.set(id, new Set()); + protectedByAdmin.get(id).add(i); + } + + let changed = 0; + const queue = []; + for (let pass = 0; pass < maxPasses; pass++) { + let passChanged = 0; + const ids = new Set(); + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && adminId[i] >= 0) ids.add(adminId[i]); + for (const id of ids) { + const seen = new Uint8Array(SIZE); + const components = []; + for (let i = 0; i < SIZE; i++) { + if (seen[i] || adminId[i] !== id || !prefectureMask[i] || sea[i]) continue; + const comp = []; + let protectedHits = 0; + queue.length = 0; + queue.push(i); + seen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + comp.push(cur); + if (protectedByAdmin.get(id)?.has(cur)) protectedHits++; + const [x, y] = xyOf(cur); + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (seen[ni] || adminId[ni] !== id || !prefectureMask[ni] || sea[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + components.push({ cells: comp, protectedHits }); + } + if (components.length <= 1) continue; + components.sort((a, b) => + (b.protectedHits ? 1_000_000 : 0) + b.cells.length - + ((a.protectedHits ? 1_000_000 : 0) + a.cells.length) + ); + const primary = components[0]; + for (const component of components.slice(1)) { + const counts = new Map(); + for (const ci of component.cells) { + const [x, y] = xyOf(ci); + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + const other = adminId[ni]; + if (other >= 0 && other !== id) counts.set(other, (counts.get(other) || 0) + 1); + } + } + let target = -1; + let best = -1; + for (const [other, count] of counts) { + const bonus = protectedByAdmin.get(other)?.size ? 0.25 : 0; + const score = count + bonus; + if (score > best || (score === best && other < target)) { best = score; target = other; } + } + if (target < 0) { + // Very rare: a detached island component has no labeled neighbor. + // Keep the largest/protected primary and merge the component into it + // only if it is directly adjacent after previous changes; otherwise + // leave it for the next pass rather than inventing over-sea ownership. + target = id; + } + if (target >= 0 && target !== id) { + for (const ci of component.cells) adminId[ci] = target; + passChanged += component.cells.length; + } else if (component !== primary) { + // If no external target exists, still mark it as handled by keeping it; + // another pass may expose a target after surrounding cells change. + } + } + } + changed += passChanged; + if (!passChanged) break; + } + return changed; +} + export function terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse) { const urbanPenalty = urbanBoundaryPenalty(i, populationDensity, landuse); const majorRiver = clamp(Math.max(river[i] - 0.32, 0) * 1.9 + Math.max(flowAccum[i] - 0.38, 0) * 0.75); diff --git a/app.js b/app.js index b9bdc54..6eab3ad 100644 --- a/app.js +++ b/app.js @@ -1,6 +1,10 @@ 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"], @@ -16,89 +20,487 @@ const modes = [ 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 panState = { keys: new Set(), raf: null, lastTime: 0, speedPxPerSecond: 520 }; +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 mapClientToCell(event) { - if (!state.map) return null; - const rect = canvas.getBoundingClientRect(); - if (!rect.width || !rect.height) return null; - const relX = (event.clientX - rect.left) / rect.width; - const relY = (event.clientY - rect.top) / rect.height; +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 { - x: Math.floor(relX * state.map.width), - y: Math.floor(relY * state.map.height), + width: Math.max(1, Math.ceil(MAP_W / z)), + height: Math.max(1, Math.ceil(MAP_H / z)), }; } -function isEditableTarget(target) { - if (!target) return false; - const tag = target.tagName?.toLowerCase?.(); - return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable; +function clampCameraForView(camera, size = viewportSizeForZoom(state.zoom)) { + return clampCameraToWorld(camera, state.world, size?.width || MAP_W, size?.height || MAP_H); } -function panFrame(time) { - if (!canvasShell || panState.keys.size === 0) { - panState.raf = null; - panState.lastTime = 0; +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 dt = panState.lastTime ? Math.min(0.05, (time - panState.lastTime) / 1000) : 0; - panState.lastTime = time; - let dx = 0; - let dy = 0; - if (panState.keys.has("a")) dx -= 1; - if (panState.keys.has("d")) dx += 1; - if (panState.keys.has("w")) dy -= 1; - if (panState.keys.has("s")) dy += 1; - if (dx || dy) { - const normalizer = dx && dy ? Math.SQRT1_2 : 1; - const amount = panState.speedPxPerSecond * dt; - canvasShell.scrollLeft += dx * normalizer * amount; - canvasShell.scrollTop += dy * normalizer * amount; - tooltipEl?.classList.remove("visible"); + 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); } - panState.raf = requestAnimationFrame(panFrame); } -function startKeyboardPan() { - if (panState.raf == null) panState.raf = requestAnimationFrame(panFrame); +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 handlePanKeyDown(event) { - const key = event.key?.toLowerCase?.(); - if (!key || !"wasd".includes(key) || isEditableTarget(event.target)) return; - panState.keys.add(key); - startKeyboardPan(); +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 handlePanKeyUp(event) { - const key = event.key?.toLowerCase?.(); - if (!key || !"wasd".includes(key)) return; - panState.keys.delete(key); +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(); } @@ -253,7 +655,8 @@ function buildHoverEntities(map) { function nearestEntity(items, x, y, maxDistance = 5) { let best = null; let bestD = maxDistance; - for (const item of items) { + 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; } } @@ -264,46 +667,140 @@ function landuseName(value) { return landuseLabel(value); } -function adminName(map, adminId) { - const center = (map.adminCenters || [])[adminId]; - return center?.name || (adminId >= 0 ? `Municipality ${adminId + 1}` : "-"); +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 adminPopulation(map, adminId) { - const center = (map.adminCenters || [])[adminId]; - return Number.isFinite(center?.municipalityPopulation) ? center.municipalityPopulation : 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; } + } + 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) => p.id === id); - return region?.name || (id >= 0 ? `Prefecture ${id + 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 adminId = map.adminId?.[i] ?? -1; + const mappedPref = adminId >= 0 ? map.municipalityToPrefectureId?.[adminId] ?? -1 : -1; + const center = mappedPref === id ? nearestNamedAdminCenter(map, i, 90, adminId) : null; + const fromCenter = firstUsableText(center, ["prefectureName", "prefectureRegionName", "regionName"]); + return fromCenter || (id >= 0 ? "Unnamed prefecture" : "-"); } function updateTooltip(event) { - if (!state.map || !tooltipEl) return; + 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 >= state.map.width || y >= state.map.height) { + if (x < 0 || y < 0 || x >= map.width || y >= map.height) { tooltipEl.classList.remove("visible"); return; } - const i = y * state.map.width + x; + const i = y * map.width + x; + const worldCell = viewportCellToWorldCell({ x, y }); const entity = nearestEntity(state.hoverEntities, x, y); - const elevation = state.map.elevation?.[i] ?? 0; - const density = state.map.populationDensity?.[i] ?? 0; - const hoveredAdminId = state.map.adminId?.[i] ?? -1; - const hoveredAdminPopulation = adminPopulation(state.map, hoveredAdminId); + 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 = [ - `${entity ? `${entity.name} / ${entity.kind}` : `${x}, ${y}`}`, - `Prefecture: ${prefectureNameForCell(state.map, i)}`, - `Admin: ${adminName(state.map, hoveredAdminId)}`, + `${entityTitle}`, + `Prefecture: ${prefectureNameForCell(map, i)}`, + `Admin: ${adminName(map, hoveredAdminId, i)}`, `Admin Pop: ${hoveredAdminPopulation === null ? "-" : hoveredAdminPopulation.toLocaleString()}`, - `Land: ${state.map.sea?.[i] ? "Sea" : landuseName(state.map.landuse?.[i])}`, - `Elevation: ${elevation.toFixed(3)} / Slope: ${(state.map.slope?.[i] ?? 0).toFixed(3)}`, - `River: ${(state.map.river?.[i] ?? 0).toFixed(2)} / Density: ${density.toFixed(2)}`, + `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("
"); @@ -336,11 +833,16 @@ function renderModeButtons() { 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 }); - state.hoverEntities = buildHoverEntities(state.map); + 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)}`; @@ -352,13 +854,133 @@ async function regenerate() { } } -function redraw() { - if (!state.map) return; - drawMap(canvas, state.map, { + +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(result.patchTimings || []); + 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, - showLabels: state.showLabels, + 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() { @@ -369,6 +991,22 @@ function init() { 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(); @@ -385,13 +1023,18 @@ function init() { }); canvasShell?.setAttribute("tabindex", "0"); - window.addEventListener("keydown", handlePanKeyDown); - window.addEventListener("keyup", handlePanKeyUp); + 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(); } diff --git a/index.html b/index.html index 1179b32..a0b500d 100644 --- a/index.html +++ b/index.html @@ -13,12 +13,14 @@

Prefecture Map Generator v17

-

Terrain, municipalities, transport, land use, and hover inspection in one generated map.

+

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

+ +