hmm
This commit is contained in:
parent
112e6bf86b
commit
f2d0306d96
13 changed files with 1434 additions and 516 deletions
388
app.js
388
app.js
|
|
@ -28,6 +28,8 @@ const state = {
|
|||
world: null,
|
||||
camera: { x: 0, y: 0 },
|
||||
viewportMap: null,
|
||||
viewWidth: MAP_W,
|
||||
viewHeight: MAP_H,
|
||||
hoverEntities: [],
|
||||
selectionRect: null,
|
||||
patchVariant: 0,
|
||||
|
|
@ -50,6 +52,7 @@ const showLabelsInput = document.getElementById("showLabels");
|
|||
const modeGrid = document.getElementById("modeGrid");
|
||||
const statsEl = document.getElementById("stats");
|
||||
const tooltipEl = document.getElementById("mapTooltip");
|
||||
const selectionSvgEl = document.getElementById("mapSelectionSvg");
|
||||
const selectionEl = document.getElementById("mapSelection");
|
||||
const progressEl = document.getElementById("generationProgress");
|
||||
const progressStageEl = document.getElementById("generationProgressStage");
|
||||
|
|
@ -57,6 +60,8 @@ const progressTimingsEl = document.getElementById("generationProgressTimings");
|
|||
let generationStartedAt = 0;
|
||||
let generationCurrentStage = "";
|
||||
let generationTimer = null;
|
||||
let zoomRedrawRaf = null;
|
||||
let zoomSettledTimer = null;
|
||||
|
||||
const dragState = {
|
||||
mode: null,
|
||||
|
|
@ -67,6 +72,7 @@ const dragState = {
|
|||
startCameraY: 0,
|
||||
selectStart: null,
|
||||
selectEnd: null,
|
||||
selectPath: null,
|
||||
pendingCamera: null,
|
||||
panRaf: null,
|
||||
};
|
||||
|
|
@ -81,19 +87,31 @@ function clampZoom(value) {
|
|||
return Math.min(Math.max(parsed, 0.55), 2.8);
|
||||
}
|
||||
|
||||
function zoomTransform() {
|
||||
const zoom = clampZoom(state.zoom || 1);
|
||||
const width = canvas.width || MAP_W * CELL_SIZE;
|
||||
const height = canvas.height || MAP_H * CELL_SIZE;
|
||||
function viewportSizeForZoom(zoom = state.zoom) {
|
||||
const z = clampZoom(zoom || 1);
|
||||
return {
|
||||
zoom,
|
||||
width,
|
||||
height,
|
||||
tx: width * (1 - zoom) * 0.5,
|
||||
ty: height * (1 - zoom) * 0.5,
|
||||
width: Math.max(1, Math.ceil(MAP_W / z)),
|
||||
height: Math.max(1, Math.ceil(MAP_H / z)),
|
||||
};
|
||||
}
|
||||
|
||||
function clampCameraForView(camera, size = viewportSizeForZoom(state.zoom)) {
|
||||
return clampCameraToWorld(camera, state.world, size?.width || MAP_W, size?.height || MAP_H);
|
||||
}
|
||||
|
||||
function syncViewportSize() {
|
||||
const size = viewportSizeForZoom(state.zoom);
|
||||
state.viewWidth = size.width;
|
||||
state.viewHeight = size.height;
|
||||
return size;
|
||||
}
|
||||
|
||||
function mapCellScreenSize(map = activeMap()) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mapWidth = Math.max(1, map?.width || state.viewWidth || MAP_W);
|
||||
return rect.width ? rect.width / mapWidth : CELL_SIZE * clampZoom(state.zoom || 1);
|
||||
}
|
||||
|
||||
function applyCanvasZoom() {
|
||||
if (!canvas) return;
|
||||
state.zoom = clampZoom(state.zoom || 1);
|
||||
|
|
@ -105,38 +123,39 @@ function applyCanvasZoom() {
|
|||
}
|
||||
|
||||
function displayedCellSize() {
|
||||
return CELL_SIZE * clampZoom(state.zoom || 1);
|
||||
return mapCellScreenSize();
|
||||
}
|
||||
|
||||
function screenPointToMapPixel(clientX, clientY) {
|
||||
function screenPointToMapPixel(clientX, clientY, sizeOverride = null) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return null;
|
||||
const t = zoomTransform();
|
||||
const scaleX = t.width / rect.width;
|
||||
const scaleY = t.height / rect.height;
|
||||
const canvasX = (clientX - rect.left) * scaleX;
|
||||
const canvasY = (clientY - rect.top) * scaleY;
|
||||
return {
|
||||
x: (canvasX - t.tx) / t.zoom,
|
||||
y: (canvasY - t.ty) / t.zoom,
|
||||
};
|
||||
const map = activeMap();
|
||||
const viewWidth = Math.max(1, sizeOverride?.width || map?.width || state.viewWidth || MAP_W);
|
||||
const viewHeight = Math.max(1, sizeOverride?.height || map?.height || state.viewHeight || MAP_H);
|
||||
const canvasX = (clientX - rect.left) * ((canvas.width || MAP_W * CELL_SIZE) / rect.width);
|
||||
const canvasY = (clientY - rect.top) * ((canvas.height || MAP_H * CELL_SIZE) / rect.height);
|
||||
const cellX = canvasX / Math.max(1e-6, (canvas.width || MAP_W * CELL_SIZE) / viewWidth);
|
||||
const cellY = canvasY / Math.max(1e-6, (canvas.height || MAP_H * CELL_SIZE) / viewHeight);
|
||||
return { x: cellX * CELL_SIZE, y: cellY * CELL_SIZE };
|
||||
}
|
||||
|
||||
function mapPixelToScreenPoint(px, py) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const t = zoomTransform();
|
||||
const canvasX = t.tx + px * t.zoom;
|
||||
const canvasY = t.ty + py * t.zoom;
|
||||
const map = activeMap();
|
||||
const viewWidth = Math.max(1, map?.width || state.viewWidth || MAP_W);
|
||||
const viewHeight = Math.max(1, map?.height || state.viewHeight || MAP_H);
|
||||
const canvasX = (px / CELL_SIZE) * ((canvas.width || MAP_W * CELL_SIZE) / viewWidth);
|
||||
const canvasY = (py / CELL_SIZE) * ((canvas.height || MAP_H * CELL_SIZE) / viewHeight);
|
||||
return {
|
||||
x: canvas.offsetLeft + canvasX * (rect.width / Math.max(1, t.width)),
|
||||
y: canvas.offsetTop + canvasY * (rect.height / Math.max(1, t.height)),
|
||||
x: canvas.offsetLeft + canvasX * (rect.width / Math.max(1, canvas.width || MAP_W * CELL_SIZE)),
|
||||
y: canvas.offsetTop + canvasY * (rect.height / Math.max(1, canvas.height || MAP_H * CELL_SIZE)),
|
||||
};
|
||||
}
|
||||
|
||||
function mapClientToCell(event) {
|
||||
function mapClientToCell(event, sizeOverride = null) {
|
||||
const map = activeMap();
|
||||
if (!map) return null;
|
||||
const p = screenPointToMapPixel(event.clientX, event.clientY);
|
||||
if (!map && !sizeOverride) return null;
|
||||
const p = screenPointToMapPixel(event.clientX, event.clientY, sizeOverride);
|
||||
if (!p) return null;
|
||||
return {
|
||||
x: Math.floor(p.x / CELL_SIZE),
|
||||
|
|
@ -160,24 +179,99 @@ function clampCanvasPoint(event) {
|
|||
};
|
||||
}
|
||||
|
||||
function screenPointToWorldCell(point) {
|
||||
const map = activeMap();
|
||||
if (!map || !point) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return null;
|
||||
const viewWidth = Math.max(1, map.width || state.viewWidth || MAP_W);
|
||||
const viewHeight = Math.max(1, map.height || state.viewHeight || MAP_H);
|
||||
const canvasX = point.x * ((canvas.width || MAP_W * CELL_SIZE) / rect.width);
|
||||
const canvasY = point.y * ((canvas.height || MAP_H * CELL_SIZE) / rect.height);
|
||||
const localX = Math.floor((canvasX / Math.max(1e-6, (canvas.width || MAP_W * CELL_SIZE) / viewWidth)));
|
||||
const localY = Math.floor((canvasY / Math.max(1e-6, (canvas.height || MAP_H * CELL_SIZE) / viewHeight)));
|
||||
const cameraX = Math.round(state.camera?.x || 0);
|
||||
const cameraY = Math.round(state.camera?.y || 0);
|
||||
return {
|
||||
x: cameraX + Math.min(Math.max(localX, 0), Math.max(0, map.width - 1)),
|
||||
y: cameraY + Math.min(Math.max(localY, 0), Math.max(0, map.height - 1)),
|
||||
};
|
||||
}
|
||||
|
||||
function worldCellToOverlayPoint(point) {
|
||||
const cameraX = Math.round(state.camera?.x || 0);
|
||||
const cameraY = Math.round(state.camera?.y || 0);
|
||||
const screen = mapPixelToScreenPoint((point.x - cameraX + 0.5) * CELL_SIZE, (point.y - cameraY + 0.5) * CELL_SIZE);
|
||||
return { x: screen.x - canvas.offsetLeft, y: screen.y - canvas.offsetTop };
|
||||
}
|
||||
|
||||
function simplifySelectionPath(points) {
|
||||
const out = [];
|
||||
for (const p of points || []) {
|
||||
if (!out.length || Math.hypot(out[out.length - 1].x - p.x, out[out.length - 1].y - p.y) >= 6) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function polygonArea(points) {
|
||||
let area = 0;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const a = points[i];
|
||||
const b = points[(i + 1) % points.length];
|
||||
area += a.x * b.y - b.x * a.y;
|
||||
}
|
||||
return Math.abs(area) * 0.5;
|
||||
}
|
||||
|
||||
function selectionPathToShape(points) {
|
||||
const simplified = simplifySelectionPath(points || []);
|
||||
if (simplified.length < 3) return null;
|
||||
const polygon = simplified.map(screenPointToWorldCell).filter(Boolean);
|
||||
if (polygon.length < 3) return null;
|
||||
const xs = polygon.map((p) => p.x);
|
||||
const ys = polygon.map((p) => p.y);
|
||||
return {
|
||||
kind: "lasso",
|
||||
polygon,
|
||||
x0: Math.min(...xs),
|
||||
y0: Math.min(...ys),
|
||||
x1: Math.max(...xs) + 1,
|
||||
y1: Math.max(...ys) + 1,
|
||||
areaCells: Math.max(1, Math.round(polygonArea(polygon))),
|
||||
};
|
||||
}
|
||||
|
||||
function drawSelectionSvg(points, invalid = false) {
|
||||
if (!selectionSvgEl) return;
|
||||
if (!points || points.length < 3) {
|
||||
selectionSvgEl.style.display = "none";
|
||||
selectionSvgEl.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const pts = points.map((p) => `${p.x},${p.y}`).join(" ");
|
||||
selectionSvgEl.setAttribute("viewBox", `0 0 ${canvas.clientWidth || canvas.width || 1} ${canvas.clientHeight || canvas.height || 1}`);
|
||||
selectionSvgEl.innerHTML = `<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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue