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