This commit is contained in:
33333-33333 2026-05-28 23:51:55 +09:00
commit 112e6bf86b
11 changed files with 1586 additions and 1253 deletions

273
app.js
View file

@ -2,7 +2,7 @@ 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 } from "./worldMap.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";
@ -30,6 +30,8 @@ const state = {
viewportMap: null,
hoverEntities: [],
selectionRect: null,
patchVariant: 0,
zoom: 1,
lastPatchResult: null,
};
@ -38,7 +40,9 @@ 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");
@ -71,16 +75,72 @@ 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 rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return null;
const relX = (event.clientX - rect.left) / rect.width;
const relY = (event.clientY - rect.top) / rect.height;
const p = screenPointToMapPixel(event.clientX, event.clientY);
if (!p) return null;
return {
x: Math.floor(relX * map.width),
y: Math.floor(relY * map.height),
x: Math.floor(p.x / CELL_SIZE),
y: Math.floor(p.y / CELL_SIZE),
};
}
@ -115,6 +175,7 @@ function updateSelectionOverlay() {
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
@ -128,13 +189,14 @@ function updateSelectionOverlayFromWorldRect() {
if (!selectionEl || !state.selectionRect || !state.camera || !activeMap()) return;
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const map = activeMap();
const cameraX = Math.round(state.camera.x || 0);
const cameraY = Math.round(state.camera.y || 0);
const vx0 = (state.selectionRect.x0 - cameraX) / map.width * rect.width;
const vy0 = (state.selectionRect.y0 - cameraY) / map.height * rect.height;
const vx1 = (state.selectionRect.x1 - cameraX) / map.width * rect.width;
const vy1 = (state.selectionRect.y1 - cameraY) / map.height * rect.height;
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);
@ -159,13 +221,37 @@ function formatRectSize(rect) {
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.`;
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;
}
@ -176,9 +262,9 @@ function updatePatchControls() {
}
const rects = buildPatchRects(validation.rect, state.world);
const patchText = state.lastPatchResult
? ` Last patch: ${state.lastPatchResult.label}, 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}` : ""}.`
? ` 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 = `Core: ${formatRectSize(rects.coreRect)}. Write: ${formatRectSize(rects.writeRect)}. Repair: ${formatRectSize(rects.repairRect)}.${patchText}`;
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);
}
@ -211,6 +297,7 @@ function hideSelectionOverlay() {
dragState.selectStart = null;
dragState.selectEnd = null;
state.selectionRect = null;
resetPatchVariant({ update: false });
if (selectionEl) selectionEl.style.display = "none";
updatePatchControls();
}
@ -220,10 +307,18 @@ function selectionPixelsToCells(start, end) {
if (!map || !start || !end) return null;
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return null;
const localX0 = Math.floor(Math.min(start.x, end.x) / rect.width * map.width);
const localY0 = Math.floor(Math.min(start.y, end.y) / rect.height * map.height);
const localX1 = Math.ceil(Math.max(start.x, end.x) / rect.width * map.width);
const localY1 = Math.ceil(Math.max(start.y, end.y) / rect.height * map.height);
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 {
@ -264,8 +359,9 @@ function handleMapPointerMove(event) {
tooltipEl?.classList.remove("visible");
if (dragState.mode === "pan") {
const dxCells = Math.round((event.clientX - dragState.startClientX) / CELL_SIZE);
const dyCells = Math.round((event.clientY - dragState.startClientY) / CELL_SIZE);
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,
@ -288,6 +384,8 @@ function handleMapPointerUp(event) {
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 {
@ -466,20 +564,61 @@ function landuseName(value) {
return landuseLabel(value);
}
function adminName(map, adminId) {
const center = (map.adminCenters || [])[adminId];
return center?.name || (adminId >= 0 ? `Municipality ${adminId + 1}` : "-");
function numericIdOf(item) {
for (const key of ["adminId", "municipalityId", "id", "adminNumericId"]) {
const value = item?.[key];
if (Number.isFinite(value)) return value;
}
return null;
}
function adminPopulation(map, adminId) {
const center = (map.adminCenters || [])[adminId];
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);
return region?.name || (id >= 0 ? `Prefecture ${id + 1}` : "-");
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) {
@ -497,9 +636,9 @@ function updateTooltip(event) {
const worldCell = viewportCellToWorldCell({ x, y });
const entity = nearestEntity(state.hoverEntities, x, y);
const elevation = map.elevation?.[i] ?? 0;
const density = map.populationDensity?.[i] ?? 0;
const density = map.populationDensity?.[i] ?? map.settlementScore?.[i] ?? 0;
const hoveredAdminId = map.adminId?.[i] ?? -1;
const hoveredAdminPopulation = adminPopulation(map, hoveredAdminId);
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"}`
@ -507,7 +646,7 @@ function updateTooltip(event) {
const lines = [
`<strong>${entityTitle}</strong>`,
`Prefecture: ${prefectureNameForCell(map, i)}`,
`Admin: ${adminName(map, hoveredAdminId)}`,
`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)}`,
@ -552,6 +691,7 @@ async function regenerate() {
state.world = createWorldMap(state.map);
state.camera = createInitialCamera(state.world);
state.lastPatchResult = null;
resetPatchVariant({ update: false });
hideSelectionOverlay();
renderStats(state.map);
redraw();
@ -565,16 +705,41 @@ async function regenerate() {
}
function derivePatchSeed(rect, terrainType) {
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) {
@ -582,11 +747,12 @@ async function generateSelectedPatch() {
return;
}
const terrainType = patchTerrainTypeInput?.value || generationTypeInput?.value || "auto";
const seed = derivePatchSeed(validation.rect, terrainType);
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 });
const result = generatePatch(state.world, validation.rect, { terrainType, seed, variant });
if (!result.ok) {
if (progressStageEl) progressStageEl.textContent = `Patch failed: ${result.reason || "invalid selection"}`;
updatePatchControls();
@ -599,7 +765,7 @@ async function generateSelectedPatch() {
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} / core ${formatRectSize(result.rects.coreRect)} / write ${formatRectSize(result.rects.writeRect)} / terrain ${result.updatedCells.toLocaleString()} cells / coast ${result.coastCellsChanged || 0} / natural ${result.naturalRegionsUpdated || 0}${humanText}`;
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) {
@ -608,8 +774,30 @@ async function generateSelectedPatch() {
}
}
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);
@ -618,7 +806,9 @@ function redraw(options = {}) {
showFeatures: state.showFeatures,
showLabels: state.showLabels && !options.fastTerrain,
continuousTerrain: !options.fastTerrain,
zoom: state.zoom || 1,
});
applyCanvasZoom();
if (state.selectionRect && dragState.mode !== "select") updateSelectionOverlayFromWorldRect();
}
@ -631,8 +821,20 @@ function init() {
});
generationTypeInput?.addEventListener("change", regenerate);
patchTerrainTypeInput?.addEventListener("change", updatePatchControls);
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));
@ -651,6 +853,7 @@ function init() {
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);