Implement inspect mode for shape metadata and enhance overlay interactions
This commit is contained in:
parent
06e67d8341
commit
ab5e514e61
6 changed files with 325 additions and 38 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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("&", "&")
|
.replaceAll("&", "&")
|
||||||
|
|
@ -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));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 ?? "");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue