Implement inspect mode for shape metadata and enhance overlay interactions

This commit is contained in:
Marco 2026-03-27 13:30:49 +01:00
commit ab5e514e61
6 changed files with 325 additions and 38 deletions

View file

@ -19,18 +19,33 @@ Phase 1 implementation choice:
Goal: promote editor-only content from "baked into the raster" to interactive overlay objects. Goal: promote editor-only content from "baked into the raster" to interactive overlay objects.
- keep the base map rendered as a flat server-generated tile surface - completed: keep the base map rendered as a flat server-generated tile surface
- extract editor-only objects into a standalone overlay data stream - completed: extract editor-only objects into a standalone overlay data stream
- render those overlay items in the client as positioned interactive markers or sprites above the base map - completed: render editor-only overlay items in the client as positioned sprite overlays above the base map
- on hover, slightly scale the hovered item and show a tooltip with its decoded metadata payload - completed: remove editor-only records from the base raster so overlay shapes are not duplicated in the map tiles
- completed: fix overlay transparency so the sprite background stays transparent instead of fading black
- completed: add an inspection mode checkbox that shows metadata for any rendered shape under the cursor, not just editor overlays
- improve roof detection before or during the overlay split because the current roof filtering still lets some roofs render when they should not - improve roof detection before or during the overlay split because the current roof filtering still lets some roofs render when they should not
- identify occluding helper geometry such as invisible walls and render those semitransparently so they remain legible without hiding too much of the map beneath them - identify occluding helper geometry such as invisible walls and render those semitransparently so they remain legible without hiding too much of the map beneath them
- fix pipe rendering because pipes currently are not showing up correctly - fix pipe rendering because pipes currently are not showing up correctly
- investigate force-field rendering because they appear yellow instead of the expected blue semitransparent look; this may be a debug-shape choice issue or a palette/color-rendering issue - investigate force-field rendering because they appear yellow instead of the expected blue semitransparent look; this may be a debug-shape choice issue or a palette/color-rendering issue
- likely revisit ScummVM Crusader handling in `D:\source\scummvm` to confirm what editor/debug records carry and how best to decode them for display - likely revisit ScummVM Crusader handling in `D:\source\scummvm` to confirm what editor/debug records carry and how best to decode them for display
Current phase 2 status:
- the server now builds three outputs from one map build: base raster tiles, editor-only overlay sprites, and a shared inspectable-shape metadata stream
- editor-only shapes are interactive overlay sprites rendered from their original decoded frames rather than synthetic markers
- when inspect mode is active, the cursor reports metadata for whichever rendered shape is currently under it, including base-map shapes
Next steps:
- use inspect mode on representative maps to identify which visible structures are true roofs versus normal geometry so roof filtering can be tightened with evidence
- decide which helper/occluder families should stay semitransparent overlays and which should eventually be hidden or toggled separately
- inspect broken pipe shapes and compare their metadata against ScummVM handling to determine why they currently render incorrectly
- inspect force-field shapes and compare palette or translucency traits against expected in-game appearance
Open questions for phase 2: Open questions for phase 2:
- which editor-only families should become interactive overlays versus remain baked into the base render - which helper/editor families should stay as overlay sprites versus gain their own visibility toggles
- what exact metadata fields are reliable enough to expose in the tooltip - what exact metadata fields are reliable enough to expose in the tooltip long-term
- whether some editor-only entries should be clustered, filtered, or toggled by family to keep dense maps usable - whether some editor-only entries should be clustered, filtered, or toggled by family to keep dense maps usable

View file

