diff --git a/app.js b/app.js index 0380924..d989f9c 100644 --- a/app.js +++ b/app.js @@ -28,6 +28,8 @@ const state = { world: null, camera: { x: 0, y: 0 }, viewportMap: null, + viewWidth: MAP_W, + viewHeight: MAP_H, hoverEntities: [], selectionRect: null, patchVariant: 0, @@ -50,6 +52,7 @@ 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"); @@ -57,6 +60,8 @@ const progressTimingsEl = document.getElementById("generationProgressTimings"); let generationStartedAt = 0; let generationCurrentStage = ""; let generationTimer = null; +let zoomRedrawRaf = null; +let zoomSettledTimer = null; const dragState = { mode: null, @@ -67,6 +72,7 @@ const dragState = { startCameraY: 0, selectStart: null, selectEnd: null, + selectPath: null, pendingCamera: null, panRaf: null, }; @@ -81,19 +87,31 @@ function clampZoom(value) { return Math.min(Math.max(parsed, 0.55), 2.8); } -function zoomTransform() { - const zoom = clampZoom(state.zoom || 1); - const width = canvas.width || MAP_W * CELL_SIZE; - const height = canvas.height || MAP_H * CELL_SIZE; +function viewportSizeForZoom(zoom = state.zoom) { + const z = clampZoom(zoom || 1); return { - zoom, - width, - height, - tx: width * (1 - zoom) * 0.5, - ty: height * (1 - zoom) * 0.5, + 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); @@ -105,38 +123,39 @@ function applyCanvasZoom() { } function displayedCellSize() { - return CELL_SIZE * clampZoom(state.zoom || 1); + return mapCellScreenSize(); } -function screenPointToMapPixel(clientX, clientY) { +function screenPointToMapPixel(clientX, clientY, sizeOverride = null) { const rect = canvas.getBoundingClientRect(); if (!rect.width || !rect.height) return null; - const t = zoomTransform(); - const scaleX = t.width / rect.width; - const scaleY = t.height / rect.height; - const canvasX = (clientX - rect.left) * scaleX; - const canvasY = (clientY - rect.top) * scaleY; - return { - x: (canvasX - t.tx) / t.zoom, - y: (canvasY - t.ty) / t.zoom, - }; + 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 t = zoomTransform(); - const canvasX = t.tx + px * t.zoom; - const canvasY = t.ty + py * t.zoom; + 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, t.width)), - y: canvas.offsetTop + canvasY * (rect.height / Math.max(1, t.height)), + 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) { +function mapClientToCell(event, sizeOverride = null) { const map = activeMap(); - if (!map) return null; - const p = screenPointToMapPixel(event.clientX, event.clientY); + 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), @@ -160,24 +179,99 @@ function clampCanvasPoint(event) { }; } +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 (!selectionEl || !dragState.selectStart || !dragState.selectEnd) return; - const x0 = Math.min(dragState.selectStart.x, dragState.selectEnd.x); - const y0 = Math.min(dragState.selectStart.y, dragState.selectEnd.y); - const x1 = Math.max(dragState.selectStart.x, dragState.selectEnd.x); - const y1 = Math.max(dragState.selectStart.y, dragState.selectEnd.y); - 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 liveRect = selectionPixelsToCells(dragState.selectStart, dragState.selectEnd); - const validation = validatePatchRect(liveRect, state.world); - selectionEl.classList.toggle("invalid", !validation.ok); + 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 || liveRect; + const current = validation.rect || liveShape; patchStatusEl.textContent = validation.ok ? `Selected: ${formatRectSize(validation.rect)}. Release to confirm patch selection.` : `${validation.reason} Current: ${formatRectSize(current)}.`; @@ -186,9 +280,16 @@ function updateSelectionOverlay() { } function updateSelectionOverlayFromWorldRect() { - if (!selectionEl || !state.selectionRect || !state.camera || !activeMap()) return; + 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); @@ -205,6 +306,7 @@ function updateSelectionOverlayFromWorldRect() { selectionEl.style.display = "none"; return; } + hideSelectionSvg(); selectionEl.style.display = "block"; selectionEl.style.left = `${canvas.offsetLeft + x0}px`; selectionEl.style.top = `${canvas.offsetTop + y0}px`; @@ -218,7 +320,8 @@ function formatRectSize(rect) { if (!rect) return "-"; const w = Math.max(0, rect.x1 - rect.x0); const h = Math.max(0, rect.y1 - rect.y0); - return `${w} x ${h} cells / ${(w * h).toLocaleString()} cells`; + const area = Math.max(0, rect.areaCells || (w * h)); + return `${w} x ${h} cells / ${area.toLocaleString()} cells`; } function normalizePatchVariant(value) { @@ -251,7 +354,7 @@ function updatePatchControls() { if (alternativePatchButton) alternativePatchButton.disabled = !validation.ok; if (!patchStatusEl) return; if (!state.selectionRect) { - patchStatusEl.textContent = `Right-drag an area. Minimum: ${PATCH_MIN_WIDTH} x ${PATCH_MIN_HEIGHT} cells and ${PATCH_MIN_AREA.toLocaleString()} cells. Current variant: ${variant}.`; + 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; } @@ -262,7 +365,7 @@ function updatePatchControls() { } 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}, idmap ${state.lastPatchResult.humanGeography.continuityIdMappings || 0}/${state.lastPatchResult.humanGeography.continuityIdMappedCells || 0}, invalid ports ${state.lastPatchResult.humanGeography.invalidPortsRemoved || 0}` : ""}.` + ? ` 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); @@ -296,39 +399,31 @@ function schedulePanRedraw(camera) { 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 map = activeMap(); - if (!map || !start || !end) return null; - const rect = canvas.getBoundingClientRect(); - if (!rect.width || !rect.height) return null; - const toMapPixel = (p) => { - const t = zoomTransform(); - const canvasX = p.x * (t.width / rect.width); - const canvasY = p.y * (t.height / rect.height); - return { x: (canvasX - t.tx) / t.zoom, y: (canvasY - t.ty) / t.zoom }; - }; - const a = toMapPixel(start); - const b = toMapPixel(end); - const localX0 = Math.floor(Math.min(a.x, b.x) / CELL_SIZE); - const localY0 = Math.floor(Math.min(a.y, b.y) / CELL_SIZE); - const localX1 = Math.ceil(Math.max(a.x, b.x) / CELL_SIZE); - const localY1 = Math.ceil(Math.max(a.y, b.y) / CELL_SIZE); - const cameraX = Math.round(state.camera?.x || 0); - const cameraY = Math.round(state.camera?.y || 0); + const a = screenPointToWorldCell(start); + const b = screenPointToWorldCell(end); + if (!a || !b) return null; return { - x0: cameraX + Math.min(Math.max(localX0, 0), map.width - 1), - y0: cameraY + Math.min(Math.max(localY0, 0), map.height - 1), - x1: cameraX + Math.min(Math.max(localX1, 1), map.width), - y1: cameraY + Math.min(Math.max(localY1, 1), map.height), + 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; @@ -346,6 +441,7 @@ function handleMapPointerDown(event) { dragState.mode = "select"; dragState.selectStart = clampCanvasPoint(event); dragState.selectEnd = dragState.selectStart; + dragState.selectPath = [dragState.selectStart]; canvasShell.classList.add("selecting"); updateSelectionOverlay(); } @@ -362,13 +458,16 @@ function handleMapPointerMove(event) { 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 = clampCameraToWorld({ + const nextCamera = clampCameraForView({ x: dragState.startCameraX - dxCells, y: dragState.startCameraY - dyCells, - }, state.world, MAP_W, MAP_H); + }, 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(); } @@ -380,10 +479,13 @@ function handleMapPointerUp(event) { 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 (width >= 4 && height >= 4) { - state.selectionRect = selectionPixelsToCells(dragState.selectStart, dragState.selectEnd); + if (shape && (dragState.selectPath.length >= 3 || (width >= 4 && height >= 4))) { + state.selectionRect = shape; state.lastPatchResult = null; resetPatchVariant({ update: false }); updateSelectionOverlayFromWorldRect(); @@ -553,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; } } @@ -564,61 +667,106 @@ function landuseName(value) { return landuseLabel(value); } -function numericIdOf(item) { - for (const key of ["adminId", "municipalityId", "id", "adminNumericId"]) { +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 value; + 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 || []; - const direct = centers[adminId]; - if (direct && [direct.adminId, direct.municipalityId, direct.id, direct.adminNumericId].some((v) => v === adminId)) return direct; - return centers.find((center) => [center?.adminId, center?.municipalityId, center?.id, center?.adminNumericId].some((v) => v === adminId)) || 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)\s+-?\d+/i.test(text); + return !text || /^-?\d+(?:\s*[,,]\s*\d+)*$/u.test(text) || /^(Municipality|Prefecture|Admin|Region)\s*-?\d+/i.test(text); } -function nearestNamedAdminCenter(map, cellIndex, maxDistance = 36) { +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 || []) { - const name = center?.name || center?.municipalityName || center?.canonicalSettlementName || center?.municipalityRootName || center?.generatedMunicipalityName; - if (looksNumericName(name) || !Number.isFinite(center?.x) || !Number.isFinite(center?.y)) continue; + 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); - const name = center?.municipalityName || center?.name || center?.canonicalSettlementName || center?.municipalityRootName || center?.generatedMunicipalityName; - if (!looksNumericName(name)) return name; + 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); - return Number.isFinite(center?.municipalityPopulation) ? center.municipalityPopulation : null; + 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); - if (region?.name && !looksNumericName(region.name)) return region.name; - const center = nearestNamedAdminCenter(map, i, 80); - return center?.prefectureName || center?.prefectureRegionName || (id >= 0 ? "Unnamed prefecture" : "-"); + 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) { @@ -640,8 +788,9 @@ function updateTooltip(event) { 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 - ? `${entity.name || entity.facilityLabel || entity.kind || "Feature"} / ${entity.kind || "Feature"}` + ? `${entityName || adminName(map, hoveredAdminId, i) || entity.kind || "Feature"} / ${entity.kind || "Feature"}` : coordinateText; const lines = [ `${entityTitle}`, @@ -721,23 +870,38 @@ function handleCanvasWheel(event) { if (!state.world || !activeMap()) return; event.preventDefault(); tooltipEl?.classList.remove("visible"); - const beforeCell = mapClientToCell(event); + 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.12 : 1 / 1.12; + 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); + const afterCell = mapClientToCell(event, nextSize); if (afterCell) { - state.camera = clampCameraToWorld({ + state.camera = clampCameraForView({ x: beforeWorld.x - afterCell.x, y: beforeWorld.y - afterCell.y, - }, state.world, MAP_W, MAP_H); + }, nextSize); } } - redraw({ fastTerrain: false }); + + // 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() { @@ -786,26 +950,32 @@ async function generateAlternativePatch() { function redraw(options = {}) { if (!state.world) return; - const expansion = ensureWorldPaddingForCamera(state.world, state.camera, MAP_W, MAP_H); + 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 = { - x0: state.selectionRect.x0 + (expansion.dx || 0), - y0: state.selectionRect.y0 + (expansion.dy || 0), - x1: state.selectionRect.x1 + (expansion.dx || 0), - y1: state.selectionRect.y1 + (expansion.dy || 0), + ...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 = clampCameraToWorld(state.camera, state.world, MAP_W, MAP_H); - state.viewportMap = getViewportMap(state.world, state.camera, MAP_W, MAP_H); - state.hoverEntities = buildHoverEntities(state.viewportMap); + 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, + showFeatures: state.showFeatures && !options.fastTerrain, showLabels: state.showLabels && !options.fastTerrain, continuousTerrain: !options.fastTerrain, + fastTerrain: !!options.fastTerrain, zoom: state.zoom || 1, }); applyCanvasZoom(); diff --git a/index.html b/index.html index fa4f34a..a0b500d 100644 --- a/index.html +++ b/index.html @@ -19,6 +19,7 @@
+ -

Right-drag an area to enable patch generation.

+

Right-drag to lasso a freeform patch area.

@@ -112,7 +113,7 @@
Notes
-

Open index.html with Live Server. Left-drag pans the viewport; right-drag selects a regeneration area; use Patch Generation to write terrain into that area.

+

Open index.html with Live Server. Left-drag pans the viewport; right-drag draws a freeform regeneration area; use Patch Generation to write terrain into that area.

Add preferred reusable place names in CUSTOM_NAME_LIST inside names.js.

diff --git a/mapFeatures.js b/mapFeatures.js index 75e009a..9ffaada 100644 --- a/mapFeatures.js +++ b/mapFeatures.js @@ -1960,9 +1960,9 @@ const premodernRoads = []; // are through-corridors here, not automatic suburbanization generators. // A narrow field controls land-use attachment, while a broader field raises // population density around trunk roads without painting a wide suburb band. - const roadLanduseInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 2.25, "road:landuse"); - const roadInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 5.0, "road:influence"); - const roadDensityInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 9.0, "road:density"); + let roadLanduseInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 2.25, "road:landuse:provisional"); + let roadInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 5.0, "road:influence:provisional"); + let roadDensityInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 9.0, "road:density:provisional"); const railInfluence2 = cachedInfluenceFromPaths([...railways, ...branchRailways, ...externalRailways], 4, "rail:influence"); const stations = []; @@ -2522,6 +2522,18 @@ const premodernRoads = []; transportDebugLayers.postConnectivityExpresswayDedup = dedupeTransportPathSet(expressways, { minLength: 8, sampleStep: 2 }); transportDebugLayers.postConnectivityExpresswayEndpointICs = ensureExpresswayEndpointsHaveICs(); markFeatureTiming("post-connectivity-guarantees"); + + const finalRoadInfluencePaths = [...nationalRoads, ...ringRoads, ...externalRoads]; + roadLanduseInfluence = cachedInfluenceFromPaths(finalRoadInfluencePaths, 2.25, "road:landuse:final"); + roadInfluence = cachedInfluenceFromPaths(finalRoadInfluencePaths, 5.0, "road:influence:final"); + roadDensityInfluence = cachedInfluenceFromPaths(finalRoadInfluencePaths, 9.0, "road:density:final"); + transportDebugLayers.finalRoadInfluenceRefresh = { + roadPaths: finalRoadInfluencePaths.length, + nationalRoads: nationalRoads.length, + externalRoads: externalRoads.length, + ringRoads: ringRoads.length, + }; + const { landuse, populationDensity } = buildFeatureLanduse({ seed, elevation, slope, sea, river, floodplain, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, diff --git a/mapOutput.js b/mapOutput.js index f23c24f..d79b529 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -1223,6 +1223,9 @@ export function finishMapOutput({ return applyOutputOptions({ width: MAP_W, height: MAP_H, + originX: Number.isFinite(terrain?.originX) ? terrain.originX : (Number.isFinite(options?.originX) ? options.originX : 0), + originY: Number.isFinite(terrain?.originY) ? terrain.originY : (Number.isFinite(options?.originY) ? options.originY : 0), + generationContext: options?.generationContext || terrain?.generationContext || null, cellSize: CELL_SIZE, terrainTemplate, seaLevel, diff --git a/mapPatch.js b/mapPatch.js index cabe7a4..d01be2a 100644 --- a/mapPatch.js +++ b/mapPatch.js @@ -44,7 +44,7 @@ const ADMIN_CONTINUITY_FIELD_NAMES = new Set(["adminId", "municipalityId", "pref const NATURAL_CONTINUITY_FIELD_NAMES = new Set(["regionId", "naturalCompartmentId", "watershedId"]); const CONTINUITY_FIELD_NAMES = new Set([...ADMIN_CONTINUITY_FIELD_NAMES, ...NATURAL_CONTINUITY_FIELD_NAMES]); -const SKIP_CELL_FIELDS = new Set(["flowTo"]); +const SKIP_CELL_FIELDS = new Set(["flowTo", "prefectureMask", "humanRegionMask"]); function worldIndex(world, x, y) { if (!world || x < 0 || y < 0 || x >= world.width || y >= world.height) return -1; @@ -81,6 +81,89 @@ function normalizeRect(rect) { return { x0, y0, x1, y1 }; } +function isPolygonSelection(input) { + return !!input && Array.isArray(input.polygon) && input.polygon.length >= 3; +} + +function clampPointToWorld(point, world) { + return { + x: clamp(Math.round(point.x ?? 0), 0, Math.max(0, (world?.width || 1) - 1)), + y: clamp(Math.round(point.y ?? 0), 0, Math.max(0, (world?.height || 1) - 1)), + }; +} + +function polygonBounds(polygon) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const p of polygon || []) { + if (!Number.isFinite(p?.x) || !Number.isFinite(p?.y)) continue; + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + } + if (!Number.isFinite(minX)) return null; + return { x0: Math.floor(minX), y0: Math.floor(minY), x1: Math.ceil(maxX + 1), y1: Math.ceil(maxY + 1) }; +} + +function polygonAreaCells(polygon) { + if (!polygon || polygon.length < 3) return 0; + let area = 0; + for (let i = 0; i < polygon.length; i++) { + const a = polygon[i]; + const b = polygon[(i + 1) % polygon.length]; + area += a.x * b.y - b.x * a.y; + } + return Math.abs(area) * 0.5; +} + +function normalizeSelectionShape(input, world = null) { + if (!isPolygonSelection(input)) return normalizeRect(input); + const polygon = (input.polygon || []).map((p) => world ? clampPointToWorld(p, world) : { x: Math.round(p.x), y: Math.round(p.y) }); + const bounds = polygonBounds(polygon); + if (!bounds) return null; + return { + kind: input.kind || 'lasso', + polygon, + areaCells: Math.max(1, Math.round(input.areaCells || polygonAreaCells(polygon))), + x0: bounds.x0, + y0: bounds.y0, + x1: bounds.x1, + y1: bounds.y1, + }; +} + +function pointInPolygon(px, py, polygon) { + let inside = false; + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const xi = polygon[i].x + 0.5; + const yi = polygon[i].y + 0.5; + const xj = polygon[j].x + 0.5; + const yj = polygon[j].y + 0.5; + const intersect = ((yi > py) !== (yj > py)) && (px < ((xj - xi) * (py - yi)) / Math.max(1e-6, (yj - yi)) + xi); + if (intersect) inside = !inside; + } + return inside; +} + +function pointSegmentDistance(px, py, ax, ay, bx, by) { + const dx = bx - ax; + const dy = by - ay; + const len2 = dx * dx + dy * dy; + if (len2 <= 1e-6) 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 distanceToPolygonEdge(px, py, polygon) { + let best = Infinity; + for (let i = 0; i < polygon.length; i++) { + const a = polygon[i]; + const b = polygon[(i + 1) % polygon.length]; + best = Math.min(best, pointSegmentDistance(px, py, a.x + 0.5, a.y + 0.5, b.x + 0.5, b.y + 0.5)); + } + return best; +} + function insideRect(x, y, rect) { return !!rect && x >= rect.x0 && y >= rect.y0 && x < rect.x1 && y < rect.y1; } @@ -119,8 +202,9 @@ function ensureWorldField(world, name, source) { } export function clipPatchRect(rect, world) { - const normalized = normalizeRect(rect); + const normalized = normalizeSelectionShape(rect, world); if (!normalized || !world) return null; + if (isPolygonSelection(normalized)) return normalized; return { x0: Math.min(Math.max(normalized.x0, 0), world.width), y0: Math.min(Math.max(normalized.y0, 0), world.height), @@ -134,7 +218,7 @@ export function validatePatchRect(rect, world) { if (!clipped) return { ok: false, rect: null, reason: "No selected area." }; const width = rectWidth(clipped); const height = rectHeight(clipped); - const area = width * height; + const area = isPolygonSelection(clipped) ? Math.max(1, Math.round(clipped.areaCells || polygonAreaCells(clipped.polygon))) : width * height; if (width < PATCH_MIN_WIDTH || height < PATCH_MIN_HEIGHT) { const parts = []; if (width < PATCH_MIN_WIDTH) parts.push(`minimum width ${PATCH_MIN_WIDTH} cells`); @@ -162,7 +246,7 @@ export function validatePatchRect(rect, world) { } export function buildPatchRects(userRect, world = null) { - const coreRect = normalizeRect(userRect); + const coreRect = normalizeSelectionShape(userRect, world); const width = rectWidth(coreRect); const height = rectHeight(coreRect); const shortSide = Math.max(1, Math.min(width, height)); @@ -173,7 +257,7 @@ export function buildPatchRects(userRect, world = null) { const repairMargin = Math.max(writeMargin, Math.min(desiredRepair, maxBySource)); const writeRect = expandRect(coreRect, writeMargin, world); const repairRect = expandRect(coreRect, repairMargin, world); - const transportReachMargin = Math.max(repairMargin + 96, Math.min(260, repairMargin + Math.max(MAP_W, MAP_H))); + const transportReachMargin = Math.max(repairMargin + 160, Math.min(420, Math.max(220, repairMargin + Math.floor(Math.max(MAP_W, MAP_H) * 1.35)))); const transportReachRect = expandRect(coreRect, transportReachMargin, world); return { coreRect, @@ -184,6 +268,7 @@ export function buildPatchRects(userRect, world = null) { blendRect: coreRect, userRect: writeRect, selectedRect: coreRect, + selectionShape: isPolygonSelection(coreRect) ? coreRect : null, writeMargin, repairMargin, transportReachMargin, @@ -195,10 +280,21 @@ export function buildPatchRects(userRect, world = null) { function patchAlpha(x, y, rects, seed = 0) { const writeRect = rects.writeRect || rects.userRect; if (!insideRect(x, y, writeRect)) return 0; - const edge = distanceToRectEdge(x, y, writeRect); const margin = Math.max(1, rects.writeMargin || 1); const low = valueNoise(x, y, seed ^ 0x7153a9d1, 18) - 0.5; const mid = valueNoise(x, y, seed ^ 0x9e3779b9, 7) - 0.5; + const shape = rects.selectionShape; + if (shape?.polygon?.length >= 3) { + const px = x + 0.5; + const py = y + 0.5; + const inside = pointInPolygon(px, py, shape.polygon); + const dist = distanceToPolygonEdge(px, py, shape.polygon); + const noisyDist = dist + low * margin * 0.28 + mid * margin * 0.10; + if (inside) return 1; + if (noisyDist >= margin * 1.08) return 0; + return clamp(smoothstep(1 - noisyDist / Math.max(1e-6, margin))); + } + const edge = distanceToRectEdge(x, y, writeRect); const noisyEdge = edge + low * margin * 0.42 + mid * margin * 0.16; const base = smoothstep(clamp(noisyEdge / margin)); // Keep the expanded repair band as the actual seam. The user's selected core @@ -251,105 +347,253 @@ function fieldIdOffset(name, seed) { return base + ((seed >>> 0) % 997) * 10000; } -function offsetFieldValue(name, raw, seed) { - if (!Number.isFinite(raw) || raw < 0) return raw; - const offset = fieldIdOffset(name, seed); - return offset ? raw + offset : raw; +function maxFieldId(field) { + if (!field) return -1; + let max = -1; + for (let i = 0; i < field.length; i++) { + const id = field[i]; + if (Number.isFinite(id) && id > max) max = id; + } + return max; } -function isContinuityTransitionCell(x, y, rects, seed = 0, mode = "normal") { - if (!insideRect(x, y, rects.writeRect)) return false; - const edge = distanceToRectEdge(x, y, rects.writeRect); - const margin = Math.max(2, rects.writeMargin || 1); - const alpha = patchAlpha(x, y, rects, seed); - const edgeLimit = mode === "prefecture" ? margin * 2.15 : mode === "admin" ? margin * 1.75 : margin * 1.45; - const alphaLimit = mode === "prefecture" ? 0.995 : mode === "admin" ? 0.985 : 0.96; - return edge <= edgeLimit || alpha < alphaLimit; +function addMappingVote(votes, from, to, weight = 1) { + if (!Number.isFinite(from) || from < 0 || !Number.isFinite(to) || to < 0) return; + const key = Math.floor(from); + const target = Math.floor(to); + const bucket = votes.get(key) || new Map(); + bucket.set(target, (bucket.get(target) || 0) + Math.max(1, weight)); + votes.set(key, bucket); } -function addCount(bucket, key, amount = 1) { - if (!Number.isFinite(key) || key < 0) return; - bucket.set(key, (bucket.get(key) || 0) + amount); -} - -function buildContinuityIdMappings(world, candidate, rects, window, oldFields, seed = 0) { - const out = new Map(); - const debug = { continuityIdMappings: 0, continuityIdMappedCells: 0 }; - const dirs = [[0,0],[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]; - - for (const name of CONTINUITY_FIELD_NAMES) { - const source = candidate?.[name]; - const old = oldFields?.get(name) || world.fields?.[name]; - if (!source || !old || !isCellField(source)) continue; - const mode = name === "prefectureRegionId" ? "prefecture" : (name === "adminId" || name === "municipalityId") ? "admin" : "natural"; - const contacts = new Map(); - - for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { - for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { - if (!isContinuityTransitionCell(x, y, rects, seed, mode)) continue; - const s = sourceCoordForWorld(window, x, y); - const si = sourceIndex(s.x, s.y); - if (si < 0) continue; - const from = offsetFieldValue(name, source[si], seed); - if (!Number.isFinite(from) || from < 0) continue; - const bucket = contacts.get(from) || new Map(); - for (const [dx, dy] of dirs) { - const nx = x + dx, ny = y + dy; - const wi = worldIndex(world, nx, ny); - if (wi < 0 || old[wi] < 0) continue; - const outsideWrite = !insideRect(nx, ny, rects.writeRect); - const weakPatch = insideRect(nx, ny, rects.writeRect) && patchAlpha(nx, ny, rects, seed) < (mode === "prefecture" ? 0.96 : 0.88); - const edgeWeight = outsideWrite ? 6 : weakPatch ? 3 : (dx || dy ? 1 : 2); - addCount(bucket, old[wi], edgeWeight); - } - contacts.set(from, bucket); - } - } - - const mapping = new Map(); - for (const [from, bucket] of contacts) { - let total = 0; - let best = -1; - let bestCount = 0; - for (const [to, count] of bucket) { - total += count; - if (count > bestCount) { best = to; bestCount = count; } - } - const minCount = mode === "prefecture" ? 10 : mode === "admin" ? 8 : 5; - const minShare = mode === "prefecture" ? 0.42 : mode === "admin" ? 0.48 : 0.36; - if (best >= 0 && bestCount >= minCount && bestCount / Math.max(1, total) >= minShare) { - mapping.set(from, best); - } - } - if (mapping.size) { - out.set(name, mapping); - debug.continuityIdMappings += mapping.size; +function chooseVotedTarget(bucket, minVotes = 1) { + let best = -1; + let bestVotes = 0; + for (const [target, count] of bucket || []) { + if (count > bestVotes || (count === bestVotes && target < best)) { + best = target; + bestVotes = count; } } - out.debug = debug; - return out; + return best >= 0 && bestVotes >= minVotes ? best : -1; } -function applyContinuityMapping(idMappings, name, value) { - const map = idMappings?.get?.(name); - return map && map.has(value) ? map.get(value) : value; -} - -function pointCandidateContinuityIds(p, key, seed = 0) { - const ids = []; - if (!p) return ids; - if (key === "adminCenters") { - for (const raw of [p.id, p.adminId, p.adminNumericId, p.municipalityId]) { - if (Number.isFinite(raw) && raw >= 0) ids.push(offsetFieldValue("adminId", raw, seed)); - } - } else if (key === "prefectureRegions") { - for (const raw of [p.id, p.prefectureRegionId]) { - if (Number.isFinite(raw) && raw >= 0) ids.push(offsetFieldValue("prefectureRegionId", raw, seed)); +function collectCandidateIdsInRect(candidateField, rects, window, minAlpha = 0.20, seed = 0) { + const ids = new Set(); + if (!candidateField) return ids; + const rect = rects.writeRect; + for (let y = rect.y0; y < rect.y1; y++) { + for (let x = rect.x0; x < rect.x1; x++) { + if (patchAlpha(x, y, rects, seed) < minAlpha) continue; + const s = sourceCoordForWorld(window, x, y); + const si = sourceIndex(s.x, s.y); + if (si < 0) continue; + const id = candidateField[si]; + if (Number.isFinite(id) && id >= 0) ids.add(Math.floor(id)); } } return ids; } +function buildCandidatePrefByAdmin(candidateMap, candidateAdminIds) { + const out = new Map(); + const direct = candidateMap?.municipalityToPrefectureId; + for (const id of candidateAdminIds || []) { + const pref = direct?.[id]; + if (Number.isFinite(pref) && pref >= 0) out.set(id, Math.floor(pref)); + } + const adminField = candidateMap?.adminId || candidateMap?.municipalityId; + const prefField = candidateMap?.prefectureRegionId; + if (!adminField || !prefField) return out; + const votes = new Map(); + for (let i = 0; i < adminField.length; i++) { + const admin = adminField[i]; + const pref = prefField[i]; + if (!Number.isFinite(admin) || admin < 0 || !Number.isFinite(pref) || pref < 0) continue; + if (candidateAdminIds?.size && !candidateAdminIds.has(Math.floor(admin))) continue; + addMappingVote(votes, admin, pref, 1); + } + for (const [admin, bucket] of votes) { + if (!out.has(admin)) { + const pref = chooseVotedTarget(bucket, 1); + if (pref >= 0) out.set(admin, pref); + } + } + return out; +} + +export function buildAdminIdMapping({ candidateMap, world, writeRect, seamBand = 24, window = null, rects = null, seed = 0 } = {}) { + const actualWindow = window || (rects ? sourceWindowForRects(rects) : null); + const actualRects = rects || { writeRect, writeMargin: seamBand || 1 }; + if (!candidateMap || !world || !writeRect || !actualWindow) { + return { + prefecture: new Map(), + municipality: new Map(), + admin: new Map(), + candidateAdminToPrefecture: new Map(), + debug: { prefecturesMappedToExisting: 0, prefecturesAllocated: 0, municipalitiesMappedToExisting: 0, municipalitiesAllocated: 0 }, + }; + } + + const candidateAdmin = candidateMap.adminId || candidateMap.municipalityId; + const candidateMunicipality = candidateMap.municipalityId || candidateAdmin; + const candidatePrefecture = candidateMap.prefectureRegionId; + const worldAdmin = world.fields?.adminId || world.fields?.municipalityId; + const worldMunicipality = world.fields?.municipalityId || worldAdmin; + const worldPrefecture = world.fields?.prefectureRegionId; + + const candidateAdminIds = collectCandidateIdsInRect(candidateAdmin, actualRects, actualWindow, 0.18, seed); + const candidateMunicipalityIds = collectCandidateIdsInRect(candidateMunicipality, actualRects, actualWindow, 0.18, seed); + const candidatePrefectureIds = collectCandidateIdsInRect(candidatePrefecture, actualRects, actualWindow, 0.18, seed); + const candidateAdminToPrefecture = buildCandidatePrefByAdmin(candidateMap, candidateAdminIds); + + const adminVotes = new Map(); + const municipalityVotes = new Map(); + const prefectureVotes = new Map(); + const band = Math.max(2, Math.floor(seamBand)); + const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]; + + for (let y = writeRect.y0; y < writeRect.y1; y++) { + for (let x = writeRect.x0; x < writeRect.x1; x++) { + const edge = distanceToRectEdge(x, y, writeRect); + if (edge > band) continue; + const s = sourceCoordForWorld(actualWindow, x, y); + const si = sourceIndex(s.x, s.y); + const wi = worldIndex(world, x, y); + if (si < 0 || wi < 0) continue; + const cAdmin = candidateAdmin?.[si] ?? -1; + const cMunicipality = candidateMunicipality?.[si] ?? cAdmin; + const cPrefecture = candidatePrefecture?.[si] ?? -1; + const sameCellWeight = Math.max(1, band + 1 - edge); + addMappingVote(adminVotes, cAdmin, worldAdmin?.[wi] ?? -1, sameCellWeight); + addMappingVote(municipalityVotes, cMunicipality, worldMunicipality?.[wi] ?? worldAdmin?.[wi] ?? -1, sameCellWeight); + addMappingVote(prefectureVotes, cPrefecture, worldPrefecture?.[wi] ?? -1, sameCellWeight); + + for (const [dx, dy] of dirs) { + for (let step = 1; step <= 6; step++) { + const nx = x + dx * step; + const ny = y + dy * step; + if (insideRect(nx, ny, writeRect)) continue; + const ni = worldIndex(world, nx, ny); + if (ni < 0) break; + const w = Math.max(1, 7 - step) + Math.max(0, band - edge) * 0.25; + addMappingVote(adminVotes, cAdmin, worldAdmin?.[ni] ?? -1, w); + addMappingVote(municipalityVotes, cMunicipality, worldMunicipality?.[ni] ?? worldAdmin?.[ni] ?? -1, w); + addMappingVote(prefectureVotes, cPrefecture, worldPrefecture?.[ni] ?? -1, w); + break; + } + } + } + } + + const prefecture = new Map(); + let nextPrefectureId = maxFieldId(worldPrefecture) + 1; + let prefecturesMappedToExisting = 0; + let prefecturesAllocated = 0; + for (const id of [...candidatePrefectureIds].sort((a, b) => a - b)) { + const voted = chooseVotedTarget(prefectureVotes.get(id), 3); + if (voted >= 0) { + prefecture.set(id, voted); + prefecturesMappedToExisting++; + } else { + prefecture.set(id, nextPrefectureId++); + prefecturesAllocated++; + } + } + + const usedAdminIds = new Set(); + if (worldAdmin) { + for (let i = 0; i < worldAdmin.length; i++) if (worldAdmin[i] >= 0) usedAdminIds.add(worldAdmin[i]); + } + const municipality = new Map(); + const admin = new Map(); + let nextMunicipalityId = maxFieldId(worldAdmin || worldMunicipality) + 1; + let municipalitiesMappedToExisting = 0; + let municipalitiesAllocated = 0; + const allMunicipalityIds = new Set([...candidateAdminIds, ...candidateMunicipalityIds]); + for (const id of [...allMunicipalityIds].sort((a, b) => a - b)) { + const voted = chooseVotedTarget(municipalityVotes.get(id) || adminVotes.get(id), 4); + if (voted >= 0) { + municipality.set(id, voted); + admin.set(id, voted); + municipalitiesMappedToExisting++; + } else { + while (usedAdminIds.has(nextMunicipalityId)) nextMunicipalityId++; + municipality.set(id, nextMunicipalityId); + admin.set(id, nextMunicipalityId); + usedAdminIds.add(nextMunicipalityId); + nextMunicipalityId++; + municipalitiesAllocated++; + } + } + + const municipalityToPrefecture = new Map(); + for (const [candidateAdminId, worldAdminId] of admin) { + const candidatePrefId = candidateAdminToPrefecture.get(candidateAdminId); + const worldPrefId = prefecture.get(candidatePrefId); + if (Number.isFinite(worldAdminId) && Number.isFinite(worldPrefId)) municipalityToPrefecture.set(worldAdminId, worldPrefId); + } + + return { + prefecture, + municipality, + admin, + candidateAdminToPrefecture, + municipalityToPrefecture, + debug: { + candidatePrefectureIds: candidatePrefectureIds.size, + candidateMunicipalityIds: allMunicipalityIds.size, + prefecturesMappedToExisting, + prefecturesAllocated, + municipalitiesMappedToExisting, + municipalitiesAllocated, + }, + }; +} + +function remapAdminCandidateValue(name, raw, adminIdMapping) { + if (!Number.isFinite(raw) || raw < 0 || !adminIdMapping) return raw; + const id = Math.floor(raw); + if (name === "prefectureRegionId") return adminIdMapping.prefecture?.get(id) ?? raw; + if (name === "adminId") return adminIdMapping.admin?.get(id) ?? raw; + if (name === "municipalityId") return adminIdMapping.municipality?.get(id) ?? adminIdMapping.admin?.get(id) ?? raw; + return raw; +} + +function numericFeatureId(point, keys) { + for (const key of keys) { + const value = point?.[key]; + if (Number.isFinite(value) && value >= 0) return Math.floor(value); + } + return -1; +} + +function summarizeIdMapping(mapping) { + return { ...(mapping?.debug || {}) }; +} + +function updateSourceAdminMetadata(sourceMap, adminIdMapping) { + if (!sourceMap || !adminIdMapping?.municipalityToPrefecture?.size) return 0; + let maxId = -1; + const current = sourceMap.municipalityToPrefectureId; + if (current && typeof current.length === "number") maxId = Math.max(maxId, current.length - 1); + for (const [adminId] of adminIdMapping.municipalityToPrefecture) maxId = Math.max(maxId, adminId); + const next = new Int32Array(Math.max(0, maxId + 1)); + next.fill(-1); + if (current && typeof current.length === "number") { + for (let i = 0; i < current.length && i < next.length; i++) next[i] = current[i] ?? -1; + } + let updated = 0; + for (const [adminId, prefId] of adminIdMapping.municipalityToPrefecture) { + if (!Number.isFinite(adminId) || adminId < 0 || !Number.isFinite(prefId) || prefId < 0) continue; + if (next[adminId] !== prefId) updated++; + next[adminId] = prefId; + } + sourceMap.municipalityToPrefectureId = next; + sourceMap.patchAdminIdMappingDebug = summarizeIdMapping(adminIdMapping); + return updated; +} + function cloneContinuityFields(world) { const out = new Map(); for (const name of CONTINUITY_FIELD_NAMES) { @@ -431,8 +675,15 @@ function copyFullPipelineFields(world, candidate, rects, seed) { const window = sourceWindowForRects(rects); const oldSea = world.fields.sea ? new Uint8Array(world.fields.sea) : null; const oldContinuityFields = cloneContinuityFields(world); - const continuityIdMappings = buildContinuityIdMappings(world, candidate, rects, window, oldContinuityFields, seed); - let continuityIdMappedCells = 0; + const adminIdMapping = buildAdminIdMapping({ + candidateMap: candidate, + world, + writeRect: rects.writeRect, + seamBand: Math.max(8, Math.floor(rects.writeMargin || 24)), + window, + rects, + seed, + }); let updatedCells = 0; let coastCellsChanged = 0; let terrainCellsFullyReplaced = 0; @@ -462,12 +713,10 @@ function copyFullPipelineFields(world, candidate, rects, seed) { const threshold = continuityReplaceThreshold(name, x, y, rects, seed); if (alpha >= threshold) { const raw = source[si]; - let value = idOffset && raw >= 0 ? raw + idOffset : raw; - if (CONTINUITY_FIELD_NAMES.has(name)) { - const mapped = applyContinuityMapping(continuityIdMappings, name, value); - if (mapped !== value) continuityIdMappedCells++; - value = mapped; - } + const mapped = remapAdminCandidateValue(name, raw, adminIdMapping); + const value = (name === "adminId" || name === "municipalityId" || name === "prefectureRegionId") + ? mapped + : idOffset && raw >= 0 ? raw + idOffset : raw; if (name === "sea" && oldSea && dest[wi] !== value) coastCellsChanged++; if ((name === "naturalCompartmentId" || name === "watershedId" || name === "regionId") && dest[wi] !== value) naturalRegionsUpdated++; if ((name === "adminId" || name === "municipalityId") && dest[wi] !== value) adminCellsReassigned++; @@ -531,22 +780,184 @@ function copyFullPipelineFields(world, candidate, rects, seed) { } const continuityDebug = stabilizeContinuitySeam(world, rects, oldContinuityFields, seed); + if (world.fields.adminId && world.fields.municipalityId && !candidate?.municipalityId) { + for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { + for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { + const wi = worldIndex(world, x, y); + if (wi >= 0) world.fields.municipalityId[wi] = world.fields.adminId[wi]; + } + } + } return { window, - idMappings: continuityIdMappings, updatedCells, terrainCellsFullyReplaced, coastCellsChanged, naturalRegionsUpdated, adminCellsReassigned, landUseCellsUpdated, - continuityIdMappedCells, - continuityIdMappings: continuityIdMappings.debug?.continuityIdMappings || 0, + adminIdMapping, + adminIdMappingDebug: summarizeIdMapping(adminIdMapping), ...continuityDebug, }; } + +function repairDisplayMasks(world, rects, seed = 0) { + const expected = world.width * world.height; + if (!world.fields.prefectureMask || world.fields.prefectureMask.length !== expected) world.fields.prefectureMask = new Uint8Array(expected); + if (!world.fields.landMask || world.fields.landMask.length !== expected) world.fields.landMask = new Uint8Array(expected); + if (!world.fields.humanRegionMask || world.fields.humanRegionMask.length !== expected) world.fields.humanRegionMask = new Uint8Array(expected); + const coverage = world.fields.prefectureMask; + const landMask = world.fields.landMask; + const humanMask = world.fields.humanRegionMask; + const sea = world.fields.sea; + let displayMaskUpdated = 0; + for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { + for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { + const i = worldIndex(world, x, y); + if (i < 0) continue; + const a = patchAlpha(x, y, rects, seed); + if (a <= 0.08) continue; + if (!coverage[i]) displayMaskUpdated++; + coverage[i] = 1; + const isSea = Boolean(sea?.[i]); + humanMask[i] = isSea ? 0 : 1; + landMask[i] = isSea ? 0 : 1; + } + } + return { displayMaskUpdated }; +} + + +function nearestLandFieldValue(world, x, y, fieldName, rect, options = {}) { + const field = world.fields?.[fieldName]; + const sea = world.fields?.sea; + if (!field) return -1; + const maxRadius = Math.max(1, Math.floor(options.maxRadius || 18)); + const requiredPref = Number.isFinite(options.requiredPref) ? Math.floor(options.requiredPref) : null; + const prefField = world.fields?.prefectureRegionId; + for (let r = 1; r <= maxRadius; r++) { + let best = -1; + let bestD = Infinity; + const y0 = Math.max(0, y - r); + const y1 = Math.min(world.height - 1, y + r); + const x0 = Math.max(0, x - r); + const x1 = Math.min(world.width - 1, x + r); + for (let yy = y0; yy <= y1; yy++) { + for (let xx = x0; xx <= x1; xx++) { + if (Math.max(Math.abs(xx - x), Math.abs(yy - y)) !== r) continue; + if (rect && !insideRect(xx, yy, rect)) continue; + const i = worldIndex(world, xx, yy); + if (i < 0 || sea?.[i]) continue; + if (requiredPref !== null && prefField?.[i] !== requiredPref) continue; + const id = field[i]; + if (!Number.isFinite(id) || id < 0) continue; + const d = Math.hypot(xx - x, yy - y); + if (d < bestD) { best = Math.floor(id); bestD = d; } + } + } + if (best >= 0) return best; + } + return -1; +} + +function lookupPrefectureForAdmin(sourceMap, adminIdMapping, adminId) { + if (!Number.isFinite(adminId) || adminId < 0) return -1; + const id = Math.floor(adminId); + const mapped = adminIdMapping?.municipalityToPrefecture?.get(id); + if (Number.isFinite(mapped) && mapped >= 0) return Math.floor(mapped); + const table = sourceMap?.municipalityToPrefectureId; + if (table && id >= 0 && id < table.length && Number.isFinite(table[id]) && table[id] >= 0) return Math.floor(table[id]); + return -1; +} + +function repairAdminCoverage(world, sourceMap, rects, adminIdMapping = null) { + const admin = world.fields?.adminId; + if (!admin) return { seaAdminCellsCleared: 0, landAdminCellsFilled: 0, prefectureCellsFilled: 0, adminPrefectureCellsAligned: 0 }; + const expected = world.width * world.height; + if (!world.fields.municipalityId || world.fields.municipalityId.length !== expected) { + world.fields.municipalityId = new Int32Array(expected); + world.fields.municipalityId.fill(-1); + } + if (!world.fields.prefectureRegionId || world.fields.prefectureRegionId.length !== expected) { + world.fields.prefectureRegionId = new Int32Array(expected); + world.fields.prefectureRegionId.fill(-1); + } + const municipality = world.fields.municipalityId; + const prefecture = world.fields.prefectureRegionId; + const sea = world.fields.sea; + const coverage = world.fields.prefectureMask; + let seaAdminCellsCleared = 0; + let landAdminCellsFilled = 0; + let prefectureCellsFilled = 0; + let adminPrefectureCellsAligned = 0; + + for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { + for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { + const i = worldIndex(world, x, y); + if (i < 0) continue; + if (sea?.[i]) { + if (admin[i] >= 0 || municipality[i] >= 0 || prefecture[i] >= 0) seaAdminCellsCleared++; + admin[i] = -1; + municipality[i] = -1; + prefecture[i] = -1; + continue; + } + const generated = !coverage || coverage[i] || patchAlpha(x, y, rects, 0) > 0.08; + if (!generated) continue; + + if (admin[i] < 0) { + const preferredPref = prefecture[i] >= 0 ? prefecture[i] : null; + let nearest = nearestLandFieldValue(world, x, y, 'adminId', rects.repairRect || rects.writeRect, { requiredPref: preferredPref, maxRadius: 24 }); + if (nearest < 0) nearest = nearestLandFieldValue(world, x, y, 'adminId', null, { requiredPref: preferredPref, maxRadius: 18 }); + if (nearest >= 0) { + admin[i] = nearest; + municipality[i] = nearest; + landAdminCellsFilled++; + } + } + if (municipality[i] < 0 && admin[i] >= 0) municipality[i] = admin[i]; + if (admin[i] >= 0 && municipality[i] !== admin[i]) municipality[i] = admin[i]; + + let targetPref = lookupPrefectureForAdmin(sourceMap, adminIdMapping, admin[i]); + if (targetPref < 0 && prefecture[i] < 0) targetPref = nearestLandFieldValue(world, x, y, 'prefectureRegionId', rects.repairRect || rects.writeRect, { maxRadius: 28 }); + if (targetPref >= 0 && prefecture[i] !== targetPref) { + if (prefecture[i] < 0) prefectureCellsFilled++; + else adminPrefectureCellsAligned++; + prefecture[i] = targetPref; + } else if (prefecture[i] < 0) { + const nearestPref = nearestLandFieldValue(world, x, y, 'prefectureRegionId', null, { maxRadius: 22 }); + if (nearestPref >= 0) { prefecture[i] = nearestPref; prefectureCellsFilled++; } + } + } + } + + const current = sourceMap?.municipalityToPrefectureId; + let maxId = current?.length ? current.length - 1 : -1; + for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { + for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { + const i = worldIndex(world, x, y); + if (i >= 0 && admin[i] >= 0 && prefecture[i] >= 0) maxId = Math.max(maxId, admin[i]); + } + } + if (sourceMap && maxId >= 0) { + const next = new Int32Array(maxId + 1); + next.fill(-1); + if (current) for (let i = 0; i < current.length && i < next.length; i++) next[i] = current[i] ?? -1; + for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { + for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { + const i = worldIndex(world, x, y); + if (i >= 0 && admin[i] >= 0 && prefecture[i] >= 0) next[admin[i]] = prefecture[i]; + } + } + sourceMap.municipalityToPrefectureId = next; + } + + return { seaAdminCellsCleared, landAdminCellsFilled, prefectureCellsFilled, adminPrefectureCellsAligned }; +} + function smoothWaterTopology(world, rect, seaLevel = 0.30) { const sea = world.fields.sea; const ocean = world.fields.ocean; @@ -675,7 +1086,7 @@ function sourcePathFromWorld(world, path) { return path.map(([x, y]) => [Math.round(x - world.originX), Math.round(y - world.originY)]); } -function transformCandidatePoint(world, window, p, key, seed = 0) { +function transformCandidatePoint(world, window, p, key, seed = 0, adminIdMapping = null) { if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y)) return null; const w = worldCoordForSource(window, p.x, p.y); if (key === "ports") { @@ -689,18 +1100,39 @@ function transformCandidatePoint(world, window, p, key, seed = 0) { } const out = sourcePointFromWorld(world, { ...p, x: w.x, y: w.y }); if (key === "adminCenters") { - const offset = fieldIdOffset("adminId", seed); - if (Number.isFinite(out.id)) out.id += offset; - if (Number.isFinite(out.adminId)) out.adminId += offset; - if (Number.isFinite(out.adminNumericId)) out.adminNumericId += offset; - if (Number.isFinite(out.municipalityId)) out.municipalityId += offset; - if (!Number.isFinite(out.adminId) && Number.isFinite(out.municipalityId)) out.adminId = out.municipalityId; - if (!Number.isFinite(out.municipalityId) && Number.isFinite(out.adminId)) out.municipalityId = out.adminId; + const rawAdminId = numericFeatureId(out, ["adminId", "municipalityId", "adminNumericId"]); + const mappedAdminId = rawAdminId >= 0 + ? adminIdMapping?.admin?.get(rawAdminId) ?? adminIdMapping?.municipality?.get(rawAdminId) + : undefined; + if (Number.isFinite(mappedAdminId)) { + out.sourceAdminId = rawAdminId; + out.adminId = mappedAdminId; + out.adminNumericId = mappedAdminId; + out.municipalityId = mappedAdminId; + const candidatePrefId = adminIdMapping?.candidateAdminToPrefecture?.get(rawAdminId); + const mappedPrefId = adminIdMapping?.prefecture?.get(candidatePrefId); + if (Number.isFinite(mappedPrefId)) out.prefectureRegionId = mappedPrefId; + } else { + const offset = fieldIdOffset("adminId", seed); + if (Number.isFinite(out.adminId)) out.adminId += offset; + if (Number.isFinite(out.adminNumericId)) out.adminNumericId += offset; + if (Number.isFinite(out.municipalityId)) out.municipalityId += offset; + if (!Number.isFinite(out.adminId) && Number.isFinite(out.municipalityId)) out.adminId = out.municipalityId; + if (!Number.isFinite(out.municipalityId) && Number.isFinite(out.adminId)) out.municipalityId = out.adminId; + } } if (key === "prefectureRegions") { - const offset = fieldIdOffset("prefectureRegionId", seed); - if (Number.isFinite(out.id)) out.id += offset; - if (Number.isFinite(out.prefectureRegionId)) out.prefectureRegionId += offset; + const rawPrefectureId = numericFeatureId(out, ["prefectureRegionId", "id"]); + const mappedPrefectureId = rawPrefectureId >= 0 ? adminIdMapping?.prefecture?.get(rawPrefectureId) : undefined; + if (Number.isFinite(mappedPrefectureId)) { + out.sourcePrefectureRegionId = rawPrefectureId; + out.id = mappedPrefectureId; + out.prefectureRegionId = mappedPrefectureId; + } else { + const offset = fieldIdOffset("prefectureRegionId", seed); + if (Number.isFinite(out.id)) out.id += offset; + if (Number.isFinite(out.prefectureRegionId)) out.prefectureRegionId += offset; + } } if (key === "logisticsParks") sanitizeLogisticsPark(out); return out; @@ -890,32 +1322,71 @@ function collectInternalNetworkPoints(world, sourceMap, keys, rect, mode = "road return points; } -function connectAnchors(world, sourceMap, anchors, mode, rect) { +function rectDistance(x, y, rect) { + if (insideRect(x, y, rect)) return 0; + const dx = x < rect.x0 ? rect.x0 - x : x >= rect.x1 ? x - rect.x1 + 1 : 0; + const dy = y < rect.y0 ? rect.y0 - y : y >= rect.y1 ? y - rect.y1 + 1 : 0; + return Math.hypot(dx, dy); +} + +function collectExternalNetworkAnchors(world, sourceMap, keys, writeRect, reachRect, mode = "road") { + const candidates = []; + const seen = new Set(); + const step = mode === "rail" ? 6 : 4; + for (const key of keys) { + for (const path of sourceMap[key] || []) { + for (let i = 0; i < path.length; i += step) { + const x = Math.round(tupleWorldX(world, path[i])); + const y = Math.round(tupleWorldY(world, path[i])); + if (!insideRect(x, y, reachRect) || insideRect(x, y, writeRect) || !isLand(world, x, y)) continue; + const d = rectDistance(x, y, writeRect); + if (d < 4 || d > (mode === "rail" ? 380 : 420)) continue; + const sig = `${x},${y},${mode}`; + if (seen.has(sig)) continue; + seen.add(sig); + candidates.push({ x, y, mode, external: true, d }); + } + } + } + candidates.sort((a, b) => a.d - b.d); + return candidates.slice(0, mode === "rail" ? 18 : 28); +} + +function connectAnchors(world, sourceMap, anchors, mode, rect, preferredTargetRect = null) { const keys = mode === "rail" ? ["railways", "branchRailways"] : ["nationalRoads", "minorRoads", "premodernRoads"]; - const targets = collectInternalNetworkPoints(world, sourceMap, keys, rect, mode); + const allTargets = collectInternalNetworkPoints(world, sourceMap, keys, rect, mode); + const preferredTargets = preferredTargetRect ? allTargets.filter((p) => insideRect(p.x, p.y, preferredTargetRect)) : []; + const targets = preferredTargets.length ? preferredTargets : allTargets; if (!targets.length) return { connectors: 0, disconnected: anchors.length }; let connectors = 0; let disconnected = 0; const layer = mode === "rail" ? "branchRailways" : "minorRoads"; sourceMap[layer] ||= []; const seen = new Set(); - const maxRange = mode === "rail" ? 260 : 300; + const maxRange = mode === "rail" ? 320 : 360; for (const raw of anchors) { const anchorLand = nearestLand(world, raw.x, raw.y, rect, 18); if (!anchorLand) { disconnected++; continue; } - const target = targets + const targetList = targets .map((p) => ({ ...p, d: Math.hypot(p.x - anchorLand.x, p.y - anchorLand.y) })) - .filter((p) => p.d <= maxRange) - .sort((a, b) => (a.d / Math.max(0.6, a.weight || 1)) - (b.d / Math.max(0.6, b.weight || 1)))[0]; - if (!target) { disconnected++; continue; } - const sig = `${anchorLand.x},${anchorLand.y}:${target.x},${target.y}:${mode}`; - if (seen.has(sig)) continue; - seen.add(sig); - const searchRect = expandRect(rect, 8, world); - const path = localPathfind(world, anchorLand, target, searchRect, mode, 42000); - if (!path || path.length < 2) { disconnected++; continue; } - sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(path, mode === "rail" ? 3 : 2))); - connectors++; + .filter((p) => p.d <= maxRange && p.d >= 6) + .sort((a, b) => (a.d / Math.max(0.6, a.weight || 1)) - (b.d / Math.max(0.6, b.weight || 1))) + .slice(0, 5); + if (!targetList.length) { disconnected++; continue; } + let made = false; + for (const target of targetList) { + const sig = `${anchorLand.x},${anchorLand.y}:${target.x},${target.y}:${mode}`; + if (seen.has(sig)) continue; + const searchRect = expandRect(rect, 24, world); + const path = localPathfind(world, anchorLand, target, searchRect, mode, mode === "rail" ? 62000 : 76000); + if (!path || path.length < 2) continue; + seen.add(sig); + sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(path, mode === "rail" ? 3 : 2))); + connectors++; + made = true; + break; + } + if (!made) disconnected++; } return { connectors, disconnected }; } @@ -947,12 +1418,12 @@ function ensureSettlementRoadCoverage(world, sourceMap, rect) { for (const p of sourceMap[key] || []) { const start = nearestLand(world, pointWorldX(world, p), pointWorldY(world, p), rect, 10); if (!start || !insideRect(start.x, start.y, rect)) continue; - const target = nearestNetworkPoint(world, sourceMap, keys, start, rect, key === "villages" ? 36 : 58); + const target = nearestNetworkPoint(world, sourceMap, keys, start, rect, key === "villages" ? 72 : 132); if (!target || Math.hypot(target.x - start.x, target.y - start.y) < 5) continue; const sig = `${start.x},${start.y}:${target.x},${target.y}`; if (seen.has(sig)) continue; seen.add(sig); - const path = localPathfind(world, start, target, rect, "road", 28000); + const path = localPathfind(world, start, target, rect, "road", 64000); if (!path || path.length < 2) continue; sourceMap.minorRoads.push(sourcePathFromWorld(world, simplifyPath(path, 2))); connectors++; @@ -961,7 +1432,39 @@ function ensureSettlementRoadCoverage(world, sourceMap, rect) { return connectors; } -function mergePointLayers(world, sourceMap, candidate, rects, window, seed, idMappings = null) { +function dedupeAdminCentersByWorldId(kept, generated) { + const out = [...kept]; + const seen = new Set(); + for (const p of kept) { + const id = numericFeatureId(p, ["adminId", "municipalityId", "adminNumericId"]); + if (id >= 0) seen.add(id); + } + for (const p of generated) { + const id = numericFeatureId(p, ["adminId", "municipalityId", "adminNumericId"]); + if (id >= 0 && seen.has(id)) continue; + if (id >= 0) seen.add(id); + out.push(p); + } + return out; +} + +function dedupePrefectureRegionsByWorldId(kept, generated) { + const out = [...kept]; + const seen = new Set(); + for (const p of kept) { + const id = numericFeatureId(p, ["prefectureRegionId", "id"]); + if (id >= 0) seen.add(id); + } + for (const p of generated) { + const id = numericFeatureId(p, ["prefectureRegionId", "id"]); + if (id >= 0 && seen.has(id)) continue; + if (id >= 0) seen.add(id); + out.push(p); + } + return out; +} + +function mergePointLayers(world, sourceMap, candidate, rects, window, seed, adminIdMapping = null) { let preservedExternalEntities = 0; let regeneratedInternalEntities = 0; let invalidPortsRemoved = 0; @@ -982,14 +1485,7 @@ function mergePointLayers(world, sourceMap, candidate, rects, window, seed, idMa } const generated = []; for (const p of candidate[key] || []) { - const candidateIds = pointCandidateContinuityIds(p, key, seed); - if (key === "adminCenters" && candidateIds.some((id) => applyContinuityMapping(idMappings, "adminId", id) !== id || applyContinuityMapping(idMappings, "municipalityId", id) !== id)) { - continue; - } - if (key === "prefectureRegions" && candidateIds.some((id) => applyContinuityMapping(idMappings, "prefectureRegionId", id) !== id)) { - continue; - } - const q = transformCandidatePoint(world, window, p, key, seed); + const q = transformCandidatePoint(world, window, p, key, seed, adminIdMapping); if (!q) continue; const wx = Math.round(pointWorldX(world, q)); const wy = Math.round(pointWorldY(world, q)); @@ -997,8 +1493,10 @@ function mergePointLayers(world, sourceMap, candidate, rects, window, seed, idMa if (patchAlpha(wx, wy, rects, seed) < 0.42) continue; generated.push(q); } - sourceMap[key] = [...kept, ...generated]; - regeneratedInternalEntities += generated.length; + if (key === "adminCenters") sourceMap[key] = dedupeAdminCentersByWorldId(kept, generated); + else if (key === "prefectureRegions") sourceMap[key] = dedupePrefectureRegionsByWorldId(kept, generated); + else sourceMap[key] = [...kept, ...generated]; + regeneratedInternalEntities += Math.max(0, sourceMap[key].length - kept.length); } return { preservedExternalEntities, regeneratedInternalEntities, invalidPortsRemoved }; } @@ -1031,8 +1529,12 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) { sourceMap[key] = next; } const transportRect = rects.transportReachRect || rects.repairRect || rects.writeRect; - const roadConn = connectAnchors(world, sourceMap, roadAnchors, "road", transportRect); - const railConn = connectAnchors(world, sourceMap, railAnchors, "rail", transportRect); + const externalRoadAnchors = collectExternalNetworkAnchors(world, sourceMap, ["nationalRoads", "minorRoads", "premodernRoads", "externalRoads"], rects.writeRect, transportRect, "road"); + const externalRailAnchors = collectExternalNetworkAnchors(world, sourceMap, ["railways", "branchRailways", "externalRailways"], rects.writeRect, transportRect, "rail"); + roadAnchors = roadAnchors.concat(externalRoadAnchors); + railAnchors = railAnchors.concat(externalRailAnchors); + const roadConn = connectAnchors(world, sourceMap, roadAnchors, "road", transportRect, rects.writeRect); + const railConn = connectAnchors(world, sourceMap, railAnchors, "rail", transportRect, rects.writeRect); const settlementRoadConnectors = ensureSettlementRoadCoverage(world, sourceMap, transportRect); return { roadsClipped, @@ -1042,6 +1544,8 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) { railwayConnectorsCreated: railConn.connectors, disconnectedRoadComponents: roadConn.disconnected, disconnectedRailComponents: railConn.disconnected, + externalRoadAnchors: externalRoadAnchors.length, + externalRailAnchors: externalRailAnchors.length, }; } @@ -1088,7 +1592,7 @@ function mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rec const b = worldCoordForSource(window, seg[1]?.[0], seg[1]?.[1]); const mx = (a.x + b.x) * 0.5; const my = (a.y + b.y) * 0.5; - if (!insideRect(mx, my, rects.writeRect) || patchAlpha(mx, my, rects, seed) < 0.38) continue; + if (!insideRect(mx, my, rects.writeRect) || patchAlpha(mx, my, rects, seed) < 0.08) continue; debug.compartmentBorders.push(sourcePathFromWorld(world, [[a.x, a.y], [b.x, b.y]])); added++; } @@ -1108,8 +1612,11 @@ function mergeSegmentLayers(world, sourceMap, rects, seed = 0) { sourceMap.prefectureBorder ||= []; const debug = sourceMap.adminDebug || {}; + // Use the legacy full-pipeline compartment debug segments for regenerated + // areas. Rebuilding directly from the raster field made patch compartments + // look denser/smaller than the initial map. Candidate segments are merged + // just after this function. debug.compartmentBorders = (debug.compartmentBorders || []).filter((seg) => !segmentTouchesRect(world, seg, rects.writeRect)); - debug.compartmentBorders.push(...buildBoundarySegmentsFromField(world, "naturalCompartmentId", rects.writeRect, { rects, seed, minAlpha: 0.40 })); sourceMap.adminDebug = debug; return { adminBordersRebuilt: sourceMap.adminBorders.length, @@ -1172,18 +1679,37 @@ export function generatePatch(world, userRectInput, options = {}) { const terrainType = options.terrainType || "auto"; const seed = Number.isFinite(options.seed) ? options.seed >>> 0 : ((world?.seed || 0) + 1013904223) >>> 0; const variant = Number.isFinite(options.variant) ? Math.max(0, Math.floor(options.variant)) >>> 0 : 0; - const candidate = generateMap(seed, { terrainType, legacyTerrain: true, onProgress: () => {} }); + const candidateWindow = sourceWindowForRects(rects); + const candidateOriginX = Math.round(candidateWindow.worldCenterX - candidateWindow.sourceCenterX); + const candidateOriginY = Math.round(candidateWindow.worldCenterY - candidateWindow.sourceCenterY); + const candidate = generateMap(seed, { + terrainType, + legacyTerrain: true, + worldNative: true, + variant, + originX: candidateOriginX, + originY: candidateOriginY, + width: MAP_W, + height: MAP_H, + contextRect: rects.contextRect, + boundaryWorld: world, + onProgress: () => {}, + }); const sourceMap = world.sourceMap || (world.sourceMap = {}); const logisticsLabelsMigrated = sanitizeExistingLogistics(sourceMap); const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed); const waterDebug = smoothWaterTopology(world, rects.writeRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30); + const maskDebug = repairDisplayMasks(world, rects, seed); recomputeSlopeAndWaterDependentFields(world, rects.repairRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30); - const pointDebug = mergePointLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed, fieldDebug.idMappings); + const sourceAdminMetadataUpdated = updateSourceAdminMetadata(sourceMap, fieldDebug.adminIdMapping); + const adminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, fieldDebug.adminIdMapping); + const pointDebug = mergePointLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed, fieldDebug.adminIdMapping); const pathDebug = mergePathLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed); const segmentDebug = mergeSegmentLayers(world, sourceMap, rects, seed); const candidateCompartmentSegmentsAdded = mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rects, fieldDebug.window, seed); const landDebug = repairLanduseAndPopulation(world, rects); + const finalAdminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, fieldDebug.adminIdMapping); sanitizeExistingLogistics(sourceMap); const seaStats = countSea(world, rects.coreRect); @@ -1197,11 +1723,17 @@ export function generatePatch(world, userRectInput, options = {}) { ...pointDebug, ...pathDebug, adminCellsReassigned: fieldDebug.adminCellsReassigned, + adminIdMapping: fieldDebug.adminIdMappingDebug, + sourceAdminMetadataUpdated, + ...adminCoverageDebug, + finalSeaAdminCellsCleared: finalAdminCoverageDebug.seaAdminCellsCleared || 0, + finalLandAdminCellsFilled: finalAdminCoverageDebug.landAdminCellsFilled || 0, + finalPrefectureCellsFilled: finalAdminCoverageDebug.prefectureCellsFilled || 0, + finalAdminPrefectureCellsAligned: finalAdminCoverageDebug.adminPrefectureCellsAligned || 0, continuityCellsRestored: fieldDebug.continuityCellsRestored || 0, continuityCellsRemapped: fieldDebug.continuityCellsRemapped || 0, - continuityIdMappings: fieldDebug.continuityIdMappings || 0, - continuityIdMappedCells: fieldDebug.continuityIdMappedCells || 0, landUseCellsUpdated: fieldDebug.landUseCellsUpdated + landDebug.landUseCellsUpdated, + displayMaskUpdated: maskDebug.displayMaskUpdated || 0, logisticsLabelsMigrated, ...segmentDebug, candidateCompartmentSegmentsAdded, @@ -1210,6 +1742,11 @@ export function generatePatch(world, userRectInput, options = {}) { const record = { ...rects.coreRect, coreRect: { ...rects.coreRect }, + selectionShape: rects.selectionShape ? { + kind: rects.selectionShape.kind || 'lasso', + areaCells: rects.selectionShape.areaCells || 0, + polygon: rects.selectionShape.polygon.map((p) => ({ x: p.x, y: p.y })), + } : null, writeRect: { ...rects.writeRect }, repairRect: { ...rects.repairRect }, contextRect: { ...rects.contextRect }, @@ -1218,12 +1755,15 @@ export function generatePatch(world, userRectInput, options = {}) { label, seed, variant, + candidateOriginX, + candidateOriginY, patchGenerationMode: "legacy-full-pipeline", updatedCells: fieldDebug.updatedCells, terrainCellsFullyReplaced: fieldDebug.terrainCellsFullyReplaced, coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged, naturalRegionsUpdated: fieldDebug.naturalRegionsUpdated, naturalRegionFragmentsMerged: 0, + adminIdMapping: fieldDebug.adminIdMappingDebug, seaRatio: seaStats.seaRatio, humanGeography, createdAt: Date.now(), @@ -1235,17 +1775,27 @@ export function generatePatch(world, userRectInput, options = {}) { return { ok: true, validation, - rects, + rects: { + ...rects, + selectionShape: rects.selectionShape ? { + kind: rects.selectionShape.kind || 'lasso', + areaCells: rects.selectionShape.areaCells || 0, + polygon: rects.selectionShape.polygon.map((p) => ({ x: p.x, y: p.y })), + } : null, + }, terrainType: id, label, seed, variant, + candidateOriginX, + candidateOriginY, patchGenerationMode: "legacy-full-pipeline", updatedCells: record.updatedCells, terrainCellsFullyReplaced: record.terrainCellsFullyReplaced, coastCellsChanged: record.coastCellsChanged, naturalRegionsUpdated: record.naturalRegionsUpdated, naturalRegionFragmentsMerged: 0, + adminIdMapping: fieldDebug.adminIdMappingDebug, seaRatio: seaStats.seaRatio, humanGeography, }; diff --git a/mapPipeline.js b/mapPipeline.js index c1fba77..0aed38d 100644 --- a/mapPipeline.js +++ b/mapPipeline.js @@ -1,5 +1,5 @@ import { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js"; -import { generateInitialTerrainRect, generateTerrainAndRivers } from "./mapTerrain.js"; +import { generateTerrainAndRivers } from "./mapTerrain.js"; import { generateMapFeatures } from "./mapFeatures.js"; import { finishMapOutput } from "./mapOutput.js"; import { generateAdminLayout } from "./mapAdminStage.js"; @@ -8,19 +8,91 @@ import { finalizeAdminAwareTransport } from "./mapPostAdminTransport.js"; export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js"; +function normalizeRectLike(rect) { + if (!rect) return null; + const x0 = Math.floor(Math.min(Number(rect.x0), Number(rect.x1))); + const y0 = Math.floor(Math.min(Number(rect.y0), Number(rect.y1))); + const x1 = Math.ceil(Math.max(Number(rect.x0), Number(rect.x1))); + const y1 = Math.ceil(Math.max(Number(rect.y0), Number(rect.y1))); + if (![x0, y0, x1, y1].every(Number.isFinite)) return null; + return { x0, y0, x1, y1 }; +} + +function normalizeGenerationContext(options = {}) { + const originX = Math.floor(Number.isFinite(options.originX) ? options.originX : 0); + const originY = Math.floor(Number.isFinite(options.originY) ? options.originY : 0); + const width = Math.max(1, Math.floor(Number.isFinite(options.width) ? options.width : MAP_W)); + const height = Math.max(1, Math.floor(Number.isFinite(options.height) ? options.height : MAP_H)); + const variant = Math.max(0, Math.floor(Number.isFinite(options.variant) ? options.variant : 0)) >>> 0; + const contextRect = normalizeRectLike(options.contextRect); + return { + worldNative: options.worldNative === true, + legacyTerrain: options.legacyTerrain !== false, + originX, + originY, + width, + height, + variant, + contextRect, + hasBoundaryWorld: !!options.boundaryWorld, + }; +} + +function mixUint(h, value) { + h = Math.imul((h ^ (value >>> 0)) >>> 0, 2246822519) >>> 0; + h ^= h >>> 13; + return Math.imul(h, 3266489917) >>> 0; +} + +function mixString(h, value) { + const text = String(value ?? ""); + for (let i = 0; i < text.length; i++) h = mixUint(h, text.charCodeAt(i)); + return h >>> 0; +} + +function contextualSeed(seed, context, options = {}) { + let h = seed >>> 0; + if (!context.worldNative && !context.variant && !context.originX && !context.originY) return h; + h = mixString(h, options.terrainType || options.generationType || "auto"); + h = mixUint(h, context.variant); + h = mixUint(h, context.originX | 0); + h = mixUint(h, context.originY | 0); + h = mixUint(h, context.width); + h = mixUint(h, context.height); + if (context.contextRect) { + h = mixUint(h, context.contextRect.x0 | 0); + h = mixUint(h, context.contextRect.y0 | 0); + h = mixUint(h, context.contextRect.x1 | 0); + h = mixUint(h, context.contextRect.y1 | 0); + } + return h >>> 0; +} + +function makeRuntimeOptions(options, baseSeed) { + const generationContext = normalizeGenerationContext(options); + const effectiveSeed = contextualSeed(baseSeed, generationContext, options); + return { + ...options, + generationContext, + baseSeed, + effectiveSeed, + originX: generationContext.originX, + originY: generationContext.originY, + width: generationContext.width, + height: generationContext.height, + variant: generationContext.variant, + }; +} + function generateInitialTerrain(seed, options = {}) { - // The original high-detail terrain system remains the default for full-map - // generation. Rect-native terrain is available as an explicit option and is - // used by patch generation, but the historical noise/natural-compartment - // pipeline is still the visual baseline for ordinary maps. - if (options?.rectNativeInitial === true) return generateInitialTerrainRect(seed, options); + // Production generation is intentionally pinned to the legacy high-detail + // terrain/natural-compartment pipeline. Rect-native terrain helpers may remain + // in the codebase for experiments, but they are not reachable from generateMap. return generateTerrainAndRivers(seed, options); } -function terrainStageLabel(options = {}) { - return options?.rectNativeInitial === true - ? "Rect-native terrain, rivers, and natural compartments" - : "Terrain, rivers, and natural compartments"; +function terrainStageLabel() { + return "Terrain, rivers, and natural compartments"; } function nowMs() { @@ -60,7 +132,9 @@ async function timedStageAsync(timings, options, key, label, fn) { export function generateMap(seedInput = 114514, options = {}) { - const seed = Number(seedInput) >>> 0; + const baseSeed = Number(seedInput) >>> 0; + options = makeRuntimeOptions(options, baseSeed); + const seed = options.effectiveSeed; if (typeof options.onProgress !== "function") options = { ...options, onProgress: () => {} }; const generationTimings = []; @@ -86,10 +160,10 @@ export function generateMap(seedInput = 114514, options = {}) { naturalCompartments, } = terrain; - const geographyBasis = stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain)); - const terrainWithGeography = { ...terrain, geography: geographyBasis }; + const geographyBasis = stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain, options)); + const terrainWithGeography = { ...terrain, geography: geographyBasis, generationContext: options.generationContext }; - const features = stage("settlements", "Settlements, towns, ports, and land-use demand", () => generateMapFeatures(seed, terrainWithGeography)); + const features = stage("settlements", "Settlements, towns, ports, and land-use demand", () => generateMapFeatures(seed, terrainWithGeography, options)); const { settlementScore, villages, @@ -109,10 +183,10 @@ export function generateMap(seedInput = 114514, options = {}) { villageInfluence, } = features; - const geography = stage("geographyFinal", "Unified accessibility and centrality fields", () => finalizeGeographicBasis(seed, terrain, features, geographyBasis)); + const geography = stage("geographyFinal", "Unified accessibility and centrality fields", () => finalizeGeographicBasis(seed, terrain, features, geographyBasis, options)); const admin = stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({ - seed, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, + seed, generationContext: options.generationContext, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, geography, habitability: geography.habitability, accessibility: geography.accessibility, centrality: geography.centrality, geographicBarrier: geography.geographicBarrier, geographicBarrierCost: geography.geographicBarrierCost, adminBoundaryPreference: geography.adminBoundaryPreference, boundaryAvoidance: geography.boundaryAvoidance, settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks, adminProgress: (event) => options?.onProgress?.({ @@ -127,7 +201,7 @@ export function generateMap(seedInput = 114514, options = {}) { }), })); - stage("transport", "Administrative-aware final transport network", () => finalizeAdminAwareTransport({ seed, terrain, features, admin, geography })); + stage("transport", "Administrative-aware final transport network", () => finalizeAdminAwareTransport({ seed, terrain, features, admin, geography, generationContext: options.generationContext })); const output = stage("output", "Names, population totals, and final packaging", () => finishMapOutput({ seed, @@ -139,11 +213,16 @@ export function generateMap(seedInput = 114514, options = {}) { })); output.generationTimings = generationTimings; output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0); + output.baseSeed = options.baseSeed; + output.effectiveSeed = seed; + output.generationContext = { ...options.generationContext }; return output; } export async function generateMapAsync(seedInput = 114514, options = {}) { - const seed = Number(seedInput) >>> 0; + const baseSeed = Number(seedInput) >>> 0; + options = makeRuntimeOptions(options, baseSeed); + const seed = options.effectiveSeed; if (typeof options.onProgress !== "function") options = { ...options, onProgress: () => {} }; const generationTimings = []; @@ -169,10 +248,10 @@ export async function generateMapAsync(seedInput = 114514, options = {}) { naturalCompartments, } = terrain; - const geographyBasis = await stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain)); - const terrainWithGeography = { ...terrain, geography: geographyBasis }; + const geographyBasis = await stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain, options)); + const terrainWithGeography = { ...terrain, geography: geographyBasis, generationContext: options.generationContext }; - const features = await stage("settlements", "Settlements, towns, ports, and land-use demand", () => generateMapFeatures(seed, terrainWithGeography)); + const features = await stage("settlements", "Settlements, towns, ports, and land-use demand", () => generateMapFeatures(seed, terrainWithGeography, options)); const { settlementScore, villages, @@ -192,10 +271,10 @@ export async function generateMapAsync(seedInput = 114514, options = {}) { villageInfluence, } = features; - const geography = await stage("geographyFinal", "Unified accessibility and centrality fields", () => finalizeGeographicBasis(seed, terrain, features, geographyBasis)); + const geography = await stage("geographyFinal", "Unified accessibility and centrality fields", () => finalizeGeographicBasis(seed, terrain, features, geographyBasis, options)); const admin = await stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({ - seed, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, + seed, generationContext: options.generationContext, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, geography, habitability: geography.habitability, accessibility: geography.accessibility, centrality: geography.centrality, geographicBarrier: geography.geographicBarrier, geographicBarrierCost: geography.geographicBarrierCost, adminBoundaryPreference: geography.adminBoundaryPreference, boundaryAvoidance: geography.boundaryAvoidance, settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks, adminProgress: (event) => options?.onProgress?.({ @@ -210,7 +289,7 @@ export async function generateMapAsync(seedInput = 114514, options = {}) { }), })); - await stage("transport", "Administrative-aware final transport network", () => finalizeAdminAwareTransport({ seed, terrain, features, admin, geography })); + await stage("transport", "Administrative-aware final transport network", () => finalizeAdminAwareTransport({ seed, terrain, features, admin, geography, generationContext: options.generationContext })); const output = await stage("output", "Names, population totals, and final packaging", () => finishMapOutput({ seed, @@ -222,5 +301,8 @@ export async function generateMapAsync(seedInput = 114514, options = {}) { })); output.generationTimings = generationTimings; output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0); + output.baseSeed = options.baseSeed; + output.effectiveSeed = seed; + output.generationContext = { ...options.generationContext }; return output; } diff --git a/mapTerrain.js b/mapTerrain.js index d11fc59..497084e 100644 --- a/mapTerrain.js +++ b/mapTerrain.js @@ -1816,6 +1816,11 @@ export function generateInitialTerrainRect(seed, options = {}) { export function generateTerrainAndRivers(seed, options = {}) { + const generationContext = options.generationContext || {}; + const originX = Math.floor(Number.isFinite(options.originX) ? options.originX : (Number.isFinite(generationContext.originX) ? generationContext.originX : 0)); + const originY = Math.floor(Number.isFinite(options.originY) ? options.originY : (Number.isFinite(generationContext.originY) ? generationContext.originY : 0)); + const variant = Math.max(0, Math.floor(Number.isFinite(options.variant) ? options.variant : (Number.isFinite(generationContext.variant) ? generationContext.variant : 0))) >>> 0; + const worldNative = options.worldNative === true || generationContext.worldNative === true; const fields = createMapFields(); fields.visibleRavineField = new Float32Array(SIZE); fields.surfaceTextureField = new Float32Array(SIZE); @@ -1835,9 +1840,11 @@ export function generateTerrainAndRivers(seed, options = {}) { for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { const i = indexOf(x, y); + const wx = originX + x; + const wy = originY + y; const { px, py } = normalizeCoord(x, y); - const terrainLarge = (fbm(x * 0.65, y * 0.65, seed + 1) - 0.5) * 0.23; - const terrainRegional = (valueNoise(x * 0.8, y * 0.8, seed + 2, 42) - 0.5) * 0.16; + const terrainLarge = (fbm(wx * 0.65, wy * 0.65, seed + 1) - 0.5) * 0.23; + const terrainRegional = (valueNoise(wx * 0.8, wy * 0.8, seed + 2, 42) - 0.5) * 0.16; const { pressure: coastPressure } = computeCoastLower(px, py, terrainTemplate, seed); let e = 0.42 + terrainLarge + terrainRegional - coastPressure * (terrainTemplate.coastStrength ?? 0.96) * (0.22 + terrainTemplate.deposition * 0.040); let mountainMaskMax = 0; @@ -1862,7 +1869,7 @@ export function generateTerrainAndRivers(seed, options = {}) { const dx = (px - 0.5) * ASPECT; const dy = py - 0.5; const { u, v } = rotate(dx, dy, terrainTemplate.mountainAngle); - const warp = (valueNoise(x * 0.50, y * 0.50, seed + 504, 28) - 0.5) * 18; + const warp = (valueNoise(wx * 0.50, wy * 0.50, seed + 504, 28) - 0.5) * 18; macro = (fbm(u * 520 + warp, v * 1850 - warp * 0.35, seed + 500) - 0.5) * 2; scratch = (fbm(u * 920 + warp * 0.8, v * 2300 + warp * 0.25, seed + 502) - 0.5) * 2; const passBreak = clamp((valueNoise(u * 760 + 17, v * 1800 - 11, seed + 505, 18) - 0.62) * 2.6); @@ -1870,10 +1877,10 @@ export function generateTerrainAndRivers(seed, options = {}) { e += lateralBranch * mountainMaskMax * 0.022; e -= passBreak * mountainMaskMax * 0.052; } else { - macro = (fbm(x * terrainTemplate.macroNoiseScale * 48, y * terrainTemplate.macroNoiseScale * 48, seed + 500) - 0.5) * 2; - scratch = (fbm(x * 2.2, y * 2.2, seed + 502) - 0.5) * 2; + macro = (fbm(wx * terrainTemplate.macroNoiseScale * 48, wy * terrainTemplate.macroNoiseScale * 48, seed + 500) - 0.5) * 2; + scratch = (fbm(wx * 2.2, wy * 2.2, seed + 502) - 0.5) * 2; } - const global = (valueNoise(x * 0.23, y * 0.23, seed + 501, 38) - 0.5) * 2; + const global = (valueNoise(wx * 0.23, wy * 0.23, seed + 501, 38) - 0.5) * 2; e += macro * terrainTemplate.macroNoiseStrength * (0.38 + mountainMaskMax * 0.80); e += global * 0.020; e += scratch * terrainTemplate.scratchNoiseStrength * mountainMaskMax; @@ -1886,7 +1893,7 @@ export function generateTerrainAndRivers(seed, options = {}) { if (terrainTemplate.terrainType === "setouchi_inland_sea") { // Setouchi maps should have many low hills and island backbones rather // than a few high alpine ridges. Add broad low relief, then cap peaks. - const lowHillNoise = clamp((fbm(x * 0.95 + 17, y * 0.95 - 23, seed + 571) - 0.38) * 2.9); + const lowHillNoise = clamp((fbm(wx * 0.95 + 17, wy * 0.95 - 23, seed + 571) - 0.38) * 2.9); const lowHillMask = clamp(0.34 + mountainMaskMax * 0.72 + lowHillNoise * 0.50 - coastPressure * 0.12); e += lowHillMask * 0.145; // Do not add the previous fine speckle uplift here: it created too many @@ -1897,7 +1904,7 @@ export function generateTerrainAndRivers(seed, options = {}) { } if (terrainTemplate.terrainType === "oceanic_archipelago") { // 海洋型は大きな山塊ではなく、島列を読むための低い起伏を点在させる。 - const islandCore = clamp((fbm(x * 0.82 + 41, y * 0.82 - 29, seed + 572) - 0.46) * 2.7); + const islandCore = clamp((fbm(wx * 0.82 + 41, wy * 0.82 - 29, seed + 572) - 0.46) * 2.7); const islandChain = clamp(mountainMaskMax * 0.92 + islandCore * 0.54 - coastPressure * 0.24); e += islandChain * 0.135; e -= clamp((coastPressure - 0.38) * 1.55) * 0.040; @@ -1910,7 +1917,7 @@ export function generateTerrainAndRivers(seed, options = {}) { visibleRavineField[i] = clamp(Math.abs(scratch) * mountainMaskMax * 0.30 + Math.max(0, -scratch) * mountainMaskMax * 0.40); surfaceTextureField[i] = clamp(Math.abs(macro) * 0.12 + Math.abs(scratch) * mountainMaskMax * 0.46); valleyField[i] = clamp(Math.max(0, -scratch) * mountainMaskMax * 0.18); - moisture[i] = clamp(0.45 + coastPressure * 0.28 - elevation[i] * 0.20 + (fbm(x * 1.1, y * 1.1, seed + 503) - 0.5) * 0.16); + moisture[i] = clamp(0.45 + coastPressure * 0.28 - elevation[i] * 0.20 + (fbm(wx * 1.1, wy * 1.1, seed + 503) - 0.5) * 0.16); } } @@ -1972,9 +1979,20 @@ export function generateTerrainAndRivers(seed, options = {}) { mountainRatio: mountainCount / Math.max(1, landCount), plainRatio: plainCount / Math.max(1, landCount), mountainSystemCount: systems.length, + originX, + originY, + width: MAP_W, + height: MAP_H, + variant, + worldNative, }; return { + originX, + originY, + width: MAP_W, + height: MAP_H, + generationContext: { ...generationContext, originX, originY, width: MAP_W, height: MAP_H, variant, worldNative }, terrainTemplate, seaLevel, elevation, diff --git a/mapTransport.js b/mapTransport.js index 3f0bdb0..887bd6f 100644 --- a/mapTransport.js +++ b/mapTransport.js @@ -10,12 +10,15 @@ import { makeSpatialIndex, makeUnionFind, meanFieldAround as meanFieldAroundBase, + nearestNetworkPoint, packDebugField, pathCumulativeLengths, pathAverageField, + pathEndpoints, pathLengthCells, pointAtPathDistance, sampledNetworkCells as sampledNetworkCellsBase, + splitPathToValidCells as splitPathToValidCellsBase, squaredDistance, TRANSPORT_ROUTE_POLICIES, } from "./mapTransportUtils.js"; @@ -1332,55 +1335,20 @@ export function buildDensityFlowRoadTransportSystem(ctx) { function stitchRasterNearContacts() { const debug = { expressway: 0, national: 0, localToNational: 0, local: 0, broadNearMisses: 0 }; - function sampledCells(paths, step = 1) { - const cells = []; - for (let pathId = 0; pathId < (paths || []).length; pathId++) { - const path = paths[pathId]; - for (let k = 0; k < path.length; k += step) { - const [x, y] = path[k]; - if (inside(x, y) && !sea[indexOf(x, y)]) cells.push({ x, y, pathId }); - } - } - return cells; - } - function endpointListWithIds(paths) { - const out = []; - for (let pathId = 0; pathId < (paths || []).length; pathId++) { - const p = paths[pathId]; - if (!p || p.length < 2) continue; - out.push({ x: p[0][0], y: p[0][1], pathId }); - const q = p[p.length - 1]; - out.push({ x: q[0], y: q[1], pathId }); - } - return out; - } - function nearestWithin(source, targets, radius, excludeSamePath = true) { - let best = null; - let bestD = radius + 1; - for (const t of targets) { - if (excludeSamePath && source.pathId != null && t.pathId === source.pathId) continue; - const d = Math.hypot(source.x - t.x, source.y - t.y); - if (d > 0.01 && d < bestD) { - bestD = d; - best = t; - } - } - return best ? { target: best, d: bestD } : null; - } function stitchEndpoints(paths, mode, targets, maxAdds, radius, probability = 1) { let added = 0; - const endpoints = endpointListWithIds(paths); + const endpoints = pathEndpoints(paths); for (const ep of endpoints) { if (added >= maxAdds) break; if (probability < 1 && hash2(ep.x, ep.y, seed + 18331 + added * 17) > probability) continue; - const near = nearestWithin(ep, targets, radius, true); + const near = nearestNetworkPoint(ep, targets, radius); if (near && addShortConnector(paths, ep, near.target, mode)) added++; } return added; } - const expresswayCells = sampledCells(expressways, 1); - const nationalCells = sampledCells([...nationalRoads, ...externalRoads], 1); - const localCells = sampledCells(minorRoads, 1); + const expresswayCells = sampledNetworkCellsBase(expressways, 1, sea); + const nationalCells = sampledNetworkCellsBase([...nationalRoads, ...externalRoads], 1, sea); + const localCells = sampledNetworkCellsBase(minorRoads, 1, sea); debug.expressway += stitchEndpoints(expressways, "expressway", expresswayCells, 14, 46.0, 0.62); debug.national += stitchEndpoints(nationalRoads, "national", nationalCells, 48, 14.0); debug.localToNational += stitchEndpoints(minorRoads, "local", nationalCells, 240, 20.0); @@ -1390,11 +1358,11 @@ export function buildDensityFlowRoadTransportSystem(ctx) { // not share a raster cell. This addresses line simplification and diagonal // near-contact cases not caught by endpoint-only repair. const allLocalTargets = [...nationalCells, ...localCells]; - const candidates = sampledCells([...nationalRoads, ...minorRoads], 3) + const candidates = sampledNetworkCellsBase([...nationalRoads, ...minorRoads], 3, sea) .sort((a, b) => hash2(a.x, a.y, seed + 18377) - hash2(b.x, b.y, seed + 18377)); for (const c of candidates) { if (debug.broadNearMisses >= 260) break; - const near = nearestWithin(c, allLocalTargets, 4.75, true); + const near = nearestNetworkPoint(c, allLocalTargets, 4.75); if (!near) continue; const out = c.pathId < nationalRoads.length ? nationalRoads : minorRoads; const mode = out === nationalRoads ? "national" : "local"; @@ -1410,56 +1378,21 @@ export function buildDensityFlowRoadTransportSystem(ctx) { function stitchLongLocalBranches() { const debug = { localToTrunk: 0, localToLocal: 0 }; - function cells(paths, step = 2) { - const out = []; - for (let pathId = 0; pathId < (paths || []).length; pathId++) { - const path = paths[pathId]; - for (let k = 0; k < path.length; k += step) { - const [x, y] = path[k]; - if (inside(x, y) && !sea[indexOf(x, y)]) out.push({ x, y, pathId }); - } - } - return out; - } - function endpoints(paths) { - const out = []; - for (let pathId = 0; pathId < (paths || []).length; pathId++) { - const p = paths[pathId]; - if (!p || p.length < 2) continue; - out.push({ x: p[0][0], y: p[0][1], pathId }); - const q = p[p.length - 1]; - out.push({ x: q[0], y: q[1], pathId }); - } - return out; - } - function nearest(source, targets, radius, excludePath = null) { - let best = null; - let bestD = radius + 1; - for (const t of targets) { - if (excludePath != null && t.pathId === excludePath) continue; - const d = Math.hypot(source.x - t.x, source.y - t.y); - if (d > 0.01 && d < bestD) { - bestD = d; - best = t; - } - } - return best ? { ...best, d: bestD } : null; - } - const trunkCells = cells([...nationalRoads, ...externalRoads], 2); - const localCells = cells(minorRoads, 2); - const eps = endpoints(minorRoads) + const trunkCells = sampledNetworkCellsBase([...nationalRoads, ...externalRoads], 2, sea); + const localCells = sampledNetworkCellsBase(minorRoads, 2, sea); + const eps = pathEndpoints(minorRoads) .sort((a, b) => hash2(a.x, a.y, seed + 18491) - hash2(b.x, b.y, seed + 18491)); for (const ep of eps) { if (debug.localToTrunk >= 160) break; - const hit = nearest(ep, trunkCells, 20, null); + const hit = nearestNetworkPoint(ep, trunkCells, 20, { excludeSamePath: false }); if (!hit) continue; if (addShortConnector(minorRoads, ep, hit, "local")) debug.localToTrunk++; } - const eps2 = endpoints(minorRoads) + const eps2 = pathEndpoints(minorRoads) .sort((a, b) => hash2(a.x, a.y, seed + 18493) - hash2(b.x, b.y, seed + 18493)); for (const ep of eps2) { if (debug.localToLocal >= 150) break; - const hit = nearest(ep, localCells, 17, ep.pathId); + const hit = nearestNetworkPoint(ep, localCells, 17, { excludePath: ep.pathId }); if (!hit) continue; if (addShortConnector(minorRoads, ep, hit, "local")) debug.localToLocal++; } @@ -1929,33 +1862,11 @@ export function buildDensityFlowRoadTransportSystem(ctx) { return transportFields.local; } function splitPathToValidCells(path, costField, minCells = 2) { - const chunks = []; - let cur = []; - function valid(x, y) { + return splitPathToValidCellsBase(path, (x, y) => { if (!inside(x, y)) return false; const i = indexOf(x, y); return !sea[i] && !highAltitudeRoadClosed(i) && costField[i] < INF; - } - function pushPoint(x, y) { - if (!valid(x, y)) { - if (cur.length >= minCells) chunks.push(cur); - cur = []; - return; - } - if (!cur.length || cur[cur.length - 1][0] !== x || cur[cur.length - 1][1] !== y) cur.push([x, y]); - } - for (let k = 0; k < (path?.length || 0); k++) { - const a = path[k]; - const b = path[Math.min(k + 1, path.length - 1)]; - const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1]))); - for (let s = 0; s <= steps; s++) { - if (k > 0 && s === 0) continue; - const t = s / steps; - pushPoint(Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t)); - } - } - if (cur.length >= minCells) chunks.push(cur); - return chunks; + }, minCells); } function normalizeRoadGroups() { const groups = roadGroups(); diff --git a/mapTransportUtils.js b/mapTransportUtils.js index 749420c..580c49f 100644 --- a/mapTransportUtils.js +++ b/mapTransportUtils.js @@ -95,6 +95,61 @@ export function sampledNetworkCells(paths, step = 2, sea = null) { return cells; } +export function pathEndpoints(paths) { + const endpoints = []; + for (let pathId = 0; pathId < (paths || []).length; pathId++) { + const path = paths[pathId]; + if (!path || path.length < 2) continue; + endpoints.push({ x: path[0][0], y: path[0][1], pathId }); + const end = path[path.length - 1]; + endpoints.push({ x: end[0], y: end[1], pathId }); + } + return endpoints; +} + +export function nearestNetworkPoint(source, targets, radius, options = {}) { + if (!source || !targets?.length) return null; + const excludeSamePath = options.excludeSamePath !== false; + const excludePath = options.excludePath; + let best = null; + let bestD = radius + 1; + for (const target of targets) { + if (excludePath != null && target.pathId === excludePath) continue; + if (excludeSamePath && source.pathId != null && target.pathId === source.pathId) continue; + const d = Math.hypot(source.x - target.x, source.y - target.y); + if (d > 0.01 && d < bestD) { + bestD = d; + best = target; + } + } + return best ? { target: best, d: bestD, ...best } : null; +} + +export function splitPathToValidCells(path, isValid, minCells = 2) { + const chunks = []; + let current = []; + function pushPoint(x, y) { + if (!isValid(x, y)) { + if (current.length >= minCells) chunks.push(current); + current = []; + return; + } + if (!current.length || current[current.length - 1][0] !== x || current[current.length - 1][1] !== y) current.push([x, y]); + } + for (let k = 0; k < (path?.length || 0); k++) { + const a = path[k]; + const b = path[Math.min(k + 1, path.length - 1)]; + const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1]))); + for (let s = 0; s <= steps; s++) { + if (k > 0 && s === 0) continue; + const t = s / steps; + pushPoint(Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t)); + } + } + if (current.length >= minCells) chunks.push(current); + return chunks; +} + export function pathCumulativeLengths(path) { const cum = [0]; for (let k = 1; k < (path?.length || 0); k++) { diff --git a/renderer.js b/renderer.js index c756360..61ac178 100644 --- a/renderer.js +++ b/renderer.js @@ -1,12 +1,37 @@ -import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf, inside } from "./mapUtils.js"; +import { CELL_SIZE, MAP_H, MAP_W, clamp } from "./mapUtils.js"; const segmentVectorCache = new WeakMap(); const pathVectorCache = new WeakMap(); const coastlineCache = new WeakMap(); +const rasterBorderCache = new WeakMap(); const baseImageCache = new WeakMap(); const MAX_BASE_CACHE_IMAGES = 4; +function mapWidth(map) { + return Math.max(1, Math.floor(Number.isFinite(map?.width) ? map.width : MAP_W)); +} + +function mapHeight(map) { + return Math.max(1, Math.floor(Number.isFinite(map?.height) ? map.height : MAP_H)); +} + +function cellIndex(map, x, y) { + return y * mapWidth(map) + x; +} + +function insideMap(map, x, y) { + return x >= 0 && y >= 0 && x < mapWidth(map) && y < mapHeight(map); +} + +function mapPixelWidth(map) { + return mapWidth(map) * CELL_SIZE; +} + +function mapPixelHeight(map) { + return mapHeight(map) * CELL_SIZE; +} + function pointKey(p) { return `${p[0]},${p[1]}`; } @@ -183,16 +208,18 @@ function getCoastlineSegments(map) { if (cached) return cached; const segments = []; - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); + const w = mapWidth(map); + const h = mapHeight(map); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const i = cellIndex(map, x, y); const a = Boolean(map.sea[i]); - if (x + 1 < MAP_W) { - const b = Boolean(map.sea[indexOf(x + 1, y)]); + if (x + 1 < w) { + const b = Boolean(map.sea[cellIndex(map, x + 1, y)]); if (a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]); } - if (y + 1 < MAP_H) { - const b = Boolean(map.sea[indexOf(x, y + 1)]); + if (y + 1 < h) { + const b = Boolean(map.sea[cellIndex(map, x, y + 1)]); if (a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]); } } @@ -244,10 +271,10 @@ function drawVectorSegments(ctx, segments, color, width, dashed = false, vectorO ctx.restore(); } -function sampleCellIndex(fx, fy) { - const x = Math.max(0, Math.min(MAP_W - 1, Math.floor(fx))); - const y = Math.max(0, Math.min(MAP_H - 1, Math.floor(fy))); - return indexOf(x, y); +function sampleCellIndex(map, fx, fy) { + const x = Math.max(0, Math.min(mapWidth(map) - 1, Math.floor(fx))); + const y = Math.max(0, Math.min(mapHeight(map) - 1, Math.floor(fy))); + return cellIndex(map, x, y); } function seaCoverageSample(map, fx, fy) { @@ -261,7 +288,7 @@ function seaCoverageSample(map, fx, fy) { [-0.26, -0.26], [0.26, -0.26], [-0.26, 0.26], [0.26, 0.26], ]; let sum = 0; - for (const [ox, oy] of offsets) sum += fieldSample(map.sea, fx + ox, fy + oy); + for (const [ox, oy] of offsets) sum += fieldSample(map, map.sea, fx + ox, fy + oy); return clamp(sum / offsets.length); } @@ -293,20 +320,21 @@ function blendOutside(color, isInside) { ]; } -function fieldSample(field, fx, fy) { - const sx = Math.max(0, Math.min(MAP_W - 1, fx)); - const sy = Math.max(0, Math.min(MAP_H - 1, fy)); +function fieldSample(map, field, fx, fy) { + if (!field) return 0; + const sx = Math.max(0, Math.min(mapWidth(map) - 1, fx)); + const sy = Math.max(0, Math.min(mapHeight(map) - 1, fy)); const x0 = Math.floor(sx); const y0 = Math.floor(sy); - const x1 = Math.max(0, Math.min(MAP_W - 1, x0 + 1)); - const y1 = Math.max(0, Math.min(MAP_H - 1, y0 + 1)); + const x1 = Math.max(0, Math.min(mapWidth(map) - 1, x0 + 1)); + const y1 = Math.max(0, Math.min(mapHeight(map) - 1, y0 + 1)); const tx = sx - x0; const ty = sy - y0; - const a = field[indexOf(x0, y0)]; - const b = field[indexOf(x1, y0)]; - const c = field[indexOf(x0, y1)]; - const d = field[indexOf(x1, y1)]; + const a = field[cellIndex(map, x0, y0)] || 0; + const b = field[cellIndex(map, x1, y0)] || 0; + const c = field[cellIndex(map, x0, y1)] || 0; + const d = field[cellIndex(map, x1, y1)] || 0; return ((a * (1 - tx) + b * tx) * (1 - ty)) + ((c * (1 - tx) + d * tx) * ty); } @@ -329,20 +357,20 @@ function interpolateColorStops(value, stops) { } function terrainColorContinuous(map, fx, fy, mode) { - const i = sampleCellIndex(fx, fy); + const i = sampleCellIndex(map, fx, fy); const isInside = Boolean(map.prefectureMask[i]); let color; const waterCoverage = seaCoverageSample(map, fx, fy); - const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map.elevation, fx, fy)) * 2.4); + const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map, map.elevation, fx, fy)) * 2.4); const waterColor = [Math.round(174 - depth * 7), Math.round(203 + depth * 2), Math.round(224 + depth * 5)]; let landColor; if (mode === "development") { const dCity = distToNearest(map.modernCities, fx, fy); const urban = clamp(1 - dCity / 25); - const density = map.populationDensity ? fieldSample(map.populationDensity, fx, fy) : urban; + const density = map.populationDensity ? fieldSample(map, map.populationDensity, fx, fy) : urban; const base = 235; landColor = [ Math.round(base + density * 20), @@ -352,7 +380,7 @@ function terrainColorContinuous(map, fx, fy, mode) { } else { // 地形の基底色は標高のみに従わせる。 // 谷や微地形の見え方は陰影側で制御し、谷底だけが不自然に茶色化しないようにする。 - const e = fieldSample(map.elevation, fx, fy); + const e = fieldSample(map, map.elevation, fx, fy); landColor = interpolateColorStops(clamp(e), [ [0.20, [231, 236, 223]], [0.30, [223, 231, 214]], @@ -378,11 +406,11 @@ function terrainColorContinuous(map, fx, fy, mode) { function terrainShadeContinuous(map, fx, fy) { const step = 0.50; - const eC = fieldSample(map.elevation, fx, fy); - const eL = fieldSample(map.elevation, fx - step, fy); - const eR = fieldSample(map.elevation, fx + step, fy); - const eU = fieldSample(map.elevation, fx, fy - step); - const eD = fieldSample(map.elevation, fx, fy + step); + const eC = fieldSample(map, map.elevation, fx, fy); + const eL = fieldSample(map, map.elevation, fx - step, fy); + const eR = fieldSample(map, map.elevation, fx + step, fy); + const eU = fieldSample(map, map.elevation, fx, fy - step); + const eD = fieldSample(map, map.elevation, fx, fy + step); // x は東向き, y は南向き。法線は (-dz/dx, -dz/dy, 1)。 // 描画では地形生成上の差分をやや誇張し、粗いDEMでも山腹の起伏を読ませる。 @@ -398,14 +426,14 @@ function terrainShadeContinuous(map, fx, fy) { const lz = 0.7071067811865476; const hill = clamp((nx * lx + ny * ly + nz * lz) / nLen * 0.58 + 0.48); - const slope = map.slope ? fieldSample(map.slope, fx, fy) : 0; - const valley = map.valleyField ? fieldSample(map.valleyField, fx, fy) : 0; - const ravine = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy) : 0; - const tex = map.surfaceTextureField ? fieldSample(map.surfaceTextureField, fx, fy) : 0; - const rvL = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx - 0.90, fy) : 0; - const rvR = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx + 0.90, fy) : 0; - const rvU = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy - 0.90) : 0; - const rvD = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy + 0.90) : 0; + const slope = map.slope ? fieldSample(map, map.slope, fx, fy) : 0; + const valley = map.valleyField ? fieldSample(map, map.valleyField, fx, fy) : 0; + const ravine = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx, fy) : 0; + const tex = map.surfaceTextureField ? fieldSample(map, map.surfaceTextureField, fx, fy) : 0; + const rvL = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx - 0.90, fy) : 0; + const rvR = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx + 0.90, fy) : 0; + const rvU = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx, fy - 0.90) : 0; + const rvD = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx, fy + 0.90) : 0; const ravineRelief = (rvL - rvR) * 0.26 + (rvU - rvD) * 0.20; const concavity = clamp(((eL + eR + eU + eD) * 0.25 - eC) * 9.0, -0.18, 0.18); @@ -419,7 +447,7 @@ function terrainShadeContinuous(map, fx, fy) { } function discreteColor(map, x, y, mode) { - const i = indexOf(x, y); + const i = cellIndex(map, x, y); let color; if (map.sea[i]) { @@ -451,35 +479,39 @@ function discreteColor(map, x, y, mode) { return blendOutside(color, Boolean(map.prefectureMask[i])); } -function baseCacheKey(mode, continuousTerrain) { +function baseCacheKey(mode, continuousTerrain, renderScale = 1) { const continuousModes = ["terrain", "development", "all"]; + const scaleKey = Math.round((renderScale || 1) * 20) / 20; if (continuousTerrain && continuousModes.includes(mode)) { - return `continuous:${mode === "all" ? "terrain" : mode}`; + return `continuous:${mode === "all" ? "terrain" : mode}:${scaleKey}`; } - return `discrete:${mode}`; + return `discrete:${mode}:${scaleKey}`; } -function getCachedBaseImage(ctx, map, mode, continuousTerrain) { +function getCachedBaseCanvas(ctx, map, mode, continuousTerrain, renderScale = 1) { let cache = baseImageCache.get(map); if (!cache) { cache = new Map(); baseImageCache.set(map, cache); } - const key = baseCacheKey(mode, continuousTerrain); - let image = cache.get(key); - if (image) return image; + const key = baseCacheKey(mode, continuousTerrain, renderScale); + let canvas = cache.get(key); + if (canvas) return canvas; - const width = MAP_W * CELL_SIZE; - const height = MAP_H * CELL_SIZE; + const sourceWidth = mapPixelWidth(map); + const sourceHeight = mapPixelHeight(map); + const targetScale = Math.max(0.35, Math.min(1, renderScale || 1)); + const width = Math.max(1, Math.round(sourceWidth * targetScale)); + const height = Math.max(1, Math.round(sourceHeight * targetScale)); const img = ctx.createImageData(width, height); const continuousModes = ["terrain", "development", "all"]; if (continuousTerrain && continuousModes.includes(mode)) { for (let py = 0; py < height; py++) { - const fy = py / CELL_SIZE; + const fy = (py / Math.max(1, height)) * mapHeight(map); for (let px = 0; px < width; px++) { - const fx = px / CELL_SIZE; + const fx = (px / Math.max(1, width)) * mapWidth(map); const [r, g, b] = terrainColorContinuous(map, fx, fy, mode === "all" ? "terrain" : mode); const shade = terrainShadeContinuous(map, fx, fy); @@ -491,8 +523,10 @@ function getCachedBaseImage(ctx, map, mode, continuousTerrain) { } } } else { - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { + const w = mapWidth(map); + const h = mapHeight(map); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { const [r, g, b] = discreteColor(map, x, y, mode); for (let dy = 0; dy < CELL_SIZE; dy++) { for (let dx = 0; dx < CELL_SIZE; dx++) { @@ -506,13 +540,53 @@ function getCachedBaseImage(ctx, map, mode, continuousTerrain) { } } } - cache.set(key, img); + + canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const bctx = canvas.getContext("2d"); + bctx.putImageData(img, 0, 0); + cache.set(key, canvas); if (cache.size > MAX_BASE_CACHE_IMAGES) cache.delete(cache.keys().next().value); - return img; + return canvas; } -function drawBase(ctx, map, mode, continuousTerrain) { - ctx.putImageData(getCachedBaseImage(ctx, map, mode, continuousTerrain), 0, 0); +function drawBase(ctx, map, mode, continuousTerrain, renderScale = 1) { + // putImageData ignores the current transform, so it made the terrain layer + // appear unzoomed while vector layers scaled. Cache the raster into an + // offscreen canvas and draw it with drawImage so wheel zoom applies to terrain. + ctx.imageSmoothingEnabled = true; + ctx.drawImage(getCachedBaseCanvas(ctx, map, mode, continuousTerrain, renderScale), 0, 0, mapPixelWidth(map), mapPixelHeight(map)); +} + + +function getRasterBorderSegments(map, fieldName) { + const field = map?.[fieldName]; + if (!field) return []; + let cacheByField = rasterBorderCache.get(field); + if (cacheByField?.segments) return cacheByField.segments; + const segments = []; + const w = mapWidth(map); + const h = mapHeight(map); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const i = cellIndex(map, x, y); + const id = field[i]; + if (id < 0 || map.sea?.[i]) continue; + if (x + 1 < w) { + const ri = cellIndex(map, x + 1, y); + const rid = field[ri]; + if (!map.sea?.[ri] && rid >= 0 && rid !== id) segments.push([[x + 0.5, y], [x + 0.5, y + 1]]); + } + if (y + 1 < h) { + const di = cellIndex(map, x, y + 1); + const did = field[di]; + if (!map.sea?.[di] && did >= 0 && did !== id) segments.push([[x, y + 0.5], [x + 1, y + 0.5]]); + } + } + } + rasterBorderCache.set(field, { segments }); + return segments; } function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) { @@ -523,8 +597,8 @@ function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) { for (let k = 0; k < path.length - 1; k++) { const [x1, y1] = path[k]; const [x2, y2] = path[k + 1]; - const i1 = indexOf(x1, y1); - const i2 = indexOf(x2, y2); + const i1 = cellIndex(map, Math.round(x1), Math.round(y1)); + const i2 = cellIndex(map, Math.round(x2), Math.round(y2)); const strength = Math.max((map.river?.[i1] || 0) + (map.flowAccum?.[i1] || 0) * 0.95, (map.river?.[i2] || 0) + (map.flowAccum?.[i2] || 0) * 0.95); ctx.strokeStyle = color; ctx.globalAlpha = alpha; @@ -558,7 +632,7 @@ function landOnlySubpaths(map, path, minCells = 2) { let cur = []; for (const p of path) { const [x, y] = p; - const land = inside(x, y) && !map.sea[indexOf(x, y)]; + const land = insideMap(map, x, y) && !map.sea[cellIndex(map, x, y)]; if (land) { cur.push(p); } else if (cur.length >= minCells) { @@ -660,9 +734,11 @@ function drawUrbanAreas(ctx, map, mode) { const cbdColor = "rgba(215, 175, 172, 0.84)"; ctx.save(); - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); + const w = mapWidth(map); + const h = mapHeight(map); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const i = cellIndex(map, x, y); const areaMask = map.humanRegionMask || map.prefectureMask; if (areaMask && !areaMask[i]) continue; const lu = map.landuse[i]; @@ -684,9 +760,11 @@ function drawUrbanAreas(ctx, map, mode) { function drawDebugCells(ctx, map, field, color) { if (!field) return; ctx.save(); - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); + const w = mapWidth(map); + const h = mapHeight(map); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const i = cellIndex(map, x, y); const debugMask = map.humanRegionMask || map.prefectureMask; if (!debugMask[i] || map.sea[i]) continue; const raw = field[i] || 0; @@ -744,9 +822,11 @@ function drawPrefectureRegionFill(ctx, map, mode) { const alpha = mode === "borders-debug" ? 0.34 : 0.18; ctx.save(); ctx.globalAlpha = alpha; - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); + const w = mapWidth(map); + const h = mapHeight(map); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const i = cellIndex(map, x, y); const id = ids[i]; if (map.sea[i] || id < 0) continue; const [r, g, b] = prefectureRegionColor(id); @@ -806,7 +886,7 @@ function labelWithCollision(ctx, p, occupied) { const x = baseX + ox; const y = baseY + oy; const box = { x1: x - 3, y1: y - textH, x2: x + textW + 3, y2: y + 5 }; - if (box.x1 < 0 || box.y1 < 0 || box.x2 > MAP_W * CELL_SIZE || box.y2 > MAP_H * CELL_SIZE) continue; + if (box.x1 < 0 || box.y1 < 0 || box.x2 > (ctx.__mapPixelWidth || MAP_W * CELL_SIZE) || box.y2 > (ctx.__mapPixelHeight || MAP_H * CELL_SIZE)) continue; const overlaps = occupied.filter((b) => boxesOverlap(box, b, isPrefectureLabel ? 5 : isMunicipalityLabel ? 1 : 3)); if (!overlaps.length) { ctx.lineJoin = "round"; @@ -849,11 +929,11 @@ function drawLabels(ctx, points, limit = Infinity, occupied = null) { return used; } -function drawScaleBar(ctx) { +function drawScaleBar(ctx, cellScreenSize = CELL_SIZE) { const kmPerCell = 0.5; const targetKm = 25; const lengthCells = Math.max(8, Math.round(targetKm / kmPerCell)); - const lengthPx = lengthCells * CELL_SIZE; + const lengthPx = lengthCells * cellScreenSize; const margin = 14; const x = margin; const y = margin + 18; @@ -893,23 +973,33 @@ export function drawMap(canvas, map, options) { const showFeatures = options.showFeatures !== false; const showLabels = options.showLabels !== false; const continuousTerrain = options.continuousTerrain !== false; - const zoom = Math.min(Math.max(Number(options.zoom) || 1, 0.55), 2.8); - - const width = MAP_W * CELL_SIZE; - const height = MAP_H * CELL_SIZE; - if (canvas.width !== width) canvas.width = width; - if (canvas.height !== height) canvas.height = height; - ctx.clearRect(0, 0, width, height); + const outputWidth = MAP_W * CELL_SIZE; + const outputHeight = MAP_H * CELL_SIZE; + const sourceWidth = mapPixelWidth(map); + const sourceHeight = mapPixelHeight(map); + const drawScale = Math.min(outputWidth / Math.max(1, sourceWidth), outputHeight / Math.max(1, sourceHeight)); + const drawOffsetX = (outputWidth - sourceWidth * drawScale) * 0.5; + const drawOffsetY = (outputHeight - sourceHeight * drawScale) * 0.5; + const cellScreenSize = CELL_SIZE * drawScale; + const terrainRenderScale = continuousTerrain ? clamp(drawScale * (options.fastTerrain ? 0.48 : 1.35), 0.30, 1) : 1; + + if (canvas.width !== outputWidth) canvas.width = outputWidth; + if (canvas.height !== outputHeight) canvas.height = outputHeight; + ctx.clearRect(0, 0, outputWidth, outputHeight); ctx.save(); - ctx.translate(width * (1 - zoom) * 0.5, height * (1 - zoom) * 0.5); - ctx.scale(zoom, zoom); + ctx.translate(drawOffsetX, drawOffsetY); + ctx.scale(drawScale, drawScale); + ctx.__mapPixelWidth = sourceWidth; + ctx.__mapPixelHeight = sourceHeight; const finish = () => { ctx.restore(); - drawScaleBar(ctx); + drawScaleBar(ctx, cellScreenSize); + delete ctx.__mapPixelWidth; + delete ctx.__mapPixelHeight; }; // 1. Base Terrain & Urban - drawBase(ctx, map, mode, continuousTerrain); + drawBase(ctx, map, mode, continuousTerrain, terrainRenderScale); drawUrbanAreas(ctx, map, mode); const coastSegments = getCoastlineSegments(map); drawVectorSegments(ctx, coastSegments, "rgba(120, 175, 210, 0.22)", 2.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); @@ -926,7 +1016,7 @@ export function drawMap(canvas, map, options) { let tailCount = 0; for (let k = 0; k < path.length; k++) { const [x, y] = path[k]; - const i = indexOf(x, y); + const i = cellIndex(map, Math.round(x), Math.round(y)); const strength = (map.river?.[i] || 0) + (map.flowAccum?.[i] || 0) * 0.75; peak = Math.max(peak, strength); if (k >= tailStart) { @@ -977,9 +1067,11 @@ export function drawMap(canvas, map, options) { const showPrefectureRegions = ["all", "admin", "borders-debug"].includes(mode); if (showPrefectureRegions) drawPrefectureRegionFill(ctx, map, mode); - if (showAdmin && map.adminBorders) { - drawVectorSegments(ctx, map.adminBorders, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 }); - drawVectorSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 }); + const adminBorderSegments = map.adminId ? getRasterBorderSegments(map, "adminId") : map.adminBorders; + const prefectureBorderSegments = map.prefectureRegionId ? getRasterBorderSegments(map, "prefectureRegionId") : map.regionalPrefectureBorders; + 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 }); } if (mode === "borders-debug") { // Keep the natural barrier heatmap subtle. A dense cell fill can look like @@ -990,13 +1082,13 @@ export function drawMap(canvas, map, options) { } if (showTransportDebug) drawTransportDebug(ctx, map); - if (showPrefectureRegions && map.regionalPrefectureBorders) { - drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(255, 255, 255, 0.90)", 4.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); - drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(82, 60, 102, 0.96)", 1.8, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); + if (showPrefectureRegions && prefectureBorderSegments?.length) { + drawVectorSegments(ctx, prefectureBorderSegments, "rgba(255, 255, 255, 0.90)", 4.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); + drawVectorSegments(ctx, prefectureBorderSegments, "rgba(82, 60, 102, 0.96)", 1.8, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); } if (!showPrefectureRegions) { - const finalPrefectureBorders = (map.regionalPrefectureBorders && map.regionalPrefectureBorders.length) ? map.regionalPrefectureBorders : map.prefectureBorder; + const finalPrefectureBorders = (prefectureBorderSegments && prefectureBorderSegments.length) ? prefectureBorderSegments : map.prefectureBorder; drawVectorSegments(ctx, finalPrefectureBorders, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); drawVectorSegments(ctx, finalPrefectureBorders, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); } diff --git a/styles.css b/styles.css index 4aa2bed..7ffa8ee 100644 --- a/styles.css +++ b/styles.css @@ -72,6 +72,9 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px} .progress-timing-row{display:flex;justify-content:space-between;gap:16px;border-top:1px solid rgba(0,0,0,0.06);padding-top:4px} .canvas-shell.panning{cursor:grabbing} +.map-selection-svg{position:absolute;inset:12px;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} diff --git a/worldMap.js b/worldMap.js index 379adfe..4c5fc05 100644 --- a/worldMap.js +++ b/worldMap.js @@ -39,6 +39,24 @@ function makeWorldField(name, source, worldWidth, worldHeight, originX, originY) return out; } + +function sanitizeInitialWorldFields(fields, width, height) { + const sea = fields.sea; + if (!sea) return; + const adminFields = ["adminId", "municipalityId", "prefectureRegionId"]; + for (let i = 0; i < width * height; i++) { + const isSea = Boolean(sea[i]); + if (fields.landMask) fields.landMask[i] = isSea ? 0 : (fields.prefectureMask?.[i] ? 1 : fields.landMask[i]); + if (fields.humanRegionMask && isSea) fields.humanRegionMask[i] = 0; + if (isSea) { + for (const key of adminFields) if (fields[key]) fields[key][i] = -1; + if (fields.landuse) fields.landuse[i] = 0; + } else { + if (fields.municipalityId && fields.adminId && fields.municipalityId[i] < 0 && fields.adminId[i] >= 0) fields.municipalityId[i] = fields.adminId[i]; + } + } +} + export function createWorldMap(initialMap, options = {}) { const paddingX = Number.isFinite(options.paddingX) ? Math.max(0, Math.floor(options.paddingX)) : DEFAULT_WORLD_PADDING_X; const paddingY = Number.isFinite(options.paddingY) ? Math.max(0, Math.floor(options.paddingY)) : DEFAULT_WORLD_PADDING_Y; @@ -60,6 +78,7 @@ export function createWorldMap(initialMap, options = {}) { fields.elevation = new Float32Array(worldWidth * worldHeight); fields.elevation.fill(0.08); } + sanitizeInitialWorldFields(fields, worldWidth, worldHeight); return { seed: initialMap?.seed ?? 0, diff --git a/worldViewport.js b/worldViewport.js index 8cb6272..448a1d9 100644 --- a/worldViewport.js +++ b/worldViewport.js @@ -68,8 +68,8 @@ function copyViewportField(name, source, world, camera, viewWidth, viewHeight) { return out; } -function inViewportPoint(p, margin = 0) { - return p && p.x >= -margin && p.y >= -margin && p.x < MAP_W + margin && p.y < MAP_H + margin; +function inViewportPoint(p, margin = 0, viewWidth = MAP_W, viewHeight = MAP_H) { + return p && p.x >= -margin && p.y >= -margin && p.x < viewWidth + margin && p.y < viewHeight + margin; } function transformPointObject(point, camera, originX, originY) { @@ -83,9 +83,9 @@ function transformPointObject(point, camera, originX, originY) { }; } -function transformPointArray(items, camera, originX, originY, margin = 36, preserveIndexes = false) { +function transformPointArray(items, camera, originX, originY, margin = 36, preserveIndexes = false, viewWidth = MAP_W, viewHeight = MAP_H) { const mapped = (items || []).map((item) => transformPointObject(item, camera, originX, originY)); - return preserveIndexes ? mapped : mapped.filter((item) => inViewportPoint(item, margin)); + return preserveIndexes ? mapped : mapped.filter((item) => inViewportPoint(item, margin, viewWidth, viewHeight)); } function transformTuple(tuple, camera, originX, originY) { @@ -93,16 +93,16 @@ function transformTuple(tuple, camera, originX, originY) { return [tuple[0] + originX - camera.x, tuple[1] + originY - camera.y]; } -function tupleInside(tuple, margin = 0) { - return tuple && tuple[0] >= -margin && tuple[1] >= -margin && tuple[0] < MAP_W + margin && tuple[1] < MAP_H + margin; +function tupleInside(tuple, margin = 0, viewWidth = MAP_W, viewHeight = MAP_H) { + return tuple && tuple[0] >= -margin && tuple[1] >= -margin && tuple[0] < viewWidth + margin && tuple[1] < viewHeight + margin; } -function splitTransformedPath(path, camera, originX, originY, margin = 0) { +function splitTransformedPath(path, camera, originX, originY, margin = 0, viewWidth = MAP_W, viewHeight = MAP_H) { const chunks = []; let current = []; for (const tuple of path || []) { const p = transformTuple(tuple, camera, originX, originY); - const inside = tupleInside(p, margin); + const inside = tupleInside(p, margin, viewWidth, viewHeight); if (inside) { current.push([Math.round(p[0]), Math.round(p[1])]); } else if (current.length >= 2) { @@ -116,23 +116,23 @@ function splitTransformedPath(path, camera, originX, originY, margin = 0) { return chunks; } -function transformPaths(paths, camera, originX, originY) { +function transformPaths(paths, camera, originX, originY, viewWidth = MAP_W, viewHeight = MAP_H) { const out = []; - for (const path of paths || []) out.push(...splitTransformedPath(path, camera, originX, originY)); + for (const path of paths || []) out.push(...splitTransformedPath(path, camera, originX, originY, 0, viewWidth, viewHeight)); return out; } -function segmentIntersectsViewport(seg, margin = 4) { +function segmentIntersectsViewport(seg, margin = 4, viewWidth = MAP_W, viewHeight = MAP_H) { if (!seg || seg.length < 2) return false; const xs = [seg[0][0], seg[1][0]]; const ys = [seg[0][1], seg[1][1]]; - return Math.max(...xs) >= -margin && Math.min(...xs) <= MAP_W + margin && Math.max(...ys) >= -margin && Math.min(...ys) <= MAP_H + margin; + return Math.max(...xs) >= -margin && Math.min(...xs) <= viewWidth + margin && Math.max(...ys) >= -margin && Math.min(...ys) <= viewHeight + margin; } -function transformSegments(segments, camera, originX, originY) { +function transformSegments(segments, camera, originX, originY, viewWidth = MAP_W, viewHeight = MAP_H) { return (segments || []) .map((seg) => [transformTuple(seg?.[0], camera, originX, originY), transformTuple(seg?.[1], camera, originX, originY)]) - .filter(segmentIntersectsViewport); + .filter((seg) => segmentIntersectsViewport(seg, 4, viewWidth, viewHeight)); } @@ -151,18 +151,18 @@ function copySourceMapViewportField(source, camera, originX, originY, viewWidth, return out; } -function transformTransportDebug(debug, camera, originX, originY, viewport = null) { +function transformTransportDebug(debug, camera, originX, originY, viewport = null, viewWidth = MAP_W, viewHeight = MAP_H) { if (!debug?.layers) return debug; const layers = { ...debug.layers }; for (const [key, value] of Object.entries(layers)) { - if (ArrayBuffer.isView(value) && value.length === SIZE) layers[key] = copySourceMapViewportField(value, camera, originX, originY, MAP_W, MAP_H); + if (ArrayBuffer.isView(value) && value.length === SIZE) layers[key] = copySourceMapViewportField(value, camera, originX, originY, viewWidth, viewHeight); } // The original transport-debug potential layers are fixed-map arrays. Once the // viewport pans into patched world cells, synthesize equivalent viewport-sized // debug fields from the current world-backed fields so the color overlay moves // with the terrain instead of staying tied to the initial source map. if (viewport) { - const n = MAP_W * MAP_H; + const n = viewWidth * viewHeight; const make = (fn) => { const out = new Float32Array(n); for (let i = 0; i < n; i++) out[i] = fn(i); @@ -184,14 +184,14 @@ function transformTransportDebug(debug, camera, originX, originY, viewport = nul ...component, cells: (component.cells || []) .map((cell) => transformTuple(cell, camera, originX, originY)) - .filter((cell) => tupleInside(cell, 0)) + .filter((cell) => tupleInside(cell, 0, viewWidth, viewHeight)) .map(([x, y]) => [Math.round(x), Math.round(y)]), })).filter((component) => component.cells.length); } if (Array.isArray(layers.repairedSegments)) { - layers.repairedSegments = layers.repairedSegments.flatMap((repair) => transformPaths([repair.path || []], camera, originX, originY).map((path) => ({ ...repair, path }))); + layers.repairedSegments = layers.repairedSegments.flatMap((repair) => transformPaths([repair.path || []], camera, originX, originY, viewWidth, viewHeight).map((path) => ({ ...repair, path }))); } - if (Array.isArray(layers.unservedSettlements)) layers.unservedSettlements = transformPointArray(layers.unservedSettlements, camera, originX, originY); + if (Array.isArray(layers.unservedSettlements)) layers.unservedSettlements = transformPointArray(layers.unservedSettlements, camera, originX, originY, 36, false, viewWidth, viewHeight); return { ...debug, layers }; } @@ -206,7 +206,7 @@ function buildEmptyViewportFromSource(sourceMap, world, camera, viewWidth, viewH return viewport; } -export function getViewportMap(world, camera, viewWidth = MAP_W, viewHeight = MAP_H) { +export function getViewportMap(world, camera, viewWidth = MAP_W, viewHeight = MAP_H, options = {}) { const sourceMap = world?.sourceMap || {}; const normalizedCamera = { x: Math.round(camera?.x || 0), @@ -218,29 +218,31 @@ export function getViewportMap(world, camera, viewWidth = MAP_W, viewHeight = MA viewport[name] = copyViewportField(name, value, world, normalizedCamera, viewWidth, viewHeight); } + if (options.light) return viewport; + const originX = world?.originX || 0; const originY = world?.originY || 0; for (const key of POINT_ARRAY_KEYS) { if (!Array.isArray(sourceMap[key])) continue; - viewport[key] = transformPointArray(sourceMap[key], normalizedCamera, originX, originY, 36, key === "adminCenters"); + viewport[key] = transformPointArray(sourceMap[key], normalizedCamera, originX, originY, 36, key === "adminCenters", viewWidth, viewHeight); } - for (const key of PATH_ARRAY_KEYS) if (Array.isArray(sourceMap[key])) viewport[key] = transformPaths(sourceMap[key], normalizedCamera, originX, originY); - for (const key of SEGMENT_ARRAY_KEYS) if (Array.isArray(sourceMap[key])) viewport[key] = transformSegments(sourceMap[key], normalizedCamera, originX, originY); + for (const key of PATH_ARRAY_KEYS) if (Array.isArray(sourceMap[key])) viewport[key] = transformPaths(sourceMap[key], normalizedCamera, originX, originY, viewWidth, viewHeight); + for (const key of SEGMENT_ARRAY_KEYS) if (Array.isArray(sourceMap[key])) viewport[key] = transformSegments(sourceMap[key], normalizedCamera, originX, originY, viewWidth, viewHeight); if (sourceMap.adminDebug) { viewport.adminDebug = { ...sourceMap.adminDebug, - compartmentBorders: transformSegments(sourceMap.adminDebug.compartmentBorders || [], normalizedCamera, originX, originY), - lowlandAdminSeeds: transformPointArray(sourceMap.adminDebug.lowlandAdminSeeds || [], normalizedCamera, originX, originY), + compartmentBorders: transformSegments(sourceMap.adminDebug.compartmentBorders || [], normalizedCamera, originX, originY, viewWidth, viewHeight), + lowlandAdminSeeds: transformPointArray(sourceMap.adminDebug.lowlandAdminSeeds || [], normalizedCamera, originX, originY, 36, false, viewWidth, viewHeight), }; } - if (sourceMap.transportDebug) viewport.transportDebug = transformTransportDebug(sourceMap.transportDebug, normalizedCamera, originX, originY, viewport); + if (sourceMap.transportDebug) viewport.transportDebug = transformTransportDebug(sourceMap.transportDebug, normalizedCamera, originX, originY, viewport, viewWidth, viewHeight); if (sourceMap.neighborPrefectureDetails) { viewport.neighborPrefectureDetails = { ...sourceMap.neighborPrefectureDetails, - cities: transformPointArray(sourceMap.neighborPrefectureDetails.cities || [], normalizedCamera, originX, originY), - adminCenters: transformPointArray(sourceMap.neighborPrefectureDetails.adminCenters || [], normalizedCamera, originX, originY), - roads: transformPaths(sourceMap.neighborPrefectureDetails.roads || [], normalizedCamera, originX, originY), + cities: transformPointArray(sourceMap.neighborPrefectureDetails.cities || [], normalizedCamera, originX, originY, 36, false, viewWidth, viewHeight), + adminCenters: transformPointArray(sourceMap.neighborPrefectureDetails.adminCenters || [], normalizedCamera, originX, originY, 36, false, viewWidth, viewHeight), + roads: transformPaths(sourceMap.neighborPrefectureDetails.roads || [], normalizedCamera, originX, originY, viewWidth, viewHeight), }; }