This commit is contained in:
33333-33333 2026-05-29 14:31:42 +09:00
commit f2d0306d96
13 changed files with 1434 additions and 516 deletions

388
app.js
View file

@ -28,6 +28,8 @@ const state = {
world: null,
camera: { x: 0, y: 0 },
viewportMap: null,
viewWidth: MAP_W,
viewHeight: MAP_H,
hoverEntities: [],
selectionRect: null,
patchVariant: 0,
@ -50,6 +52,7 @@ const showLabelsInput = document.getElementById("showLabels");
const modeGrid = document.getElementById("modeGrid");
const statsEl = document.getElementById("stats");
const tooltipEl = document.getElementById("mapTooltip");
const selectionSvgEl = document.getElementById("mapSelectionSvg");
const selectionEl = document.getElementById("mapSelection");
const progressEl = document.getElementById("generationProgress");
const progressStageEl = document.getElementById("generationProgressStage");
@ -57,6 +60,8 @@ const progressTimingsEl = document.getElementById("generationProgressTimings");
let generationStartedAt = 0;
let generationCurrentStage = "";
let generationTimer = null;
let zoomRedrawRaf = null;
let zoomSettledTimer = null;
const dragState = {
mode: null,
@ -67,6 +72,7 @@ const dragState = {
startCameraY: 0,
selectStart: null,
selectEnd: null,
selectPath: null,
pendingCamera: null,
panRaf: null,
};
@ -81,19 +87,31 @@ function clampZoom(value) {
return Math.min(Math.max(parsed, 0.55), 2.8);
}
function zoomTransform() {
const zoom = clampZoom(state.zoom || 1);
const width = canvas.width || MAP_W * CELL_SIZE;
const height = canvas.height || MAP_H * CELL_SIZE;
function viewportSizeForZoom(zoom = state.zoom) {
const z = clampZoom(zoom || 1);
return {
zoom,
width,
height,
tx: width * (1 - zoom) * 0.5,
ty: height * (1 - zoom) * 0.5,
width: Math.max(1, Math.ceil(MAP_W / z)),
height: Math.max(1, Math.ceil(MAP_H / z)),
};
}
function clampCameraForView(camera, size = viewportSizeForZoom(state.zoom)) {
return clampCameraToWorld(camera, state.world, size?.width || MAP_W, size?.height || MAP_H);
}
function syncViewportSize() {
const size = viewportSizeForZoom(state.zoom);
state.viewWidth = size.width;
state.viewHeight = size.height;
return size;
}
function mapCellScreenSize(map = activeMap()) {
const rect = canvas.getBoundingClientRect();
const mapWidth = Math.max(1, map?.width || state.viewWidth || MAP_W);
return rect.width ? rect.width / mapWidth : CELL_SIZE * clampZoom(state.zoom || 1);
}
function applyCanvasZoom() {
if (!canvas) return;
state.zoom = clampZoom(state.zoom || 1);
@ -105,38 +123,39 @@ function applyCanvasZoom() {
}
function displayedCellSize() {
return CELL_SIZE * clampZoom(state.zoom || 1);
return mapCellScreenSize();
}
function screenPointToMapPixel(clientX, clientY) {
function screenPointToMapPixel(clientX, clientY, sizeOverride = null) {
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return null;
const t = zoomTransform();
const scaleX = t.width / rect.width;
const scaleY = t.height / rect.height;
const canvasX = (clientX - rect.left) * scaleX;
const canvasY = (clientY - rect.top) * scaleY;
return {
x: (canvasX - t.tx) / t.zoom,
y: (canvasY - t.ty) / t.zoom,
};
const map = activeMap();
const viewWidth = Math.max(1, sizeOverride?.width || map?.width || state.viewWidth || MAP_W);
const viewHeight = Math.max(1, sizeOverride?.height || map?.height || state.viewHeight || MAP_H);
const canvasX = (clientX - rect.left) * ((canvas.width || MAP_W * CELL_SIZE) / rect.width);
const canvasY = (clientY - rect.top) * ((canvas.height || MAP_H * CELL_SIZE) / rect.height);
const cellX = canvasX / Math.max(1e-6, (canvas.width || MAP_W * CELL_SIZE) / viewWidth);
const cellY = canvasY / Math.max(1e-6, (canvas.height || MAP_H * CELL_SIZE) / viewHeight);
return { x: cellX * CELL_SIZE, y: cellY * CELL_SIZE };
}
function mapPixelToScreenPoint(px, py) {
const rect = canvas.getBoundingClientRect();
const t = zoomTransform();
const canvasX = t.tx + px * t.zoom;
const canvasY = t.ty + py * t.zoom;
const map = activeMap();
const viewWidth = Math.max(1, map?.width || state.viewWidth || MAP_W);
const viewHeight = Math.max(1, map?.height || state.viewHeight || MAP_H);
const canvasX = (px / CELL_SIZE) * ((canvas.width || MAP_W * CELL_SIZE) / viewWidth);
const canvasY = (py / CELL_SIZE) * ((canvas.height || MAP_H * CELL_SIZE) / viewHeight);
return {
x: canvas.offsetLeft + canvasX * (rect.width / Math.max(1, t.width)),
y: canvas.offsetTop + canvasY * (rect.height / Math.max(1, t.height)),
x: canvas.offsetLeft + canvasX * (rect.width / Math.max(1, canvas.width || MAP_W * CELL_SIZE)),
y: canvas.offsetTop + canvasY * (rect.height / Math.max(1, canvas.height || MAP_H * CELL_SIZE)),
};
}
function mapClientToCell(event) {
function mapClientToCell(event, sizeOverride = null) {
const map = activeMap();
if (!map) return null;
const p = screenPointToMapPixel(event.clientX, event.clientY);
if (!map && !sizeOverride) return null;
const p = screenPointToMapPixel(event.clientX, event.clientY, sizeOverride);
if (!p) return null;
return {
x: Math.floor(p.x / CELL_SIZE),
@ -160,24 +179,99 @@ function clampCanvasPoint(event) {
};
}
function screenPointToWorldCell(point) {
const map = activeMap();
if (!map || !point) return null;
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return null;
const viewWidth = Math.max(1, map.width || state.viewWidth || MAP_W);
const viewHeight = Math.max(1, map.height || state.viewHeight || MAP_H);
const canvasX = point.x * ((canvas.width || MAP_W * CELL_SIZE) / rect.width);
const canvasY = point.y * ((canvas.height || MAP_H * CELL_SIZE) / rect.height);
const localX = Math.floor((canvasX / Math.max(1e-6, (canvas.width || MAP_W * CELL_SIZE) / viewWidth)));
const localY = Math.floor((canvasY / Math.max(1e-6, (canvas.height || MAP_H * CELL_SIZE) / viewHeight)));
const cameraX = Math.round(state.camera?.x || 0);
const cameraY = Math.round(state.camera?.y || 0);
return {
x: cameraX + Math.min(Math.max(localX, 0), Math.max(0, map.width - 1)),
y: cameraY + Math.min(Math.max(localY, 0), Math.max(0, map.height - 1)),
};
}
function worldCellToOverlayPoint(point) {
const cameraX = Math.round(state.camera?.x || 0);
const cameraY = Math.round(state.camera?.y || 0);
const screen = mapPixelToScreenPoint((point.x - cameraX + 0.5) * CELL_SIZE, (point.y - cameraY + 0.5) * CELL_SIZE);
return { x: screen.x - canvas.offsetLeft, y: screen.y - canvas.offsetTop };
}
function simplifySelectionPath(points) {
const out = [];
for (const p of points || []) {
if (!out.length || Math.hypot(out[out.length - 1].x - p.x, out[out.length - 1].y - p.y) >= 6) out.push(p);
}
return out;
}
function polygonArea(points) {
let area = 0;
for (let i = 0; i < points.length; i++) {
const a = points[i];
const b = points[(i + 1) % points.length];
area += a.x * b.y - b.x * a.y;
}
return Math.abs(area) * 0.5;
}
function selectionPathToShape(points) {
const simplified = simplifySelectionPath(points || []);
if (simplified.length < 3) return null;
const polygon = simplified.map(screenPointToWorldCell).filter(Boolean);
if (polygon.length < 3) return null;
const xs = polygon.map((p) => p.x);
const ys = polygon.map((p) => p.y);
return {
kind: "lasso",
polygon,
x0: Math.min(...xs),
y0: Math.min(...ys),
x1: Math.max(...xs) + 1,
y1: Math.max(...ys) + 1,
areaCells: Math.max(1, Math.round(polygonArea(polygon))),
};
}
function drawSelectionSvg(points, invalid = false) {
if (!selectionSvgEl) return;
if (!points || points.length < 3) {
selectionSvgEl.style.display = "none";
selectionSvgEl.innerHTML = "";
return;
}
const pts = points.map((p) => `${p.x},${p.y}`).join(" ");
selectionSvgEl.setAttribute("viewBox", `0 0 ${canvas.clientWidth || canvas.width || 1} ${canvas.clientHeight || canvas.height || 1}`);
selectionSvgEl.innerHTML = `<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");
}
function updateSelectionOverlay() {
if (!selectionEl || !dragState.selectStart || !dragState.selectEnd) return;
const x0 = Math.min(dragState.selectStart.x, dragState.selectEnd.x);
const y0 = Math.min(dragState.selectStart.y, dragState.selectEnd.y);
const x1 = Math.max(dragState.selectStart.x, dragState.selectEnd.x);
const y1 = Math.max(dragState.selectStart.y, dragState.selectEnd.y);
selectionEl.style.display = "block";
selectionEl.style.left = `${canvas.offsetLeft + x0}px`;
selectionEl.style.top = `${canvas.offsetTop + y0}px`;
selectionEl.style.width = `${Math.max(1, x1 - x0)}px`;
selectionEl.style.height = `${Math.max(1, y1 - y0)}px`;
const liveRect = selectionPixelsToCells(dragState.selectStart, dragState.selectEnd);
const validation = validatePatchRect(liveRect, state.world);
selectionEl.classList.toggle("invalid", !validation.ok);
if (!dragState.selectPath?.length) return;
const liveShape = selectionPathToShape(dragState.selectPath);
const validation = validatePatchRect(liveShape, state.world);
drawSelectionSvg(dragState.selectPath, !validation.ok);
if (selectionEl) selectionEl.style.display = "none";
if (generatePatchButton) generatePatchButton.disabled = true;
if (alternativePatchButton) alternativePatchButton.disabled = true;
if (patchStatusEl) {
const current = validation.rect || liveRect;
const current = validation.rect || liveShape;
patchStatusEl.textContent = validation.ok
? `Selected: ${formatRectSize(validation.rect)}. Release to confirm patch selection.`
: `${validation.reason} Current: ${formatRectSize(current)}.`;
@ -186,9 +280,16 @@ function updateSelectionOverlay() {
}
function updateSelectionOverlayFromWorldRect() {
if (!selectionEl || !state.selectionRect || !state.camera || !activeMap()) return;
if (!state.selectionRect || !state.camera || !activeMap()) return;
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return;
if (Array.isArray(state.selectionRect.polygon) && state.selectionRect.polygon.length >= 3) {
const points = state.selectionRect.polygon.map(worldCellToOverlayPoint)
.map((p) => ({ x: Math.min(Math.max(p.x, 0), rect.width), y: Math.min(Math.max(p.y, 0), rect.height) }));
drawSelectionSvg(points, !validatePatchRect(state.selectionRect, state.world).ok);
if (selectionEl) selectionEl.style.display = "none";
return;
}
const cameraX = Math.round(state.camera.x || 0);
const cameraY = Math.round(state.camera.y || 0);
const p0 = mapPixelToScreenPoint((state.selectionRect.x0 - cameraX) * CELL_SIZE, (state.selectionRect.y0 - cameraY) * CELL_SIZE);
@ -205,6 +306,7 @@ function updateSelectionOverlayFromWorldRect() {
selectionEl.style.display = "none";
return;
}
hideSelectionSvg();
selectionEl.style.display = "block";
selectionEl.style.left = `${canvas.offsetLeft + x0}px`;
selectionEl.style.top = `${canvas.offsetTop + y0}px`;
@ -218,7 +320,8 @@ function formatRectSize(rect) {
if (!rect) return "-";
const w = Math.max(0, rect.x1 - rect.x0);
const h = Math.max(0, rect.y1 - rect.y0);
return `${w} x ${h} cells / ${(w * h).toLocaleString()} cells`;
const area = Math.max(0, rect.areaCells || (w * h));
return `${w} x ${h} cells / ${area.toLocaleString()} cells`;
}
function normalizePatchVariant(value) {
@ -251,7 +354,7 @@ function updatePatchControls() {
if (alternativePatchButton) alternativePatchButton.disabled = !validation.ok;
if (!patchStatusEl) return;
if (!state.selectionRect) {
patchStatusEl.textContent = `Right-drag an area. Minimum: ${PATCH_MIN_WIDTH} x ${PATCH_MIN_HEIGHT} cells and ${PATCH_MIN_AREA.toLocaleString()} cells. Current variant: ${variant}.`;
patchStatusEl.textContent = `Right-drag a freeform area. Minimum: ${PATCH_MIN_WIDTH} x ${PATCH_MIN_HEIGHT} cells and ${PATCH_MIN_AREA.toLocaleString()} cells. Current variant: ${variant}.`;
patchStatusEl.classList.toggle("invalid", false);
return;
}
@ -262,7 +365,7 @@ function updatePatchControls() {
}
const rects = buildPatchRects(validation.rect, state.world);
const patchText = state.lastPatchResult
? ` Last patch: ${state.lastPatchResult.label}, variant ${state.lastPatchResult.variant ?? "-"}, mode ${state.lastPatchResult.patchGenerationMode || "legacy-full-pipeline"}, terrain ${state.lastPatchResult.updatedCells.toLocaleString()} cells, coast ${state.lastPatchResult.coastCellsChanged || 0}, natural ${state.lastPatchResult.naturalRegionsUpdated || 0}${state.lastPatchResult.humanGeography?.ok ? `, connectors ${(state.lastPatchResult.humanGeography.roadConnectorsCreated || 0) + (state.lastPatchResult.humanGeography.railwayConnectorsCreated || 0)}, admin ${state.lastPatchResult.humanGeography.adminCellsReassigned || 0}, idmap ${state.lastPatchResult.humanGeography.continuityIdMappings || 0}/${state.lastPatchResult.humanGeography.continuityIdMappedCells || 0}, invalid ports ${state.lastPatchResult.humanGeography.invalidPortsRemoved || 0}` : ""}.`
? ` Last patch: ${state.lastPatchResult.label}, variant ${state.lastPatchResult.variant ?? "-"}, mode ${state.lastPatchResult.patchGenerationMode || "legacy-full-pipeline"}, terrain ${state.lastPatchResult.updatedCells.toLocaleString()} cells, coast ${state.lastPatchResult.coastCellsChanged || 0}, natural ${state.lastPatchResult.naturalRegionsUpdated || 0}${state.lastPatchResult.humanGeography?.ok ? `, connectors ${(state.lastPatchResult.humanGeography.roadConnectorsCreated || 0) + (state.lastPatchResult.humanGeography.railwayConnectorsCreated || 0)}, admin ${state.lastPatchResult.humanGeography.adminCellsReassigned || 0}, invalid ports ${state.lastPatchResult.humanGeography.invalidPortsRemoved || 0}` : ""}.`
: "";
patchStatusEl.textContent = `Variant: ${variant}. Core: ${formatRectSize(rects.coreRect)}. Write: ${formatRectSize(rects.writeRect)}. Context: ${formatRectSize(rects.contextRect)}. Repair: ${formatRectSize(rects.repairRect)}.${patchText}`;
patchStatusEl.classList.toggle("invalid", false);
@ -296,39 +399,31 @@ function schedulePanRedraw(camera) {
function hideSelectionOverlay() {
dragState.selectStart = null;
dragState.selectEnd = null;
dragState.selectPath = null;
state.selectionRect = null;
resetPatchVariant({ update: false });
hideSelectionSvg();
if (selectionEl) selectionEl.style.display = "none";
updatePatchControls();
}
function selectionPixelsToCells(start, end) {
const map = activeMap();
if (!map || !start || !end) return null;
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return null;
const toMapPixel = (p) => {
const t = zoomTransform();
const canvasX = p.x * (t.width / rect.width);
const canvasY = p.y * (t.height / rect.height);
return { x: (canvasX - t.tx) / t.zoom, y: (canvasY - t.ty) / t.zoom };
};
const a = toMapPixel(start);
const b = toMapPixel(end);
const localX0 = Math.floor(Math.min(a.x, b.x) / CELL_SIZE);
const localY0 = Math.floor(Math.min(a.y, b.y) / CELL_SIZE);
const localX1 = Math.ceil(Math.max(a.x, b.x) / CELL_SIZE);
const localY1 = Math.ceil(Math.max(a.y, b.y) / CELL_SIZE);
const cameraX = Math.round(state.camera?.x || 0);
const cameraY = Math.round(state.camera?.y || 0);
const a = screenPointToWorldCell(start);
const b = screenPointToWorldCell(end);
if (!a || !b) return null;
return {
x0: cameraX + Math.min(Math.max(localX0, 0), map.width - 1),
y0: cameraY + Math.min(Math.max(localY0, 0), map.height - 1),
x1: cameraX + Math.min(Math.max(localX1, 1), map.width),
y1: cameraY + Math.min(Math.max(localY1, 1), map.height),
x0: Math.min(a.x, b.x),
y0: Math.min(a.y, b.y),
x1: Math.max(a.x, b.x) + 1,
y1: Math.max(a.y, b.y) + 1,
};
}
function selectionPixelsToShape(start, end, path = null) {
if (Array.isArray(path) && path.length >= 3) return selectionPathToShape(path);
return selectionPixelsToCells(start, end);
}
function handleMapPointerDown(event) {
if (!state.world || !canvasShell) return;
if (event.button !== 0 && event.button !== 2) return;
@ -346,6 +441,7 @@ function handleMapPointerDown(event) {
dragState.mode = "select";
dragState.selectStart = clampCanvasPoint(event);
dragState.selectEnd = dragState.selectStart;
dragState.selectPath = [dragState.selectStart];
canvasShell.classList.add("selecting");
updateSelectionOverlay();
}
@ -362,13 +458,16 @@ function handleMapPointerMove(event) {
const cellSize = Math.max(1, displayedCellSize());
const dxCells = Math.round((event.clientX - dragState.startClientX) / cellSize);
const dyCells = Math.round((event.clientY - dragState.startClientY) / cellSize);
const nextCamera = clampCameraToWorld({
const nextCamera = clampCameraForView({
x: dragState.startCameraX - dxCells,
y: dragState.startCameraY - dyCells,
}, state.world, MAP_W, MAP_H);
}, viewportSizeForZoom(state.zoom));
schedulePanRedraw(nextCamera);
} else if (dragState.mode === "select") {
dragState.selectEnd = clampCanvasPoint(event);
if (!dragState.selectPath || Math.hypot(dragState.selectEnd.x - dragState.selectPath[dragState.selectPath.length - 1].x, dragState.selectEnd.y - dragState.selectPath[dragState.selectPath.length - 1].y) >= 3) {
dragState.selectPath = [...(dragState.selectPath || []), dragState.selectEnd];
}
updateSelectionOverlay();
}
@ -380,10 +479,13 @@ function handleMapPointerUp(event) {
const wasPanning = dragState.mode === "pan";
if (dragState.mode === "select") {
dragState.selectEnd = clampCanvasPoint(event);
if (!dragState.selectPath || dragState.selectPath.length < 2) dragState.selectPath = [dragState.selectStart, dragState.selectEnd];
else dragState.selectPath = [...dragState.selectPath, dragState.selectEnd];
const shape = selectionPixelsToShape(dragState.selectStart, dragState.selectEnd, dragState.selectPath);
const width = Math.abs(dragState.selectEnd.x - dragState.selectStart.x);
const height = Math.abs(dragState.selectEnd.y - dragState.selectStart.y);
if (width >= 4 && height >= 4) {
state.selectionRect = selectionPixelsToCells(dragState.selectStart, dragState.selectEnd);
if (shape && (dragState.selectPath.length >= 3 || (width >= 4 && height >= 4))) {
state.selectionRect = shape;
state.lastPatchResult = null;
resetPatchVariant({ update: false });
updateSelectionOverlayFromWorldRect();
@ -553,7 +655,8 @@ function buildHoverEntities(map) {
function nearestEntity(items, x, y, maxDistance = 5) {
let best = null;
let bestD = maxDistance;
for (const item of items) {
for (const item of items || []) {
if (!item || !Number.isFinite(item.x) || !Number.isFinite(item.y)) continue;
const d = Math.hypot(item.x - x, item.y - y);
if (d < bestD) { best = item; bestD = d; }
}
@ -564,61 +667,106 @@ function landuseName(value) {
return landuseLabel(value);
}
function numericIdOf(item) {
for (const key of ["adminId", "municipalityId", "id", "adminNumericId"]) {
const ADMIN_ID_KEYS = ["adminId", "municipalityId", "adminNumericId", "id", "numericId"];
const PREFECTURE_ID_KEYS = ["prefectureRegionId", "prefectureId", "id", "numericId"];
const MUNICIPALITY_NAME_KEYS = ["municipalityName", "name", "canonicalSettlementName", "municipalityRootName", "generatedMunicipalityName", "label"];
const PREFECTURE_NAME_KEYS = ["prefectureName", "prefectureRegionName", "regionName", "name", "label"];
const POPULATION_KEYS = ["municipalityPopulation", "adminPopulation", "population", "estimatedPopulation"];
function numericIdOf(item, keys = ADMIN_ID_KEYS) {
for (const key of keys) {
const value = item?.[key];
if (Number.isFinite(value)) return value;
if (Number.isFinite(value)) return Math.floor(value);
}
return null;
}
function hasNumericId(item, id, keys = ADMIN_ID_KEYS) {
if (!item || id == null || id < 0) return false;
return keys.some((key) => Number.isFinite(item?.[key]) && Math.floor(item[key]) === Math.floor(id));
}
function firstUsableText(item, keys) {
for (const key of keys) {
const value = item?.[key];
if (!looksNumericName(value)) return String(value).trim();
}
return "";
}
function firstPopulation(item) {
for (const key of POPULATION_KEYS) {
const value = item?.[key];
if (Number.isFinite(value) && value > 0) return Math.round(value);
}
return null;
}
function adminCenterForId(map, adminId) {
if (!map || adminId == null || adminId < 0) return null;
const centers = map.adminCenters || [];
const direct = centers[adminId];
if (direct && [direct.adminId, direct.municipalityId, direct.id, direct.adminNumericId].some((v) => v === adminId)) return direct;
return centers.find((center) => [center?.adminId, center?.municipalityId, center?.id, center?.adminNumericId].some((v) => v === adminId)) || null;
const centers = (map.adminCenters || []).filter(Boolean);
const exact = centers.find((center) => hasNumericId(center, adminId));
if (exact) return exact;
// Some legacy/admin debug arrays were once addressed by array index. Keep this
// only as a guarded fallback so numeric IDs are not mistaken for indexes.
const direct = map.adminCenters?.[adminId];
return hasNumericId(direct, adminId) ? direct : null;
}
function looksNumericName(name) {
if (!name) return true;
const text = String(name).trim();
return !text || /^-?\d+(?:\s*[,]\s*\d+)*$/u.test(text) || /^(Municipality|Prefecture)\s+-?\d+/i.test(text);
return !text || /^-?\d+(?:\s*[,]\s*\d+)*$/u.test(text) || /^(Municipality|Prefecture|Admin|Region)\s*-?\d+/i.test(text);
}
function nearestNamedAdminCenter(map, cellIndex, maxDistance = 36) {
function nearestNamedAdminCenter(map, cellIndex, maxDistance = 36, adminId = null) {
if (!map || cellIndex < 0) return null;
const x = cellIndex % map.width;
const y = Math.floor(cellIndex / map.width);
let best = null;
let bestD = maxDistance;
for (const center of map.adminCenters || []) {
const name = center?.name || center?.municipalityName || center?.canonicalSettlementName || center?.municipalityRootName || center?.generatedMunicipalityName;
if (looksNumericName(name) || !Number.isFinite(center?.x) || !Number.isFinite(center?.y)) continue;
if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y)) continue;
if (adminId != null && adminId >= 0 && !hasNumericId(center, adminId)) continue;
if (!firstUsableText(center, MUNICIPALITY_NAME_KEYS)) continue;
const d = Math.hypot(center.x - x, center.y - y);
if (d < bestD) { best = center; bestD = d; }
}
if (!best && adminId != null && adminId >= 0) return nearestNamedAdminCenter(map, cellIndex, maxDistance, null);
return best;
}
function adminName(map, adminId, cellIndex = -1) {
const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex);
const name = center?.municipalityName || center?.name || center?.canonicalSettlementName || center?.municipalityRootName || center?.generatedMunicipalityName;
if (!looksNumericName(name)) return name;
const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex, 54, adminId);
const name = firstUsableText(center, MUNICIPALITY_NAME_KEYS);
if (name) return name;
return adminId >= 0 ? "Unnamed municipality" : "-";
}
function adminPopulation(map, adminId, cellIndex = -1) {
const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex);
return Number.isFinite(center?.municipalityPopulation) ? center.municipalityPopulation : null;
const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex, 54, adminId);
const centerPop = firstPopulation(center);
if (centerPop !== null) return centerPop;
let sum = 0;
let found = false;
for (const key of ["modernCities", "satelliteCities", "ports", "markets", "villages"]) {
for (const p of map?.[key] || []) {
if (!hasNumericId(p, adminId)) continue;
const pop = firstPopulation(p);
if (pop !== null) { sum += pop; found = true; }
}
}
return found ? sum : null;
}
function prefectureNameForCell(map, i) {
const id = map.prefectureRegionId?.[i] ?? -1;
const region = (map.prefectureRegions || []).find((p) => p.id === id);
if (region?.name && !looksNumericName(region.name)) return region.name;
const center = nearestNamedAdminCenter(map, i, 80);
return center?.prefectureName || center?.prefectureRegionName || (id >= 0 ? "Unnamed prefecture" : "-");
const region = (map.prefectureRegions || []).find((p) => hasNumericId(p, id, PREFECTURE_ID_KEYS));
const regionName = firstUsableText(region, PREFECTURE_NAME_KEYS);
if (regionName) return regionName;
const center = nearestNamedAdminCenter(map, i, 90);
const fromCenter = firstUsableText(center, ["prefectureName", "prefectureRegionName", "regionName"]);
return fromCenter || (id >= 0 ? "Unnamed prefecture" : "-");
}
function updateTooltip(event) {
@ -640,8 +788,9 @@ function updateTooltip(event) {
const hoveredAdminId = map.adminId?.[i] ?? -1;
const hoveredAdminPopulation = adminPopulation(map, hoveredAdminId, i);
const coordinateText = worldCell ? `World ${worldCell.x}, ${worldCell.y} / View ${x}, ${y}` : `${x}, ${y}`;
const entityName = firstUsableText(entity, ["name", "facilityLabel", "municipalityName", "canonicalSettlementName", "kind"]);
const entityTitle = entity
? `${entity.name || entity.facilityLabel || entity.kind || "Feature"} / ${entity.kind || "Feature"}`
? `${entityName || adminName(map, hoveredAdminId, i) || entity.kind || "Feature"} / ${entity.kind || "Feature"}`
: coordinateText;
const lines = [
`<strong>${entityTitle}</strong>`,
@ -721,23 +870,38 @@ function handleCanvasWheel(event) {
if (!state.world || !activeMap()) return;
event.preventDefault();
tooltipEl?.classList.remove("visible");
const beforeCell = mapClientToCell(event);
const beforeSize = viewportSizeForZoom(state.zoom);
const beforeCell = mapClientToCell(event, beforeSize);
const beforeWorld = beforeCell ? viewportCellToWorldCell(beforeCell) : null;
const oldZoom = clampZoom(state.zoom || 1);
const delta = event.deltaY < 0 ? 1.12 : 1 / 1.12;
const delta = event.deltaY < 0 ? 1.10 : 1 / 1.10;
const nextZoom = clampZoom(oldZoom * delta);
if (Math.abs(nextZoom - oldZoom) < 0.001) return;
state.zoom = nextZoom;
const nextSize = syncViewportSize();
if (beforeWorld) {
const afterCell = mapClientToCell(event);
const afterCell = mapClientToCell(event, nextSize);
if (afterCell) {
state.camera = clampCameraToWorld({
state.camera = clampCameraForView({
x: beforeWorld.x - afterCell.x,
y: beforeWorld.y - afterCell.y,
}, state.world, MAP_W, MAP_H);
}, nextSize);
}
}
redraw({ fastTerrain: false });
// Wheel events can fire dozens of times per second. Do one lightweight redraw
// per frame, then a full labeled/continuous redraw once zooming settles.
if (zoomRedrawRaf == null) {
zoomRedrawRaf = requestAnimationFrame(() => {
zoomRedrawRaf = null;
redraw({ fastTerrain: true, allowWorldExpand: false });
});
}
if (zoomSettledTimer != null) clearTimeout(zoomSettledTimer);
zoomSettledTimer = window.setTimeout(() => {
zoomSettledTimer = null;
redraw({ fastTerrain: false, allowWorldExpand: false });
}, 140);
}
async function generateSelectedPatch() {
@ -786,26 +950,32 @@ async function generateAlternativePatch() {
function redraw(options = {}) {
if (!state.world) return;
const expansion = ensureWorldPaddingForCamera(state.world, state.camera, MAP_W, MAP_H);
const viewSize = syncViewportSize();
const expansion = options.allowWorldExpand === false ? null : ensureWorldPaddingForCamera(state.world, state.camera, viewSize.width, viewSize.height);
if (expansion?.expanded) {
state.camera = { x: (state.camera?.x || 0) + (expansion.dx || 0), y: (state.camera?.y || 0) + (expansion.dy || 0) };
if (state.selectionRect) {
const dx = expansion.dx || 0;
const dy = expansion.dy || 0;
state.selectionRect = {
x0: state.selectionRect.x0 + (expansion.dx || 0),
y0: state.selectionRect.y0 + (expansion.dy || 0),
x1: state.selectionRect.x1 + (expansion.dx || 0),
y1: state.selectionRect.y1 + (expansion.dy || 0),
...state.selectionRect,
x0: state.selectionRect.x0 + dx,
y0: state.selectionRect.y0 + dy,
x1: state.selectionRect.x1 + dx,
y1: state.selectionRect.y1 + dy,
polygon: Array.isArray(state.selectionRect.polygon) ? state.selectionRect.polygon.map((p) => ({ x: p.x + dx, y: p.y + dy })) : state.selectionRect.polygon,
};
}
}
state.camera = clampCameraToWorld(state.camera, state.world, MAP_W, MAP_H);
state.viewportMap = getViewportMap(state.world, state.camera, MAP_W, MAP_H);
state.hoverEntities = buildHoverEntities(state.viewportMap);
state.camera = clampCameraForView(state.camera, viewSize);
state.viewportMap = getViewportMap(state.world, state.camera, viewSize.width, viewSize.height, { light: !!options.fastTerrain });
state.hoverEntities = options.fastTerrain ? [] : buildHoverEntities(state.viewportMap);
drawMap(canvas, state.viewportMap, {
mode: state.mode,
showFeatures: state.showFeatures,
showFeatures: state.showFeatures && !options.fastTerrain,
showLabels: state.showLabels && !options.fastTerrain,
continuousTerrain: !options.fastTerrain,
fastTerrain: !!options.fastTerrain,
zoom: state.zoom || 1,
});
applyCanvasZoom();