@ -180,6 +180,98 @@ function overlayNotes(item, info) {
return notes; return notes;
} }
function classifyBaseKind(info) {
if (info.isRoof) {
return "roof";
}
if (info.isOccl || info.isInvitem) {
return "helper";
}
if (info.isLand) {
return "terrain";
}
return "base";
}
function inspectLabel(layer, kind) {
if (layer === "overlay") {
return overlayLabel(kind);
}
if (kind === "roof") {
return "Roof Shape";
}
if (kind === "helper") {
return "Occluding Helper";
}
if (kind === "terrain") {
return "Terrain Shape";
}
return "Map Shape";
}
function serializeInspectableItem(node, minLeft, minTop, id, layer, stackOrder) {
const { item, info, frame } = node;
const kind = layer === "overlay" ? classifyOverlayKind(item, info) : classifyBaseKind(info);
const sceneLeft = node.left - minLeft;
const sceneTop = node.top - minTop;
return {
id,
layer,
stackOrder,
kind,
label: inspectLabel(layer, kind),
family: info.family,
source: item.source,
world: {
x: item.x,
y: item.y,
z: item.z
},
mapNum: item.mapNum,
npcNum: item.npcNum,
nextItem: item.nextItem,
quality: item.quality,
shape: item.shape,
frame: item.frame,
frameSize: {
width: frame.width,
height: frame.height,
xoff: frame.xoff,
yoff: frame.yoff
},
screen: {
left: sceneLeft,
top: sceneTop,
right: node.right - minLeft,
bottom: node.bottom - minTop,
width: node.right - node.left,
height: node.bottom - node.top,
anchorX: Math.trunc(sceneLeft + (node.right - node.left) / 2),
anchorY: node.bottom - minTop
},
flags: {
raw: item.flags,
hex: toHex(item.flags),
invisible: Boolean(item.flags & FLAG_INVISIBLE),
flipped: Boolean(item.flags & FLAG_FLIPPED)
},
traits: {
editor: info.isEditor,
roof: info.isRoof,
occluding: info.isOccl,
translucent: info.isTranslucent,
solid: info.isSolid,
fixed: info.isFixed,
land: info.isLand,
draw: info.isDraw,
invitem: info.isInvitem,
animType: info.animType
},
notes: overlayNotes(item, info)
};
}
function serializeOverlayItem(node, minLeft, minTop, index) { function serializeOverlayItem(node, minLeft, minTop, index) {
const { item, info, frame } = node; const { item, info, frame } = node;
const kind = classifyOverlayKind(item, info); const kind = classifyOverlayKind(item, info);
@ -417,6 +509,7 @@ export class BuildManager {
overlays: [], overlays: [],
overlayNodes: [], overlayNodes: [],
overlayNodeById: new Map(), overlayNodeById: new Map(),
inspectables: [],
minLeft: 0, minLeft: 0,
minTop: 0, minTop: 0,
width: TILE_SIZE, width: TILE_SIZE,
@ -471,6 +564,7 @@ export class BuildManager {
overlays: [], overlays: [],
overlayNodes: [], overlayNodes: [],
overlayNodeById: new Map(), overlayNodeById: new Map(),
inspectables: [],
minLeft: 0, minLeft: 0,
minTop: 0, minTop: 0,
width: TILE_SIZE, width: TILE_SIZE,
@ -516,6 +610,34 @@ export class BuildManager {
for (let index = 0; index < overlays.length; index += 1) { for (let index = 0; index < overlays.length; index += 1) {
overlayNodeById.set(overlays[index].id, overlayProjection.projected[index]); overlayNodeById.set(overlays[index].id, overlayProjection.projected[index]);
} }
const inspectables = [];
for (let index = 0; index < sorted.prepared.length; index += 1) {
const node = sorted.prepared[index];
const stackOrder = node.order >= 0 ? node.order : index;
inspectables.push(
serializeInspectableItem(
node,
bounds.minLeft,
bounds.minTop,
`base:${stackOrder}:${node.item.source}:${node.item.shape}:${node.item.frame}:${node.item.x}:${node.item.y}:${node.item.z}`,
"base",
stackOrder
)
);
}
for (let index = 0; index < overlays.length; index += 1) {
inspectables.push(
serializeInspectableItem(
overlayProjection.projected[index],
bounds.minLeft,
bounds.minTop,
`overlay:${overlays[index].id}`,
"overlay",
sorted.prepared.length + index
)
);
}
inspectables.sort((left, right) => left.stackOrder - right.stackOrder);
const invalidItemCount = sorted.invalidItemCount + overlayProjection.invalidItemCount; const invalidItemCount = sorted.invalidItemCount + overlayProjection.invalidItemCount;
const invalidItems = [...sorted.invalidItems, ...overlayProjection.invalidItems].slice(0, 20); const invalidItems = [...sorted.invalidItems, ...overlayProjection.invalidItems].slice(0, 20);
@ -566,6 +688,7 @@ export class BuildManager {
overlays, overlays,
overlayNodes: overlayProjection.projected, overlayNodes: overlayProjection.projected,
overlayNodeById, overlayNodeById,
inspectables,
minLeft: bounds.minLeft, minLeft: bounds.minLeft,
minTop: bounds.minTop, minTop: bounds.minTop,
width: bounds.width, width: bounds.width,
@ -638,6 +761,13 @@ export class BuildManager {
}; };
} }
getInspectData(jobId, gameId, mapId) {
const job = this.requireReadyJob(jobId, gameId, mapId);
return {
items: job.build.inspectables
};
}
async renderOverlaySprite(jobId, gameId, mapId, overlayId, format = "webp") { async renderOverlaySprite(jobId, gameId, mapId, overlayId, format = "webp") {
const job = this.requireReadyJob(jobId, gameId, mapId); const job = this.requireReadyJob(jobId, gameId, mapId);
const overlay = job.build.overlays.find((item) => item.id === overlayId); const overlay = job.build.overlays.find((item) => item.id === overlayId);

View file

@ -291,16 +291,15 @@ select:disabled,
transition: transform 140ms ease, filter 140ms ease; transition: transform 140ms ease, filter 140ms ease;
} }
.overlay-item:hover, .viewport:not(.inspect-active) .overlay-item:hover,
.overlay-item:focus-visible { .viewport:not(.inspect-active) .overlay-item:focus-visible {
transform: scale(1.05); transform: scale(1.05);
filter: drop-shadow(0 10px 18px rgba(0, 0, 0, 0.34)); filter: drop-shadow(0 10px 18px rgba(0, 0, 0, 0.34));
} }
.overlay-item--helper { .viewport.inspect-active .overlay-item {
outline: 1px solid var(--overlay-helper-stroke); pointer-events: none;
outline-offset: 1px; cursor: crosshair;
background: var(--overlay-helper-fill);
} }
.overlay-sprite { .overlay-sprite {
@ -365,6 +364,15 @@ select:disabled,
font-size: 0.8rem; font-size: 0.8rem;
} }
.inspect-highlight {
position: absolute;
z-index: 4;
border: 2px solid rgba(255, 229, 107, 0.95);
background: rgba(255, 229, 107, 0.08);
box-shadow: 0 0 0 1px rgba(7, 12, 18, 0.82), 0 0 18px rgba(255, 229, 107, 0.28);
pointer-events: none;
}
@keyframes spin { @keyframes spin {
from { from {
transform: rotate(0deg); transform: rotate(0deg);
@ -379,6 +387,14 @@ select:disabled,
cursor: grabbing; cursor: grabbing;
} }
.viewport.inspect-active {
cursor: crosshair;
}
.viewport.inspect-active.is-dragging {
cursor: grabbing;
}
.viewport-hint { .viewport-hint {
position: absolute; position: absolute;
top: 16px; top: 16px;

View file

@ -2,6 +2,7 @@ const mapForm = document.querySelector("#map-form");
const mapSelect = document.querySelector("#map-select"); const mapSelect = document.querySelector("#map-select");
const includeEditorCheckbox = document.querySelector("#include-editor"); const includeEditorCheckbox = document.querySelector("#include-editor");
const includeRoofsCheckbox = document.querySelector("#include-roofs"); const includeRoofsCheckbox = document.querySelector("#include-roofs");
const inspectShapesCheckbox = document.querySelector("#inspect-shapes");
const downloadButton = document.querySelector("#download-button"); const downloadButton = document.querySelector("#download-button");
const spinner = document.querySelector("#spinner"); const spinner = document.querySelector("#spinner");
const progressWrap = document.querySelector("#progress-wrap"); const progressWrap = document.querySelector("#progress-wrap");
@ -9,7 +10,9 @@ const progressFill = document.querySelector("#progress-fill");
const statusBox = document.querySelector("#status"); const statusBox = document.querySelector("#status");
const metaBox = document.querySelector("#meta"); const metaBox = document.querySelector("#meta");
const viewport = document.querySelector("#viewport"); const viewport = document.querySelector("#viewport");
const viewportHint = document.querySelector("#viewport-hint");
const scene = document.querySelector("#scene"); const scene = document.querySelector("#scene");
const inspectHighlight = document.querySelector("#inspect-highlight");
const overlayTooltip = document.querySelector("#overlay-tooltip"); const overlayTooltip = document.querySelector("#overlay-tooltip");
const emptyState = document.querySelector("#empty-state"); const emptyState = document.querySelector("#empty-state");
const zoomLabel = document.querySelector("#zoom-label"); const zoomLabel = document.querySelector("#zoom-label");
@ -31,7 +34,8 @@ const state = {
buildToken: 0, buildToken: 0,
drag: null, drag: null,
pointers: new Map(), pointers: new Map(),
pinch: null pinch: null,
inspectTargetId: null
}; };
const ZOOM_FACTOR = 1.2; const ZOOM_FACTOR = 1.2;
@ -63,6 +67,18 @@ function setStatus(message) {
statusBox.textContent = message; statusBox.textContent = message;
} }
function setInspectMode(active) {
viewport.classList.toggle("inspect-active", active);
viewportHint.textContent = active
? "Inspect mode: move the cursor to identify shapes. Drag still pans."
: "Drag to pan. Scroll or pinch to zoom.";
if (!active) {
state.inspectTargetId = null;
hideInspectHighlight();
hideOverlayTooltip();
}
}
function escapeHtml(value) { function escapeHtml(value) {
return String(value) return String(value)
.replaceAll("&", "&amp;") .replaceAll("&", "&amp;")
@ -273,19 +289,20 @@ function positionOverlayTooltip(clientX, clientY) {
overlayTooltip.style.top = `${top}px`; overlayTooltip.style.top = `${top}px`;
} }
function renderOverlayTooltip(overlay) { function renderShapeTooltip(shape) {
const notes = overlay.notes.length ? overlay.notes.map((note) => `<li>${escapeHtml(note)}</li>`).join("") : ""; const notes = shape.notes.length ? shape.notes.map((note) => `<li>${escapeHtml(note)}</li>`).join("") : "";
return ` return `
<div class="tooltip-eyebrow">${escapeHtml(overlay.label)}</div> <div class="tooltip-eyebrow">${escapeHtml(shape.label)}</div>
<div class="tooltip-title">Shape ${overlay.shape} frame ${overlay.frame}</div> <div class="tooltip-title">Shape ${shape.shape} frame ${shape.frame}</div>
<dl class="tooltip-grid"> <dl class="tooltip-grid">
<dt>Family</dt><dd>${overlay.family}</dd> <dt>Layer</dt><dd>${escapeHtml(shape.layer)}</dd>
<dt>World</dt><dd>${overlay.world.x}, ${overlay.world.y}, ${overlay.world.z}</dd> <dt>Family</dt><dd>${shape.family}</dd>
<dt>Source</dt><dd>${escapeHtml(overlay.source)}</dd> <dt>World</dt><dd>${shape.world.x}, ${shape.world.y}, ${shape.world.z}</dd>
<dt>Flags</dt><dd>${escapeHtml(overlay.flags.hex)}</dd> <dt>Source</dt><dd>${escapeHtml(shape.source)}</dd>
<dt>NPC</dt><dd>${overlay.npcNum || "-"}</dd> <dt>Flags</dt><dd>${escapeHtml(shape.flags.hex)}</dd>
<dt>Map</dt><dd>${overlay.mapNum || "-"}</dd> <dt>NPC</dt><dd>${shape.npcNum || "-"}</dd>
<dt>Quality</dt><dd>${overlay.quality || "-"}</dd> <dt>Map</dt><dd>${shape.mapNum || "-"}</dd>
<dt>Quality</dt><dd>${shape.quality || "-"}</dd>
</dl> </dl>
${notes ? `<ul class="tooltip-notes">${notes}</ul>` : ""} ${notes ? `<ul class="tooltip-notes">${notes}</ul>` : ""}
`; `;
@ -296,8 +313,20 @@ function hideOverlayTooltip() {
overlayTooltip.innerHTML = ""; overlayTooltip.innerHTML = "";
} }
function showOverlayTooltip(overlay, anchor) { function hideInspectHighlight() {
overlayTooltip.innerHTML = renderOverlayTooltip(overlay); inspectHighlight.hidden = true;
}
function showInspectHighlight(shape) {
inspectHighlight.hidden = false;
inspectHighlight.style.left = `${shape.screen.left}px`;
inspectHighlight.style.top = `${shape.screen.top}px`;
inspectHighlight.style.width = `${Math.max(1, shape.screen.width)}px`;
inspectHighlight.style.height = `${Math.max(1, shape.screen.height)}px`;
}
function showShapeTooltip(shape, anchor) {
overlayTooltip.innerHTML = renderShapeTooltip(shape);
overlayTooltip.hidden = false; overlayTooltip.hidden = false;
if (anchor instanceof Event) { if (anchor instanceof Event) {
@ -332,18 +361,35 @@ function createOverlayElement(overlay) {
item.append(sprite); item.append(sprite);
item.addEventListener("pointerenter", (event) => { item.addEventListener("pointerenter", (event) => {
showOverlayTooltip(overlay, event); if (inspectShapesCheckbox.checked) {
return;
}
showShapeTooltip(overlay, event);
}); });
item.addEventListener("pointermove", (event) => { item.addEventListener("pointermove", (event) => {
if (!overlayTooltip.hidden) { if (inspectShapesCheckbox.checked || overlayTooltip.hidden) {
positionOverlayTooltip(event.clientX, event.clientY); return;
} }
positionOverlayTooltip(event.clientX, event.clientY);
});
item.addEventListener("pointerleave", () => {
if (inspectShapesCheckbox.checked) {
return;
}
hideOverlayTooltip();
}); });
item.addEventListener("pointerleave", hideOverlayTooltip);
item.addEventListener("focus", () => { item.addEventListener("focus", () => {
showOverlayTooltip(overlay, item); if (inspectShapesCheckbox.checked) {
return;
}
showShapeTooltip(overlay, item);
});
item.addEventListener("blur", () => {
if (inspectShapesCheckbox.checked) {
return;
}
hideOverlayTooltip();
}); });
item.addEventListener("blur", hideOverlayTooltip);
return { return {
item, item,
@ -351,6 +397,51 @@ function createOverlayElement(overlay) {
}; };
} }
function clientToScenePoint(clientX, clientY) {
const rect = viewport.getBoundingClientRect();
return {
x: (clientX - rect.left - state.offsetX) / state.zoom,
y: (clientY - rect.top - state.offsetY) / state.zoom
};
}
function findInspectableAtPoint(point) {
const items = state.current?.inspect?.items ?? [];
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (
point.x >= item.screen.left &&
point.x < item.screen.right &&
point.y >= item.screen.top &&
point.y < item.screen.bottom
) {
return item;
}
}
return null;
}
function updateInspectHover(event) {
if (!inspectShapesCheckbox.checked || !state.current) {
return;
}
const target = findInspectableAtPoint(clientToScenePoint(event.clientX, event.clientY));
if (!target) {
state.inspectTargetId = null;
hideInspectHighlight();
hideOverlayTooltip();
return;
}
if (state.inspectTargetId !== target.id) {
state.inspectTargetId = target.id;
showInspectHighlight(target);
showShapeTooltip(target, event);
return;
}
showInspectHighlight(target);
positionOverlayTooltip(event.clientX, event.clientY);
}
function createTileElement(tileX, tileY, buildContext, metadata) { function createTileElement(tileX, tileY, buildContext, metadata) {
const tileSize = metadata.tileSize; const tileSize = metadata.tileSize;
const tile = document.createElement("img"); const tile = document.createElement("img");
@ -550,15 +641,16 @@ async function pollBuild(jobId, selected, token, preserveView) {
return; return;
} }
const [metadata, overlays] = await Promise.all([ const [metadata, overlays, inspect] = await Promise.all([
fetchJson(`/api/maps/${selected.game}/${selected.mapId}/metadata?buildId=${encodeURIComponent(jobId)}`), fetchJson(`/api/maps/${selected.game}/${selected.mapId}/metadata?buildId=${encodeURIComponent(jobId)}`),
fetchJson(`/api/maps/${selected.game}/${selected.mapId}/overlays?buildId=${encodeURIComponent(jobId)}`) fetchJson(`/api/maps/${selected.game}/${selected.mapId}/overlays?buildId=${encodeURIComponent(jobId)}`),
fetchJson(`/api/maps/${selected.game}/${selected.mapId}/inspect?buildId=${encodeURIComponent(jobId)}`)
]); ]);
if (token !== state.buildToken) { if (token !== state.buildToken) {
return; return;
} }
const nextContext = { selected, jobId, metadata, overlays }; const nextContext = { selected, jobId, metadata, overlays, inspect };
const nextLayerBuild = await buildLayer(nextContext); const nextLayerBuild = await buildLayer(nextContext);
if (token !== state.buildToken) { if (token !== state.buildToken) {
return; return;
@ -640,6 +732,12 @@ mapForm.addEventListener("submit", async (event) => {
mapSelect.addEventListener("change", scheduleAutoBuild); mapSelect.addEventListener("change", scheduleAutoBuild);
includeEditorCheckbox.addEventListener("change", scheduleAutoBuild); includeEditorCheckbox.addEventListener("change", scheduleAutoBuild);
includeRoofsCheckbox.addEventListener("change", scheduleAutoBuild); includeRoofsCheckbox.addEventListener("change", scheduleAutoBuild);
inspectShapesCheckbox.addEventListener("change", () => {
setInspectMode(inspectShapesCheckbox.checked);
if (!inspectShapesCheckbox.checked) {
hideOverlayTooltip();
}
});
downloadButton.addEventListener("click", (event) => { downloadButton.addEventListener("click", (event) => {
if (downloadButton.classList.contains("is-disabled")) { if (downloadButton.classList.contains("is-disabled")) {
@ -684,11 +782,24 @@ viewport.addEventListener(
{ passive: false } { passive: false }
); );
viewport.addEventListener("pointerdown", (event) => { viewport.addEventListener("pointermove", (event) => {
if (!state.current) { if (state.drag || state.pointers.size > 0) {
return; return;
} }
if (event.target.closest(".overlay-item")) { updateInspectHover(event);
});
viewport.addEventListener("pointerleave", () => {
if (!inspectShapesCheckbox.checked) {
return;
}
state.inspectTargetId = null;
hideInspectHighlight();
hideOverlayTooltip();
});
viewport.addEventListener("pointerdown", (event) => {
if (!state.current) {
return; return;
} }
event.preventDefault(); event.preventDefault();
@ -773,6 +884,8 @@ setDownloadState(false);
setLoadingState(false); setLoadingState(false);
setEmptyStateVisible(true); setEmptyStateVisible(true);
hideOverlayTooltip(); hideOverlayTooltip();
setInspectMode(false);
hideInspectHighlight();
loadCatalog().catch((error) => { loadCatalog().catch((error) => {
setStatus(error instanceof Error ? error.message : String(error)); setStatus(error instanceof Error ? error.message : String(error));
}); });

View file

@ -20,6 +20,7 @@
<div class="toggle-grid"> <div class="toggle-grid">
<label class="toggle"><input id="include-editor" type="checkbox" checked> Show editor-only elements</label> <label class="toggle"><input id="include-editor" type="checkbox" checked> Show editor-only elements</label>
<label class="toggle"><input id="include-roofs" type="checkbox"> Show roofs</label> <label class="toggle"><input id="include-roofs" type="checkbox"> Show roofs</label>
<label class="toggle"><input id="inspect-shapes" type="checkbox"> Inspect shapes under cursor</label>
</div> </div>
</form> </form>
@ -63,6 +64,7 @@
<div id="viewport-hint" class="viewport-hint">Drag to pan. Scroll or pinch to zoom.</div> <div id="viewport-hint" class="viewport-hint">Drag to pan. Scroll or pinch to zoom.</div>
<div id="scene" class="scene"> <div id="scene" class="scene">
<div id="active-layer" class="layer"></div> <div id="active-layer" class="layer"></div>
<div id="inspect-highlight" class="inspect-highlight" hidden></div>
</div> </div>
<div id="overlay-tooltip" class="overlay-tooltip" hidden></div> <div id="overlay-tooltip" class="overlay-tooltip" hidden></div>
<div id="empty-state" class="empty-state">Choose a detected map to build and view it.</div> <div id="empty-state" class="empty-state">Choose a detected map to build and view it.</div>

View file

@ -72,6 +72,17 @@ app.get("/api/maps/:game/:mapId/overlays", (request, response) => {
} }
}); });
app.get("/api/maps/:game/:mapId/inspect", (request, response) => {
try {
const buildId = String(request.query.buildId ?? "");
const mapId = Number.parseInt(request.params.mapId, 10);
const inspect = builds.getInspectData(buildId, request.params.game, mapId);
response.json(inspect);
} catch (error) {
response.status(400).json({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get("/api/maps/:game/:mapId/overlays/:overlayId.webp", async (request, response) => { app.get("/api/maps/:game/:mapId/overlays/:overlayId.webp", async (request, response) => {
try { try {
const buildId = String(request.query.buildId ?? ""); const buildId = String(request.query.buildId ?? "");