map/app.js

1040 lines
42 KiB
JavaScript
Raw Normal View History

2026-05-24 22:52:22 +09:00
import { generateMapAsync } from "./mapGenerator.js";
2026-05-20 13:50:56 +09:00
import { drawMap } from "./renderer.js";
2026-05-24 17:38:51 +09:00
import { landuseLabel } from "./landuseCodes.js";
2026-05-28 19:37:28 +09:00
import { CELL_SIZE, MAP_H, MAP_W } from "./mapUtils.js";
2026-05-28 23:51:55 +09:00
import { clampCameraToWorld, createInitialCamera, createWorldMap, ensureWorldPaddingForCamera } from "./worldMap.js";
2026-05-28 19:37:28 +09:00
import { getViewportMap } from "./worldViewport.js";
import { PATCH_MIN_AREA, PATCH_MIN_HEIGHT, PATCH_MIN_WIDTH, buildPatchRects, generatePatch, validatePatchRect } from "./mapPatch.js";
2026-05-20 13:50:56 +09:00
const modes = [
["all", "All"],
["terrain", "Terrain"],
["history", "Premodern"],
["modern", "Modern"],
["development", "Development"],
["landuse", "Land Use"],
["admin", "Municipal Borders"],
2026-05-21 13:20:19 +09:00
["borders-debug", "Borders Debug"],
2026-05-26 21:14:37 +09:00
["transport-debug", "Transport Debug"],
2026-05-20 13:50:56 +09:00
];
const state = {
seedText: "114514",
2026-05-28 16:45:00 +09:00
generationType: "auto",
2026-05-20 13:50:56 +09:00
mode: "all",
showFeatures: true,
showLabels: true,
map: null,
2026-05-28 19:37:28 +09:00
world: null,
camera: { x: 0, y: 0 },
viewportMap: null,
2026-05-29 14:31:42 +09:00
viewWidth: MAP_W,
viewHeight: MAP_H,
2026-05-26 16:56:18 +09:00
hoverEntities: [],
2026-05-28 19:37:28 +09:00
selectionRect: null,
2026-05-28 23:51:55 +09:00
patchVariant: 0,
zoom: 1,
2026-05-28 19:37:28 +09:00
lastPatchResult: null,
2026-05-20 13:50:56 +09:00
};
const canvas = document.getElementById("mapCanvas");
2026-05-28 00:30:09 +09:00
const canvasShell = document.querySelector(".canvas-shell");
2026-05-20 13:50:56 +09:00
const seedInput = document.getElementById("seed");
2026-05-28 16:45:00 +09:00
const generationTypeInput = document.getElementById("generationType");
2026-05-28 19:37:28 +09:00
const patchTerrainTypeInput = document.getElementById("patchTerrainType");
2026-05-28 23:51:55 +09:00
const patchVariantInput = document.getElementById("patchVariant");
2026-05-28 19:37:28 +09:00
const generatePatchButton = document.getElementById("generatePatch");
2026-05-28 23:51:55 +09:00
const alternativePatchButton = document.getElementById("alternativePatch");
2026-05-28 19:37:28 +09:00
const patchStatusEl = document.getElementById("patchStatus");
2026-05-20 13:50:56 +09:00
const randomSeedButton = document.getElementById("randomSeed");
const showFeaturesInput = document.getElementById("showFeatures");
const showLabelsInput = document.getElementById("showLabels");
const modeGrid = document.getElementById("modeGrid");
const statsEl = document.getElementById("stats");
const tooltipEl = document.getElementById("mapTooltip");
2026-05-29 14:31:42 +09:00
const selectionSvgEl = document.getElementById("mapSelectionSvg");
2026-05-28 19:37:28 +09:00
const selectionEl = document.getElementById("mapSelection");
2026-05-24 19:33:09 +09:00
const progressEl = document.getElementById("generationProgress");
const progressStageEl = document.getElementById("generationProgressStage");
const progressTimingsEl = document.getElementById("generationProgressTimings");
2026-05-24 22:52:22 +09:00
let generationStartedAt = 0;
let generationCurrentStage = "";
let generationTimer = null;
2026-05-29 14:31:42 +09:00
let zoomRedrawRaf = null;
let zoomSettledTimer = null;
2026-05-20 13:50:56 +09:00
2026-05-28 19:37:28 +09:00
const dragState = {
mode: null,
pointerId: null,
startClientX: 0,
startClientY: 0,
startCameraX: 0,
startCameraY: 0,
selectStart: null,
selectEnd: null,
2026-05-29 14:31:42 +09:00
selectPath: null,
2026-05-28 19:37:28 +09:00
pendingCamera: null,
panRaf: null,
};
function activeMap() {
return state.viewportMap || state.map;
}
2026-05-28 00:30:09 +09:00
2026-05-28 23:51:55 +09:00
function clampZoom(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 1;
return Math.min(Math.max(parsed, 0.55), 2.8);
}
2026-05-29 14:31:42 +09:00
function viewportSizeForZoom(zoom = state.zoom) {
const z = clampZoom(zoom || 1);
2026-05-28 23:51:55 +09:00
return {
2026-05-29 14:31:42 +09:00
width: Math.max(1, Math.ceil(MAP_W / z)),
height: Math.max(1, Math.ceil(MAP_H / z)),
2026-05-28 23:51:55 +09:00
};
}
2026-05-29 14:31:42 +09:00
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);
}
2026-05-28 23:51:55 +09:00
function applyCanvasZoom() {
if (!canvas) return;
state.zoom = clampZoom(state.zoom || 1);
// Keep the canvas element at a stable size. Zoom is applied inside the
// renderer transform, not by resizing the scrollable shell.
canvas.style.width = `${MAP_W * CELL_SIZE}px`;
canvas.style.height = `${MAP_H * CELL_SIZE}px`;
updateSelectionOverlayFromWorldRect();
}
function displayedCellSize() {
2026-05-29 14:31:42 +09:00
return mapCellScreenSize();
2026-05-28 23:51:55 +09:00
}
2026-05-29 14:31:42 +09:00
function screenPointToMapPixel(clientX, clientY, sizeOverride = null) {
2026-05-28 23:51:55 +09:00
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return null;
2026-05-29 14:31:42 +09:00
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 };
2026-05-28 23:51:55 +09:00
}
function mapPixelToScreenPoint(px, py) {
const rect = canvas.getBoundingClientRect();
2026-05-29 14:31:42 +09:00
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);
2026-05-28 23:51:55 +09:00
return {
2026-05-29 14:31:42 +09:00
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)),
2026-05-28 23:51:55 +09:00
};
}
2026-05-29 14:31:42 +09:00
function mapClientToCell(event, sizeOverride = null) {
2026-05-28 19:37:28 +09:00
const map = activeMap();
2026-05-29 14:31:42 +09:00
if (!map && !sizeOverride) return null;
const p = screenPointToMapPixel(event.clientX, event.clientY, sizeOverride);
2026-05-28 23:51:55 +09:00
if (!p) return null;
2026-05-28 00:30:09 +09:00
return {
2026-05-28 23:51:55 +09:00
x: Math.floor(p.x / CELL_SIZE),
y: Math.floor(p.y / CELL_SIZE),
2026-05-28 00:30:09 +09:00
};
}
2026-05-28 19:37:28 +09:00
function viewportCellToWorldCell(cell) {
if (!cell || !state.camera) return null;
return {
x: Math.round(state.camera.x || 0) + cell.x,
y: Math.round(state.camera.y || 0) + cell.y,
};
2026-05-28 00:30:09 +09:00
}
2026-05-28 19:37:28 +09:00
function clampCanvasPoint(event) {
const rect = canvas.getBoundingClientRect();
return {
x: Math.min(Math.max(event.clientX - rect.left, 0), rect.width),
y: Math.min(Math.max(event.clientY - rect.top, 0), rect.height),
};
}
2026-05-29 14:31:42 +09:00
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 = `<polygon points="${pts}" />`;
selectionSvgEl.style.display = "block";
selectionSvgEl.classList.toggle("invalid", !!invalid);
}
function hideSelectionSvg() {
if (!selectionSvgEl) return;
selectionSvgEl.style.display = "none";
selectionSvgEl.innerHTML = "";
selectionSvgEl.classList.remove("invalid");
}
2026-05-28 19:37:28 +09:00
function updateSelectionOverlay() {
2026-05-29 14:31:42 +09:00
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";
2026-05-28 19:37:28 +09:00
if (generatePatchButton) generatePatchButton.disabled = true;
2026-05-28 23:51:55 +09:00
if (alternativePatchButton) alternativePatchButton.disabled = true;
2026-05-28 19:37:28 +09:00
if (patchStatusEl) {
2026-05-29 14:31:42 +09:00
const current = validation.rect || liveShape;
2026-05-28 19:37:28 +09:00
patchStatusEl.textContent = validation.ok
? `Selected: ${formatRectSize(validation.rect)}. Release to confirm patch selection.`
: `${validation.reason} Current: ${formatRectSize(current)}.`;
patchStatusEl.classList.toggle("invalid", !validation.ok);
}
}
function updateSelectionOverlayFromWorldRect() {
2026-05-29 14:31:42 +09:00
if (!state.selectionRect || !state.camera || !activeMap()) return;
2026-05-28 19:37:28 +09:00
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return;
2026-05-29 14:31:42 +09:00
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;
}
2026-05-28 19:37:28 +09:00
const cameraX = Math.round(state.camera.x || 0);
const cameraY = Math.round(state.camera.y || 0);
2026-05-28 23:51:55 +09:00
const p0 = mapPixelToScreenPoint((state.selectionRect.x0 - cameraX) * CELL_SIZE, (state.selectionRect.y0 - cameraY) * CELL_SIZE);
const p1 = mapPixelToScreenPoint((state.selectionRect.x1 - cameraX) * CELL_SIZE, (state.selectionRect.y1 - cameraY) * CELL_SIZE);
const vx0 = p0.x - canvas.offsetLeft;
const vy0 = p0.y - canvas.offsetTop;
const vx1 = p1.x - canvas.offsetLeft;
const vy1 = p1.y - canvas.offsetTop;
2026-05-28 19:37:28 +09:00
const x0 = Math.min(Math.max(Math.min(vx0, vx1), 0), rect.width);
const y0 = Math.min(Math.max(Math.min(vy0, vy1), 0), rect.height);
const x1 = Math.min(Math.max(Math.max(vx0, vx1), 0), rect.width);
const y1 = Math.min(Math.max(Math.max(vy0, vy1), 0), rect.height);
if (x1 - x0 < 1 || y1 - y0 < 1) {
selectionEl.style.display = "none";
2026-05-28 00:30:09 +09:00
return;
}
2026-05-29 14:31:42 +09:00
hideSelectionSvg();
2026-05-28 19:37:28 +09:00
selectionEl.style.display = "block";
selectionEl.style.left = `${canvas.offsetLeft + x0}px`;
selectionEl.style.top = `${canvas.offsetTop + y0}px`;
selectionEl.style.width = `${Math.max(1, x1 - x0)}px`;
selectionEl.style.height = `${Math.max(1, y1 - y0)}px`;
const validation = validatePatchRect(state.selectionRect, state.world);
selectionEl.classList.toggle("invalid", !validation.ok);
}
function formatRectSize(rect) {
if (!rect) return "-";
const w = Math.max(0, rect.x1 - rect.x0);
const h = Math.max(0, rect.y1 - rect.y0);
2026-05-29 14:31:42 +09:00
const area = Math.max(0, rect.areaCells || (w * h));
return `${w} x ${h} cells / ${area.toLocaleString()} cells`;
2026-05-28 19:37:28 +09:00
}
2026-05-28 23:51:55 +09:00
function normalizePatchVariant(value) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? Math.max(0, parsed) >>> 0 : 0;
}
function setPatchVariant(value, { update = true } = {}) {
state.patchVariant = normalizePatchVariant(value);
if (patchVariantInput && patchVariantInput.value !== String(state.patchVariant)) {
patchVariantInput.value = String(state.patchVariant);
}
if (update) updatePatchControls();
return state.patchVariant;
}
function readPatchVariant() {
return setPatchVariant(patchVariantInput?.value ?? state.patchVariant, { update: false });
}
function resetPatchVariant({ update = true } = {}) {
return setPatchVariant(0, { update });
}
2026-05-28 19:37:28 +09:00
function updatePatchControls() {
if (!patchStatusEl && !generatePatchButton) return;
const validation = validatePatchRect(state.selectionRect, state.world);
2026-05-28 23:51:55 +09:00
const variant = readPatchVariant();
2026-05-28 19:37:28 +09:00
if (generatePatchButton) generatePatchButton.disabled = !validation.ok;
2026-05-28 23:51:55 +09:00
if (alternativePatchButton) alternativePatchButton.disabled = !validation.ok;
2026-05-28 19:37:28 +09:00
if (!patchStatusEl) return;
if (!state.selectionRect) {
2026-05-29 14:31:42 +09:00
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}.`;
2026-05-28 19:37:28 +09:00
patchStatusEl.classList.toggle("invalid", false);
return;
}
if (!validation.ok) {
patchStatusEl.textContent = `${validation.reason} Current: ${formatRectSize(validation.rect || state.selectionRect)}.`;
patchStatusEl.classList.toggle("invalid", true);
return;
2026-05-28 00:30:09 +09:00
}
2026-05-28 19:37:28 +09:00
const rects = buildPatchRects(validation.rect, state.world);
const patchText = state.lastPatchResult
2026-05-29 14:31:42 +09:00
? ` 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}` : ""}.`
2026-05-28 19:37:28 +09:00
: "";
2026-05-28 23:51:55 +09:00
patchStatusEl.textContent = `Variant: ${variant}. Core: ${formatRectSize(rects.coreRect)}. Write: ${formatRectSize(rects.writeRect)}. Context: ${formatRectSize(rects.contextRect)}. Repair: ${formatRectSize(rects.repairRect)}.${patchText}`;
2026-05-28 19:37:28 +09:00
patchStatusEl.classList.toggle("invalid", false);
}
function clearDragMode() {
dragState.mode = null;
dragState.pointerId = null;
dragState.pendingCamera = null;
if (dragState.panRaf != null) {
cancelAnimationFrame(dragState.panRaf);
dragState.panRaf = null;
}
canvasShell?.classList.remove("panning", "selecting");
}
function schedulePanRedraw(camera) {
dragState.pendingCamera = camera;
if (dragState.panRaf != null) return;
dragState.panRaf = requestAnimationFrame(() => {
dragState.panRaf = null;
if (!dragState.pendingCamera) return;
const next = dragState.pendingCamera;
dragState.pendingCamera = null;
if (next.x === state.camera.x && next.y === state.camera.y) return;
state.camera = next;
redraw({ fastTerrain: true });
});
}
function hideSelectionOverlay() {
dragState.selectStart = null;
dragState.selectEnd = null;
2026-05-29 14:31:42 +09:00
dragState.selectPath = null;
2026-05-28 19:37:28 +09:00
state.selectionRect = null;
2026-05-28 23:51:55 +09:00
resetPatchVariant({ update: false });
2026-05-29 14:31:42 +09:00
hideSelectionSvg();
2026-05-28 19:37:28 +09:00
if (selectionEl) selectionEl.style.display = "none";
updatePatchControls();
2026-05-28 00:30:09 +09:00
}
2026-05-28 19:37:28 +09:00
function selectionPixelsToCells(start, end) {
2026-05-29 14:31:42 +09:00
const a = screenPointToWorldCell(start);
const b = screenPointToWorldCell(end);
if (!a || !b) return null;
2026-05-28 19:37:28 +09:00
return {
2026-05-29 14:31:42 +09:00
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,
2026-05-28 19:37:28 +09:00
};
2026-05-28 00:30:09 +09:00
}
2026-05-29 14:31:42 +09:00
function selectionPixelsToShape(start, end, path = null) {
if (Array.isArray(path) && path.length >= 3) return selectionPathToShape(path);
return selectionPixelsToCells(start, end);
}
2026-05-28 19:37:28 +09:00
function handleMapPointerDown(event) {
if (!state.world || !canvasShell) return;
if (event.button !== 0 && event.button !== 2) return;
dragState.pointerId = event.pointerId;
dragState.startClientX = event.clientX;
dragState.startClientY = event.clientY;
dragState.startCameraX = state.camera.x;
dragState.startCameraY = state.camera.y;
tooltipEl?.classList.remove("visible");
if (event.button === 0) {
dragState.mode = "pan";
canvasShell.classList.add("panning");
} else {
dragState.mode = "select";
dragState.selectStart = clampCanvasPoint(event);
dragState.selectEnd = dragState.selectStart;
2026-05-29 14:31:42 +09:00
dragState.selectPath = [dragState.selectStart];
2026-05-28 19:37:28 +09:00
canvasShell.classList.add("selecting");
updateSelectionOverlay();
}
canvas.setPointerCapture?.(event.pointerId);
2026-05-28 00:30:09 +09:00
event.preventDefault();
}
2026-05-28 19:37:28 +09:00
function handleMapPointerMove(event) {
if (!dragState.mode || dragState.pointerId !== event.pointerId || !canvasShell) return;
tooltipEl?.classList.remove("visible");
if (dragState.mode === "pan") {
2026-05-28 23:51:55 +09:00
const cellSize = Math.max(1, displayedCellSize());
const dxCells = Math.round((event.clientX - dragState.startClientX) / cellSize);
const dyCells = Math.round((event.clientY - dragState.startClientY) / cellSize);
2026-05-29 14:31:42 +09:00
const nextCamera = clampCameraForView({
2026-05-28 19:37:28 +09:00
x: dragState.startCameraX - dxCells,
y: dragState.startCameraY - dyCells,
2026-05-29 14:31:42 +09:00
}, viewportSizeForZoom(state.zoom));
2026-05-28 19:37:28 +09:00
schedulePanRedraw(nextCamera);
} else if (dragState.mode === "select") {
dragState.selectEnd = clampCanvasPoint(event);
2026-05-29 14:31:42 +09:00
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];
}
2026-05-28 19:37:28 +09:00
updateSelectionOverlay();
}
event.preventDefault();
}
function handleMapPointerUp(event) {
if (dragState.pointerId !== event.pointerId) return;
const wasPanning = dragState.mode === "pan";
if (dragState.mode === "select") {
dragState.selectEnd = clampCanvasPoint(event);
2026-05-29 14:31:42 +09:00
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);
2026-05-28 19:37:28 +09:00
const width = Math.abs(dragState.selectEnd.x - dragState.selectStart.x);
const height = Math.abs(dragState.selectEnd.y - dragState.selectStart.y);
2026-05-29 14:31:42 +09:00
if (shape && (dragState.selectPath.length >= 3 || (width >= 4 && height >= 4))) {
state.selectionRect = shape;
2026-05-28 23:51:55 +09:00
state.lastPatchResult = null;
resetPatchVariant({ update: false });
2026-05-28 19:37:28 +09:00
updateSelectionOverlayFromWorldRect();
updatePatchControls();
} else {
hideSelectionOverlay();
}
}
canvas.releasePointerCapture?.(event.pointerId);
if (wasPanning && dragState.pendingCamera) {
state.camera = dragState.pendingCamera;
dragState.pendingCamera = null;
}
clearDragMode();
if (wasPanning) redraw({ fastTerrain: false });
2026-05-28 00:30:09 +09:00
event.preventDefault();
}
2026-05-20 13:50:56 +09:00
function parseSeed(seedText) {
const numeric = Number.parseInt(seedText, 10);
if (Number.isFinite(numeric)) return numeric >>> 0;
let hash = 2166136261;
for (const ch of seedText) hash = Math.imul(hash ^ ch.charCodeAt(0), 16777619);
return hash >>> 0;
}
function insideCount(items) {
return items.filter((item) => item.insidePrefecture).length;
}
function outsideCount(items) {
return items.length - insideCount(items);
}
function countText(items) {
return `${insideCount(items)} / outside ${outsideCount(items)}`;
}
2026-05-24 19:33:09 +09:00
function formatMs(ms) {
if (!Number.isFinite(ms)) return "-";
return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${Math.round(ms)}ms`;
}
function renderTimingRows(timings = []) {
if (!progressTimingsEl) return;
progressTimingsEl.innerHTML = "";
for (const row of timings) {
const item = document.createElement("div");
item.className = "progress-timing-row";
const label = document.createElement("span");
label.textContent = row.label;
const value = document.createElement("strong");
value.textContent = formatMs(row.ms);
item.append(label, value);
progressTimingsEl.append(item);
}
}
function updateGenerationProgress(event) {
if (!progressEl) return;
progressEl.classList.remove("hidden");
2026-05-24 22:52:22 +09:00
if (event?.status === "start") generationCurrentStage = event.label || "Preparing";
2026-05-24 19:33:09 +09:00
if (progressStageEl) {
2026-05-24 22:52:22 +09:00
const elapsed = generationStartedAt ? ` / elapsed ${formatMs(performance.now() - generationStartedAt)}` : "";
2026-05-24 19:33:09 +09:00
progressStageEl.textContent = event?.status === "done"
2026-05-24 22:52:22 +09:00
? `Completed: ${event.label} / ${formatMs(event.ms)}${elapsed}`
: `Running: ${event?.label || generationCurrentStage || "Preparing"}${elapsed}`;
2026-05-24 19:33:09 +09:00
}
renderTimingRows(event?.timings || []);
}
function setProgressVisible(visible, message = "Preparing") {
if (!progressEl) return;
progressEl.classList.toggle("hidden", !visible);
2026-05-24 22:52:22 +09:00
if (visible) {
generationStartedAt = performance.now();
generationCurrentStage = message;
if (generationTimer) window.clearInterval(generationTimer);
generationTimer = window.setInterval(() => {
if (progressStageEl && !progressEl.classList.contains("hidden")) {
progressStageEl.textContent = `Running: ${generationCurrentStage || "Preparing"} / elapsed ${formatMs(performance.now() - generationStartedAt)}`;
}
}, 100);
} else if (generationTimer) {
window.clearInterval(generationTimer);
generationTimer = null;
}
2026-05-24 19:33:09 +09:00
if (progressStageEl) progressStageEl.textContent = message;
if (visible) renderTimingRows([]);
}
function nextFrame() {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
2026-05-20 13:50:56 +09:00
function getStats(map) {
return [
2026-05-24 17:38:51 +09:00
["Map Type", map.terrainTemplate?.terrainTypeLabel || map.terrainDebug?.terrainTypeLabel || "-"],
["Terrain ID", map.terrainTemplate?.terrainType || map.terrainDebug?.terrainType || "-"],
2026-05-24 22:52:22 +09:00
["Generation Total", map.generationTotalMs ? formatMs(map.generationTotalMs) : "-"],
2026-05-28 00:30:09 +09:00
["Geography Basis", map.geographyDebug?.version ? `${map.geographyDebug.version} / ${map.geographyDebug.stage || "-"}` : "-"],
2026-05-24 22:52:22 +09:00
...(map.generationTimings || []).map((row) => [`Time: ${row.label}`, formatMs(row.ms)]),
2026-05-20 13:50:56 +09:00
["Villages", countText(map.villages)],
["Market Towns", countText(map.markets)],
["Castles", countText(map.castles)],
["Premodern Roads", map.premodernRoads.length],
["Minor Roads", map.minorRoads.length],
2026-05-22 19:28:39 +09:00
["Prefecture", map.prefectureName || "-"],
["Neighbor Prefectures", (map.neighborPrefectures || []).map((p) => p.name).join(" / ") || "-"],
["Neighbor Features", map.neighborPrefectureDetails ? `${map.neighborPrefectureDetails.cities?.length || 0} cities / ${map.neighborPrefectureDetails.adminCenters?.length || 0} municipalities / ${map.neighborPrefectureDetails.roads?.length || 0} roads` : "-"],
2026-05-20 13:50:56 +09:00
["Prefectural Capital", map.prefecturalCapital?.name || "-"],
2026-05-24 17:38:51 +09:00
["Regional Capitals", (map.modernCities || []).filter((p) => p.isRegionalCapital).length],
2026-05-20 13:50:56 +09:00
["Modern Cities", countText(map.modernCities)],
["Ports", `${map.ports.filter((p) => p.portClass === "major").length} major / ${map.ports.filter((p) => p.portClass === "regional").length} regional / ${map.ports.filter((p) => p.portClass === "fishing").length} fishing / ${map.ports.filter((p) => p.portClass === "lake").length} lake`],
["Satellite Cities", countText(map.satelliteCities || [])],
["Population", (map.totalPopulation || 0).toLocaleString()],
["Rivers", `${map.mainRivers.length} main / ${(map.tributaryRivers || []).length} tributary / ${(map.smallStreams || []).length} hidden streams`],
["Neighbor Prefecture Borders", (map.regionalPrefectureBorders || []).length],
["Rail Lines", map.railways.length + map.branchRailways.length + (map.ringRailways || []).length + map.externalRailways.length],
["Industrial Zones", countText(map.industrialZones)],
2026-05-22 19:28:39 +09:00
["National Roads", `${map.nationalRoads.length} / pop cover ${Math.round((map.transportDebug?.nationalRoadPopulationCoverage || 0) * 100)}% / uncovered ${(map.transportDebug?.nationalRoadUncoveredPopulation || 0).toLocaleString()}`],
["General Ring Roads", (map.ringRoads || []).length],
2026-05-20 15:02:37 +09:00
["Expressways", map.expressways.length + map.externalExpressways.length],
2026-05-20 13:50:56 +09:00
["External Gateways", map.externalGateways.length],
["Interchanges", countText(map.interchanges)],
["Logistics Parks", countText(map.logisticsParks)],
["New Towns", countText(map.newTowns)],
["Municipalities", map.adminCenters.length],
2026-05-26 15:32:27 +09:00
["Prefecture source", map.regionalDebug?.prefectureSource ?? "-"],
2026-05-20 13:50:56 +09:00
];
}
function renderStats(map) {
statsEl.innerHTML = "";
for (const [labelText, valueText] of getStats(map)) {
const row = document.createElement("div");
row.className = "stat-row";
const label = document.createElement("span");
label.textContent = labelText;
const value = document.createElement("strong");
value.textContent = String(valueText);
row.append(label, value);
statsEl.append(row);
}
}
2026-05-26 16:56:18 +09:00
function buildHoverEntities(map) {
return [
2026-05-20 13:50:56 +09:00
...(map.modernCities || []),
...(map.ports || []),
...(map.stations || []),
...(map.interchanges || []),
...(map.industrialZones || []),
...(map.logisticsParks || []),
...(map.newTowns || []),
...(map.castles || []),
...(map.markets || []),
...(map.villages || []),
...(map.adminCenters || []),
];
2026-05-26 16:56:18 +09:00
}
function nearestEntity(items, x, y, maxDistance = 5) {
2026-05-20 13:50:56 +09:00
let best = null;
let bestD = maxDistance;
2026-05-29 14:31:42 +09:00
for (const item of items || []) {
if (!item || !Number.isFinite(item.x) || !Number.isFinite(item.y)) continue;
2026-05-20 13:50:56 +09:00
const d = Math.hypot(item.x - x, item.y - y);
if (d < bestD) { best = item; bestD = d; }
}
return best;
}
function landuseName(value) {
2026-05-24 17:38:51 +09:00
return landuseLabel(value);
2026-05-20 13:50:56 +09:00
}
2026-05-29 14:31:42 +09:00
const ADMIN_ID_KEYS = ["adminId", "municipalityId", "adminNumericId", "id", "numericId"];
const PREFECTURE_ID_KEYS = ["prefectureRegionId", "prefectureId", "id", "numericId"];
const MUNICIPALITY_NAME_KEYS = ["municipalityName", "name", "canonicalSettlementName", "municipalityRootName", "generatedMunicipalityName", "label"];
const PREFECTURE_NAME_KEYS = ["prefectureName", "prefectureRegionName", "regionName", "name", "label"];
const POPULATION_KEYS = ["municipalityPopulation", "adminPopulation", "population", "estimatedPopulation"];
function numericIdOf(item, keys = ADMIN_ID_KEYS) {
for (const key of keys) {
const value = item?.[key];
if (Number.isFinite(value)) return Math.floor(value);
}
return null;
}
function hasNumericId(item, id, keys = ADMIN_ID_KEYS) {
if (!item || id == null || id < 0) return false;
return keys.some((key) => Number.isFinite(item?.[key]) && Math.floor(item[key]) === Math.floor(id));
}
function firstUsableText(item, keys) {
for (const key of keys) {
2026-05-28 23:51:55 +09:00
const value = item?.[key];
2026-05-29 14:31:42 +09:00
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);
2026-05-28 23:51:55 +09:00
}
return null;
}
function adminCenterForId(map, adminId) {
if (!map || adminId == null || adminId < 0) return null;
2026-05-29 14:31:42 +09:00
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;
2026-05-28 23:51:55 +09:00
}
function looksNumericName(name) {
if (!name) return true;
const text = String(name).trim();
2026-05-29 14:31:42 +09:00
return !text || /^-?\d+(?:\s*[,]\s*\d+)*$/u.test(text) || /^(Municipality|Prefecture|Admin|Region)\s*-?\d+/i.test(text);
2026-05-20 13:50:56 +09:00
}
2026-05-29 14:31:42 +09:00
function nearestNamedAdminCenter(map, cellIndex, maxDistance = 36, adminId = null) {
2026-05-28 23:51:55 +09:00
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 || []) {
2026-05-29 14:31:42 +09:00
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;
2026-05-28 23:51:55 +09:00
const d = Math.hypot(center.x - x, center.y - y);
if (d < bestD) { best = center; bestD = d; }
}
2026-05-29 14:31:42 +09:00
if (!best && adminId != null && adminId >= 0) return nearestNamedAdminCenter(map, cellIndex, maxDistance, null);
2026-05-28 23:51:55 +09:00
return best;
}
function adminName(map, adminId, cellIndex = -1) {
2026-05-29 14:31:42 +09:00
const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex, 54, adminId);
const name = firstUsableText(center, MUNICIPALITY_NAME_KEYS);
if (name) return name;
2026-05-28 23:51:55 +09:00
return adminId >= 0 ? "Unnamed municipality" : "-";
}
function adminPopulation(map, adminId, cellIndex = -1) {
2026-05-29 14:31:42 +09:00
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;
2026-05-26 16:56:18 +09:00
}
2026-05-26 15:32:27 +09:00
function prefectureNameForCell(map, i) {
const id = map.prefectureRegionId?.[i] ?? -1;
2026-05-29 14:31:42 +09:00
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" : "-");
2026-05-26 15:32:27 +09:00
}
2026-05-20 13:50:56 +09:00
function updateTooltip(event) {
2026-05-28 19:37:28 +09:00
const map = activeMap();
if (!map || !tooltipEl || dragState.mode) return;
2026-05-20 13:50:56 +09:00
const rect = canvas.getBoundingClientRect();
2026-05-28 00:30:09 +09:00
const cell = mapClientToCell(event);
if (!cell) return;
const { x, y } = cell;
2026-05-28 19:37:28 +09:00
if (x < 0 || y < 0 || x >= map.width || y >= map.height) {
2026-05-20 13:50:56 +09:00
tooltipEl.classList.remove("visible");
return;
}
2026-05-28 19:37:28 +09:00
const i = y * map.width + x;
const worldCell = viewportCellToWorldCell({ x, y });
2026-05-26 16:56:18 +09:00
const entity = nearestEntity(state.hoverEntities, x, y);
2026-05-28 19:37:28 +09:00
const elevation = map.elevation?.[i] ?? 0;
2026-05-28 23:51:55 +09:00
const density = map.populationDensity?.[i] ?? map.settlementScore?.[i] ?? 0;
2026-05-28 19:37:28 +09:00
const hoveredAdminId = map.adminId?.[i] ?? -1;
2026-05-28 23:51:55 +09:00
const hoveredAdminPopulation = adminPopulation(map, hoveredAdminId, i);
2026-05-28 19:37:28 +09:00
const coordinateText = worldCell ? `World ${worldCell.x}, ${worldCell.y} / View ${x}, ${y}` : `${x}, ${y}`;
2026-05-29 14:31:42 +09:00
const entityName = firstUsableText(entity, ["name", "facilityLabel", "municipalityName", "canonicalSettlementName", "kind"]);
2026-05-28 19:37:28 +09:00
const entityTitle = entity
2026-05-29 14:31:42 +09:00
? `${entityName || adminName(map, hoveredAdminId, i) || entity.kind || "Feature"} / ${entity.kind || "Feature"}`
2026-05-28 19:37:28 +09:00
: coordinateText;
2026-05-20 13:50:56 +09:00
const lines = [
2026-05-28 19:37:28 +09:00
`<strong>${entityTitle}</strong>`,
`Prefecture: ${prefectureNameForCell(map, i)}`,
2026-05-28 23:51:55 +09:00
`Admin: ${adminName(map, hoveredAdminId, i)}`,
2026-05-26 16:56:18 +09:00
`Admin Pop: ${hoveredAdminPopulation === null ? "-" : hoveredAdminPopulation.toLocaleString()}`,
2026-05-28 19:37:28 +09:00
`Land: ${map.sea?.[i] ? "Sea" : landuseName(map.landuse?.[i])}`,
`Elevation: ${elevation.toFixed(3)} / Slope: ${(map.slope?.[i] ?? 0).toFixed(3)}`,
`River: ${(map.river?.[i] ?? 0).toFixed(2)} / Density: ${density.toFixed(2)}`,
2026-05-20 13:50:56 +09:00
];
if (entity?.population) lines.splice(1, 0, `Population: ${entity.population.toLocaleString()}`);
tooltipEl.innerHTML = lines.join("<br>");
2026-05-26 15:32:27 +09:00
const margin = 8;
const offset = 14;
const maxLeft = Math.max(margin, rect.width - tooltipEl.offsetWidth - margin);
const maxTop = Math.max(margin, rect.height - tooltipEl.offsetHeight - margin);
const desiredLeft = event.clientX - rect.left + offset;
const desiredTop = event.clientY - rect.top + offset;
tooltipEl.style.left = `${Math.min(Math.max(margin, desiredLeft), maxLeft)}px`;
tooltipEl.style.top = `${Math.min(Math.max(margin, desiredTop), maxTop)}px`;
2026-05-20 13:50:56 +09:00
tooltipEl.classList.add("visible");
}
2026-05-26 16:56:18 +09:00
function renderModeButtons() {
modeGrid.innerHTML = "";
2026-05-20 13:50:56 +09:00
for (const [key, label] of modes) {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.className = key === state.mode ? "mode-button active" : "mode-button";
button.addEventListener("click", () => {
state.mode = key;
renderModeButtons();
redraw();
});
modeGrid.append(button);
}
}
2026-05-24 19:33:09 +09:00
async function regenerate() {
2026-05-20 13:50:56 +09:00
state.seedText = seedInput.value;
2026-05-28 16:45:00 +09:00
state.generationType = generationTypeInput?.value || "auto";
2026-05-24 19:33:09 +09:00
setProgressVisible(true, "Preparing generation...");
await nextFrame();
try {
2026-05-28 16:45:00 +09:00
state.map = await generateMapAsync(parseSeed(state.seedText), { onProgress: updateGenerationProgress, terrainType: state.generationType });
2026-05-28 19:37:28 +09:00
state.world = createWorldMap(state.map);
state.camera = createInitialCamera(state.world);
state.lastPatchResult = null;
2026-05-28 23:51:55 +09:00
resetPatchVariant({ update: false });
2026-05-28 19:37:28 +09:00
hideSelectionOverlay();
2026-05-24 19:33:09 +09:00
renderStats(state.map);
redraw();
if (progressStageEl) progressStageEl.textContent = `Done in ${formatMs(state.map.generationTotalMs || 0)}`;
renderTimingRows(state.map.generationTimings || []);
window.setTimeout(() => setProgressVisible(false), 900);
} catch (error) {
if (progressStageEl) progressStageEl.textContent = `Generation failed: ${error?.message || error}`;
throw error;
}
2026-05-20 13:50:56 +09:00
}
2026-05-28 19:37:28 +09:00
2026-05-28 23:51:55 +09:00
function derivePatchSeed(rect, terrainType, variant = 0) {
2026-05-28 19:37:28 +09:00
let h = parseSeed(state.seedText) ^ 0x9e3779b9;
h = Math.imul(h ^ (rect.x0 | 0), 1664525) >>> 0;
h = Math.imul(h ^ (rect.y0 | 0), 1013904223) >>> 0;
h = Math.imul(h ^ (rect.x1 | 0), 2246822519) >>> 0;
h = Math.imul(h ^ (rect.y1 | 0), 3266489917) >>> 0;
2026-05-28 23:51:55 +09:00
h = Math.imul(h ^ normalizePatchVariant(variant), 668265263) >>> 0;
2026-05-28 19:37:28 +09:00
for (const ch of String(terrainType || "auto")) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
return h >>> 0;
}
2026-05-28 23:51:55 +09:00
function handleCanvasWheel(event) {
if (!state.world || !activeMap()) return;
event.preventDefault();
tooltipEl?.classList.remove("visible");
2026-05-29 14:31:42 +09:00
const beforeSize = viewportSizeForZoom(state.zoom);
const beforeCell = mapClientToCell(event, beforeSize);
2026-05-28 23:51:55 +09:00
const beforeWorld = beforeCell ? viewportCellToWorldCell(beforeCell) : null;
const oldZoom = clampZoom(state.zoom || 1);
2026-05-29 14:31:42 +09:00
const delta = event.deltaY < 0 ? 1.10 : 1 / 1.10;
2026-05-28 23:51:55 +09:00
const nextZoom = clampZoom(oldZoom * delta);
if (Math.abs(nextZoom - oldZoom) < 0.001) return;
state.zoom = nextZoom;
2026-05-29 14:31:42 +09:00
const nextSize = syncViewportSize();
2026-05-28 23:51:55 +09:00
if (beforeWorld) {
2026-05-29 14:31:42 +09:00
const afterCell = mapClientToCell(event, nextSize);
2026-05-28 23:51:55 +09:00
if (afterCell) {
2026-05-29 14:31:42 +09:00
state.camera = clampCameraForView({
2026-05-28 23:51:55 +09:00
x: beforeWorld.x - afterCell.x,
y: beforeWorld.y - afterCell.y,
2026-05-29 14:31:42 +09:00
}, nextSize);
2026-05-28 23:51:55 +09:00
}
}
2026-05-29 14:31:42 +09:00
// 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);
2026-05-28 23:51:55 +09:00
}
2026-05-28 19:37:28 +09:00
async function generateSelectedPatch() {
const validation = validatePatchRect(state.selectionRect, state.world);
if (!validation.ok) {
updatePatchControls();
return;
}
const terrainType = patchTerrainTypeInput?.value || generationTypeInput?.value || "auto";
2026-05-28 23:51:55 +09:00
const variant = readPatchVariant();
const seed = derivePatchSeed(validation.rect, terrainType, variant);
2026-05-28 19:37:28 +09:00
setProgressVisible(true, "Generating selected patch...");
await nextFrame();
try {
2026-05-28 23:51:55 +09:00
const result = generatePatch(state.world, validation.rect, { terrainType, seed, variant });
2026-05-28 19:37:28 +09:00
if (!result.ok) {
if (progressStageEl) progressStageEl.textContent = `Patch failed: ${result.reason || "invalid selection"}`;
updatePatchControls();
window.setTimeout(() => setProgressVisible(false), 1200);
return;
}
state.lastPatchResult = result;
redraw();
renderStats(state.map);
updatePatchControls();
const human = result.humanGeography;
const humanText = human?.ok ? ` / human: ${human.modernCities || 0} cities, ${human.ports || 0} ports, ${human.villages || 0} villages, ${(human.roadConnectorsCreated || 0) + (human.railwayConnectorsCreated || 0)} connectors, admin ${human.adminCellsReassigned || 0}, invalid ports ${human.invalidPortsRemoved || 0}` : "";
2026-05-28 23:51:55 +09:00
if (progressStageEl) progressStageEl.textContent = `Patch generated: ${result.label} / variant ${result.variant ?? variant} / mode ${result.patchGenerationMode || "legacy-full-pipeline"} / core ${formatRectSize(result.rects.coreRect)} / write ${formatRectSize(result.rects.writeRect)} / terrain ${result.updatedCells.toLocaleString()} cells / coast ${result.coastCellsChanged || 0} / natural ${result.naturalRegionsUpdated || 0}${humanText}`;
2026-05-28 19:37:28 +09:00
renderTimingRows([]);
window.setTimeout(() => setProgressVisible(false), 900);
} catch (error) {
if (progressStageEl) progressStageEl.textContent = `Patch failed: ${error?.message || error}`;
throw error;
}
}
2026-05-28 23:51:55 +09:00
async function generateAlternativePatch() {
const validation = validatePatchRect(state.selectionRect, state.world);
if (!validation.ok) {
updatePatchControls();
return;
}
setPatchVariant(readPatchVariant() + 1, { update: false });
await generateSelectedPatch();
}
2026-05-28 19:37:28 +09:00
function redraw(options = {}) {
if (!state.world) return;
2026-05-29 14:31:42 +09:00
const viewSize = syncViewportSize();
const expansion = options.allowWorldExpand === false ? null : ensureWorldPaddingForCamera(state.world, state.camera, viewSize.width, viewSize.height);
2026-05-28 23:51:55 +09:00
if (expansion?.expanded) {
state.camera = { x: (state.camera?.x || 0) + (expansion.dx || 0), y: (state.camera?.y || 0) + (expansion.dy || 0) };
if (state.selectionRect) {
2026-05-29 14:31:42 +09:00
const dx = expansion.dx || 0;
const dy = expansion.dy || 0;
2026-05-28 23:51:55 +09:00
state.selectionRect = {
2026-05-29 14:31:42 +09:00
...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,
2026-05-28 23:51:55 +09:00
};
}
}
2026-05-29 14:31:42 +09:00
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);
2026-05-28 19:37:28 +09:00
drawMap(canvas, state.viewportMap, {
2026-05-20 13:50:56 +09:00
mode: state.mode,
2026-05-29 14:31:42 +09:00
showFeatures: state.showFeatures && !options.fastTerrain,
2026-05-28 19:37:28 +09:00
showLabels: state.showLabels && !options.fastTerrain,
continuousTerrain: !options.fastTerrain,
2026-05-29 14:31:42 +09:00
fastTerrain: !!options.fastTerrain,
2026-05-28 23:51:55 +09:00
zoom: state.zoom || 1,
2026-05-20 13:50:56 +09:00
});
2026-05-28 23:51:55 +09:00
applyCanvasZoom();
2026-05-28 19:37:28 +09:00
if (state.selectionRect && dragState.mode !== "select") updateSelectionOverlayFromWorldRect();
2026-05-20 13:50:56 +09:00
}
function init() {
renderModeButtons();
seedInput.addEventListener("change", regenerate);
seedInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") regenerate();
});
2026-05-28 16:45:00 +09:00
generationTypeInput?.addEventListener("change", regenerate);
2026-05-28 23:51:55 +09:00
patchTerrainTypeInput?.addEventListener("change", () => {
state.lastPatchResult = null;
resetPatchVariant({ update: false });
updatePatchControls();
});
patchVariantInput?.addEventListener("change", () => setPatchVariant(patchVariantInput.value));
patchVariantInput?.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
setPatchVariant(patchVariantInput.value);
generateSelectedPatch();
}
});
2026-05-28 19:37:28 +09:00
generatePatchButton?.addEventListener("click", generateSelectedPatch);
2026-05-28 23:51:55 +09:00
alternativePatchButton?.addEventListener("click", generateAlternativePatch);
2026-05-28 16:45:00 +09:00
2026-05-20 13:50:56 +09:00
randomSeedButton.addEventListener("click", () => {
seedInput.value = String(Math.floor(Math.random() * 9999999));
regenerate();
});
showFeaturesInput.addEventListener("change", () => {
state.showFeatures = showFeaturesInput.checked;
redraw();
});
showLabelsInput.addEventListener("change", () => {
state.showLabels = showLabelsInput.checked;
redraw();
});
2026-05-28 00:30:09 +09:00
canvasShell?.setAttribute("tabindex", "0");
2026-05-28 19:37:28 +09:00
canvas.addEventListener("contextmenu", (event) => event.preventDefault());
2026-05-28 23:51:55 +09:00
canvas.addEventListener("wheel", handleCanvasWheel, { passive: false });
2026-05-28 19:37:28 +09:00
canvas.addEventListener("pointerdown", handleMapPointerDown);
canvas.addEventListener("pointermove", handleMapPointerMove);
canvas.addEventListener("pointerup", handleMapPointerUp);
canvas.addEventListener("pointercancel", handleMapPointerUp);
2026-05-20 13:50:56 +09:00
canvas.addEventListener("mousemove", updateTooltip);
2026-05-28 00:30:09 +09:00
canvas.addEventListener("mouseleave", () => {
tooltipEl?.classList.remove("visible");
});
2026-05-20 13:50:56 +09:00
2026-05-28 19:37:28 +09:00
updatePatchControls();
2026-05-20 13:50:56 +09:00
regenerate();
}
init();