diff --git a/map_renderer/README.md b/map_renderer/README.md index d87773c..4d81676 100644 --- a/map_renderer/README.md +++ b/map_renderer/README.md @@ -26,6 +26,7 @@ Viewer behavior: - use the scroll wheel to zoom directly at the pointer - pinch to zoom on touch devices - toggle roofs and editor-only elements independently before building +- when editor-only elements are enabled, the base map stays server-rasterized while editor records render as interactive overlays with hover metadata The app expects asset folders under the app root: @@ -62,6 +63,7 @@ docker compose up --build - `POST /api/builds` starts or reuses a build. - `GET /api/builds/:id` returns build status. - `GET /api/maps/:game/:mapId/metadata?buildId=...` returns map bounds and tile settings. +- `GET /api/maps/:game/:mapId/overlays?buildId=...` returns interactive overlay records for editor-only content. - `GET /api/maps/:game/:mapId/tiles/:tileX/:tileY.png?buildId=...` returns rendered PNG tiles. No raw Crusader asset files are exposed over HTTP. diff --git a/map_renderer/src/lib/build-manager.js b/map_renderer/src/lib/build-manager.js index de7794b..207e6f7 100644 --- a/map_renderer/src/lib/build-manager.js +++ b/map_renderer/src/lib/build-manager.js @@ -5,6 +5,8 @@ import sharp from "sharp"; import { TILE_CACHE_ROOT, TILE_SIZE } from "../config.js"; import { + EGG_FAMILIES, + FLAG_INVISIBLE, FLAG_FLIPPED, ShapeArchive, collectRenderItems, @@ -16,7 +18,7 @@ import { summarizeRenderClasses } from "./formats.js"; import { blitFrame, encodePng, rgbaBuffer } from "./png.js"; -import { prepareSortedItems } from "./sorting.js"; +import { prepareSortedItems, projectItemGeometry } from "./sorting.js"; function nowIso() { return new Date().toISOString(); @@ -40,6 +42,231 @@ function buildOptionSuffix(options) { return `editor-${options.includeEditor ? "on" : "off"}_roofs-${options.includeRoofs ? "on" : "off"}`; } +function toHex(value, width = 4) { + return `0x${value.toString(16).padStart(width, "0")}`; +} + +function createEmptyProjection() { + return { + projected: [], + invalidItemCount: 0, + invalidItems: [] + }; +} + +function computeBoundsFromNodes(nodes) { + if (!nodes.length) { + return null; + } + + let minLeft = Number.MAX_SAFE_INTEGER; + let minTop = Number.MAX_SAFE_INTEGER; + let maxRight = -Number.MAX_SAFE_INTEGER; + let maxBottom = -Number.MAX_SAFE_INTEGER; + + for (const node of nodes) { + minLeft = Math.min(minLeft, node.left); + minTop = Math.min(minTop, node.top); + maxRight = Math.max(maxRight, node.right); + maxBottom = Math.max(maxBottom, node.bottom); + } + + return { + minLeft, + minTop, + maxRight, + maxBottom, + width: maxRight - minLeft, + height: maxBottom - minTop + }; +} + +function mergeBounds(boundsList) { + const validBounds = boundsList.filter(Boolean); + if (!validBounds.length) { + return null; + } + + const minLeft = Math.min(...validBounds.map((bounds) => bounds.minLeft)); + const minTop = Math.min(...validBounds.map((bounds) => bounds.minTop)); + const maxRight = Math.max(...validBounds.map((bounds) => bounds.maxRight)); + const maxBottom = Math.max(...validBounds.map((bounds) => bounds.maxBottom)); + + return { + minLeft, + minTop, + maxRight, + maxBottom, + width: maxRight - minLeft, + height: maxBottom - minTop + }; +} + +function isOverlayItem(item, shapeInfos) { + return item.shape < shapeInfos.length && shapeInfos[item.shape].isEditor; +} + +function splitRenderItems(renderItems, shapeInfos, includeEditor) { + if (!includeEditor) { + return { + baseItems: renderItems, + overlayItems: [] + }; + } + + const baseItems = []; + const overlayItems = []; + for (const item of renderItems) { + if (isOverlayItem(item, shapeInfos)) { + overlayItems.push(item); + } else { + baseItems.push(item); + } + } + + return { + baseItems, + overlayItems + }; +} + +function classifyOverlayKind(item, info) { + if ((item.flags & FLAG_INVISIBLE) || info.isOccl || info.isInvitem) { + return "helper"; + } + if (EGG_FAMILIES.has(info.family)) { + return "egg"; + } + if (info.isRoof) { + return "roof"; + } + return "editor"; +} + +function overlayLabel(kind) { + if (kind === "helper") { + return "Helper Geometry"; + } + if (kind === "egg") { + return "Egg Trigger"; + } + if (kind === "roof") { + return "Roof Marker"; + } + return "Editor Object"; +} + +function overlayNotes(item, info) { + const notes = []; + if (item.flags & FLAG_INVISIBLE) { + notes.push("invisible-flagged"); + } + if (info.isOccl) { + notes.push("occluding-geometry"); + } + if (info.isInvitem) { + notes.push("invitem-family"); + } + if (EGG_FAMILIES.has(info.family)) { + notes.push("egg-family"); + } + if (info.isRoof) { + notes.push("roof-flagged"); + } + if (info.isTranslucent) { + notes.push("translucent"); + } + return notes; +} + +function serializeOverlayItem(node, minLeft, minTop, index) { + const { item, info, frame } = node; + const kind = classifyOverlayKind(item, info); + const sceneLeft = node.left - minLeft; + const sceneTop = node.top - minTop; + const screenWidth = node.right - node.left; + const screenHeight = node.bottom - node.top; + + return { + id: `${index}:${item.source}:${item.shape}:${item.frame}:${item.x}:${item.y}:${item.z}`, + kind, + label: overlayLabel(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: screenWidth, + height: screenHeight, + anchorX: Math.trunc(sceneLeft + screenWidth / 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 summarizeOverlayItems(items) { + const kindCounts = {}; + const familyCounts = {}; + const sourceCounts = {}; + + for (const item of items) { + kindCounts[item.kind] = (kindCounts[item.kind] ?? 0) + 1; + familyCounts[item.family] = (familyCounts[item.family] ?? 0) + 1; + sourceCounts[item.source] = (sourceCounts[item.source] ?? 0) + 1; + } + + const topFamilies = Object.entries(familyCounts) + .sort((left, right) => right[1] - left[1] || Number(left[0]) - Number(right[0])) + .slice(0, 6) + .map(([family, count]) => ({ family: Number(family), count })); + + return { + itemCount: items.length, + kindCounts, + sourceCounts, + topFamilies, + helperCount: kindCounts.helper ?? 0 + }; +} + function makeUsageInfo(gameId, mapId, baseItems, renderItems) { const itemMapNums = [...new Set(baseItems.map((item) => item.mapNum))].sort((left, right) => left - right); return { @@ -63,10 +290,13 @@ function buildEmptyMetadata(gameConfig, mapId, baseItems, reason) { map: mapId, rawItemCount: baseItems.length, itemCount: 0, + baseRasterItemCount: 0, + overlayItemCount: 0, paintedItemCount: 0, occludedItemCount: 0, invalidItemCount: 0, invalidItems: [], + overlaySummary: summarizeOverlayItems([]), usage: makeUsageInfo(gameConfig.id, mapId, baseItems, []), baseItemSummary: { roofItems: 0, @@ -179,6 +409,7 @@ export class BuildManager { job.build = { assets, prepared: [], + overlays: [], minLeft: 0, minTop: 0, width: TILE_SIZE, @@ -195,16 +426,42 @@ export class BuildManager { return; } + const splitItems = splitRenderItems(renderItems, assets.shapeInfos, job.options.includeEditor); + this.touchJob( + job, + `Split ${splitItems.baseItems.length} base items and ${splitItems.overlayItems.length} overlay items` + ); + job.phase = "sorting"; - const sorted = prepareSortedItems(renderItems, assets.shapeArchive, assets.shapeInfos, { - checkpointEvery: 2000, - maxInvalidDetails: 20, - progress: (message) => this.touchJob(job, message) - }); - if (!sorted.prepared.length) { + const sorted = splitItems.baseItems.length + ? prepareSortedItems(splitItems.baseItems, assets.shapeArchive, assets.shapeInfos, { + checkpointEvery: 2000, + maxInvalidDetails: 20, + progress: (message) => this.touchJob(job, message) + }) + : { + minLeft: 0, + minTop: 0, + maxRight: TILE_SIZE, + maxBottom: TILE_SIZE, + prepared: [], + occludedCount: 0, + invalidItemCount: 0, + invalidItems: [] + }; + const overlayProjection = splitItems.overlayItems.length + ? projectItemGeometry(splitItems.overlayItems, assets.shapeArchive, assets.shapeInfos, { + checkpointEvery: 2000, + maxInvalidDetails: 20, + progress: (message) => this.touchJob(job, message) + }) + : createEmptyProjection(); + + if (!sorted.prepared.length && !overlayProjection.projected.length) { job.build = { assets, prepared: [], + overlays: [], minLeft: 0, minTop: 0, width: TILE_SIZE, @@ -226,22 +483,42 @@ export class BuildManager { return; } - const width = sorted.maxRight - sorted.minLeft; - const height = sorted.maxBottom - sorted.minTop; - if (width <= 0 || height <= 0) { + const bounds = mergeBounds([ + sorted.prepared.length + ? { + minLeft: sorted.minLeft, + minTop: sorted.minTop, + maxRight: sorted.maxRight, + maxBottom: sorted.maxBottom, + width: sorted.maxRight - sorted.minLeft, + height: sorted.maxBottom - sorted.minTop + } + : null, + computeBoundsFromNodes(overlayProjection.projected) + ]); + if (!bounds || bounds.width <= 0 || bounds.height <= 0) { throw new Error("Computed image bounds are invalid"); } + const overlays = overlayProjection.projected.map((node, index) => + serializeOverlayItem(node, bounds.minLeft, bounds.minTop, index) + ); + const invalidItemCount = sorted.invalidItemCount + overlayProjection.invalidItemCount; + const invalidItems = [...sorted.invalidItems, ...overlayProjection.invalidItems].slice(0, 20); + const metadata = { game: gameConfig.id, gameLabel: gameConfig.label, map: job.mapId, rawItemCount: baseItems.length, itemCount: renderItems.length, + baseRasterItemCount: splitItems.baseItems.length, + overlayItemCount: overlays.length, paintedItemCount: sorted.prepared.length, occludedItemCount: sorted.occludedCount, - invalidItemCount: sorted.invalidItemCount, - invalidItems: sorted.invalidItems, + invalidItemCount, + invalidItems, + overlaySummary: summarizeOverlayItems(overlays), usage: makeUsageInfo(gameConfig.id, job.mapId, baseItems, renderItems), baseItemSummary: summarizeRenderClasses(baseItems, assets.shapeInfos), sorter: "scummvm_dependency_graph", @@ -252,16 +529,16 @@ export class BuildManager { includeRoofs: job.options.includeRoofs }, bounds: { - screenLeft: sorted.minLeft, - screenTop: sorted.minTop, - screenRight: sorted.maxRight, - screenBottom: sorted.maxBottom, - width, - height + screenLeft: bounds.minLeft, + screenTop: bounds.minTop, + screenRight: bounds.maxRight, + screenBottom: bounds.maxBottom, + width: bounds.width, + height: bounds.height }, tileSize: TILE_SIZE, - tileCountX: Math.ceil(width / TILE_SIZE), - tileCountY: Math.ceil(height / TILE_SIZE), + tileCountX: Math.ceil(bounds.width / TILE_SIZE), + tileCountY: Math.ceil(bounds.height / TILE_SIZE), zoom: { min: 0.01, max: 8, @@ -273,10 +550,11 @@ export class BuildManager { job.build = { assets, prepared: sorted.prepared, - minLeft: sorted.minLeft, - minTop: sorted.minTop, - width, - height + overlays, + minLeft: bounds.minLeft, + minTop: bounds.minTop, + width: bounds.width, + height: bounds.height }; job.metadata = metadata; job.status = "ready"; @@ -337,6 +615,14 @@ export class BuildManager { return job.metadata; } + getOverlayData(jobId, gameId, mapId) { + const job = this.requireReadyJob(jobId, gameId, mapId); + return { + items: job.build.overlays, + summary: job.metadata.overlaySummary + }; + } + async renderFullMap(jobId, gameId, mapId) { const job = this.requireReadyJob(jobId, gameId, mapId); const outputPath = path.join( diff --git a/map_renderer/src/lib/sorting.js b/map_renderer/src/lib/sorting.js index f8d5f17..689a0a6 100644 --- a/map_renderer/src/lib/sorting.js +++ b/map_renderer/src/lib/sorting.js @@ -294,6 +294,61 @@ function resolvePaintOrder(ordered, progress, checkpointEvery = 0) { return painted; } +export function projectItemGeometry(items, archive, shapeInfos, options = {}) { + const { progress, checkpointEvery = 0, maxInvalidDetails = 20 } = options; + const projected = []; + let invalidItemCount = 0; + const invalidItems = []; + + for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) { + const item = items[itemIndex]; + try { + const frame = archive.getFrame(item.shape, item.frame); + projected.push(buildSortNode(item, shapeInfos[item.shape], frame, null)); + } catch (error) { + invalidItemCount += 1; + if (invalidItems.length < maxInvalidDetails) { + invalidItems.push({ + shape: item.shape, + frame: item.frame, + x: item.x, + y: item.y, + z: item.z, + source: item.source, + reason: error instanceof Error ? error.message : String(error) + }); + } + } + + if (progress && checkpointEvery > 0 && (itemIndex + 1) % checkpointEvery === 0) { + progress(`project processed=${itemIndex + 1} valid=${projected.length} invalid=${invalidItemCount}`); + } + } + + projected.sort((left, right) => { + if (left.sy_bot !== right.sy_bot) { + return left.sy_bot - right.sy_bot; + } + if (left.sx_bot !== right.sx_bot) { + return left.sx_bot - right.sx_bot; + } + if (left.item.shape !== right.item.shape) { + return left.item.shape - right.item.shape; + } + return left.item.frame - right.item.frame; + }); + + if (progress) { + progress(`project complete processed=${items.length} valid=${projected.length} invalid=${invalidItemCount}`); + } + + return { + projected, + invalidItemCount, + invalidItems + }; +} + export function prepareSortedItems(items, archive, shapeInfos, options = {}) { const { progress, checkpointEvery = 0, maxInvalidDetails = 20 } = options; const ordered = []; diff --git a/map_renderer/src/public/app.css b/map_renderer/src/public/app.css index 46514d3..47a5680 100644 --- a/map_renderer/src/public/app.css +++ b/map_renderer/src/public/app.css @@ -8,6 +8,11 @@ --muted: #6e5a37; --accent: #0d6c7d; --accent-strong: #114f59; + --overlay-editor: #18849a; + --overlay-egg: #c67129; + --overlay-roof: #627894; + --overlay-helper-fill: rgba(77, 169, 196, 0.16); + --overlay-helper-stroke: rgba(108, 201, 228, 0.72); --viewport: #0e1218; --tile-border: rgba(255, 255, 255, 0.04); --shadow: 0 18px 45px rgba(59, 40, 8, 0.16); @@ -268,6 +273,141 @@ select:disabled, pointer-events: none; } +.overlay-root { + position: absolute; + inset: 0; + pointer-events: none; +} + +.overlay-item { + position: absolute; + pointer-events: auto; + border: 0; + padding: 0; + color: inherit; + font: inherit; + background: none; + cursor: pointer; +} + +.overlay-marker { + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 999px; + color: white; + font-size: 0.68rem; + font-weight: 800; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28); + transform: translate(-50%, -100%); + transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease; +} + +.overlay-marker:hover, +.overlay-marker:focus-visible { + transform: translate(-50%, -100%) scale(1.08); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.34); + filter: saturate(1.12); +} + +.overlay-marker--editor { + background: linear-gradient(180deg, color-mix(in srgb, var(--overlay-editor) 86%, white 14%) 0%, var(--overlay-editor) 100%); +} + +.overlay-marker--egg { + background: linear-gradient(180deg, color-mix(in srgb, var(--overlay-egg) 82%, white 18%) 0%, var(--overlay-egg) 100%); +} + +.overlay-marker--roof { + background: linear-gradient(180deg, color-mix(in srgb, var(--overlay-roof) 84%, white 16%) 0%, var(--overlay-roof) 100%); +} + +.overlay-helper { + display: flex; + align-items: flex-start; + justify-content: flex-start; + min-width: 14px; + min-height: 14px; + padding: 4px; + border-radius: 8px; + border: 1px solid var(--overlay-helper-stroke); + background: var(--overlay-helper-fill); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06); + transition: transform 140ms ease, background 140ms ease; +} + +.overlay-helper:hover, +.overlay-helper:focus-visible { + transform: scale(1.02); + background: color-mix(in srgb, var(--overlay-helper-fill) 70%, white 30%); +} + +.overlay-helper-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + padding: 2px 6px; + border-radius: 999px; + background: rgba(7, 12, 18, 0.68); + color: rgba(255, 255, 255, 0.86); + font-size: 0.68rem; + font-weight: 800; +} + +.overlay-tooltip { + position: absolute; + z-index: 6; + max-width: 290px; + padding: 12px 14px; + border-radius: 14px; + background: rgba(8, 12, 18, 0.9); + color: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(124, 182, 214, 0.28); + box-shadow: 0 18px 34px rgba(0, 0, 0, 0.34); + pointer-events: none; + backdrop-filter: blur(14px); +} + +.tooltip-eyebrow { + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(138, 202, 221, 0.88); +} + +.tooltip-title { + margin-top: 4px; + font-size: 0.98rem; + font-weight: 700; +} + +.tooltip-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 6px 10px; + margin: 10px 0 0; + font-size: 0.84rem; +} + +.tooltip-grid dt { + color: rgba(176, 197, 212, 0.76); +} + +.tooltip-grid dd { + margin: 0; + text-align: right; +} + +.tooltip-notes { + margin: 10px 0 0; + padding-left: 18px; + color: rgba(214, 227, 237, 0.82); + font-size: 0.8rem; +} + @keyframes spin { from { transform: rotate(0deg); diff --git a/map_renderer/src/public/app.js b/map_renderer/src/public/app.js index 4af8465..51f9b0d 100644 --- a/map_renderer/src/public/app.js +++ b/map_renderer/src/public/app.js @@ -10,6 +10,7 @@ const statusBox = document.querySelector("#status"); const metaBox = document.querySelector("#meta"); const viewport = document.querySelector("#viewport"); const scene = document.querySelector("#scene"); +const overlayTooltip = document.querySelector("#overlay-tooltip"); const emptyState = document.querySelector("#empty-state"); const zoomLabel = document.querySelector("#zoom-label"); const zoomInButton = document.querySelector("#zoom-in"); @@ -62,6 +63,14 @@ function setStatus(message) { statusBox.textContent = message; } +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + function phaseProgress(build) { const phaseToValue = { queued: 5, @@ -91,6 +100,14 @@ function setMeta(metadata) { return; } + const overlayKinds = Object.entries(metadata.overlaySummary?.kindCounts ?? {}) + .sort((left, right) => left[0].localeCompare(right[0])) + .map(([kind, count]) => `${kind}: ${count}`) + .join(", "); + const overlayFamilies = (metadata.overlaySummary?.topFamilies ?? []) + .map((entry) => `family ${entry.family} (${entry.count})`) + .join(", "); + metaBox.innerHTML = `

Overview

@@ -105,12 +122,22 @@ function setMeta(metadata) {

Render

Raw items
${metadata.rawItemCount}
-
Render items
${metadata.itemCount}
-
Painted items
${metadata.paintedItemCount}
+
Total renderables
${metadata.itemCount}
+
Base raster
${metadata.baseRasterItemCount}
+
Overlay items
${metadata.overlayItemCount}
+
Painted base
${metadata.paintedItemCount}
Occluded
${metadata.occludedItemCount}
Invalid
${metadata.invalidItemCount}
+
+

Overlay

+
+
Helper geometry
${metadata.overlaySummary?.helperCount ?? 0}
+
Kinds
${overlayKinds || "None"}
+
Top families
${overlayFamilies || "None"}
+
+

Filters

@@ -222,6 +249,99 @@ function tileUrl(buildContext, tileX, tileY) { return `/api/maps/${selected.game}/${selected.mapId}/tiles/${tileX}/${tileY}.webp?buildId=${encodeURIComponent(jobId)}`; } +function positionOverlayTooltip(clientX, clientY) { + const rect = viewport.getBoundingClientRect(); + const tooltipWidth = overlayTooltip.offsetWidth; + const tooltipHeight = overlayTooltip.offsetHeight; + const padding = 18; + let left = clientX - rect.left + 16; + let top = clientY - rect.top + 16; + + if (left + tooltipWidth + padding > rect.width) { + left = Math.max(padding, rect.width - tooltipWidth - padding); + } + if (top + tooltipHeight + padding > rect.height) { + top = Math.max(padding, rect.height - tooltipHeight - padding); + } + + overlayTooltip.style.left = `${left}px`; + overlayTooltip.style.top = `${top}px`; +} + +function renderOverlayTooltip(overlay) { + const notes = overlay.notes.length ? overlay.notes.map((note) => `
  • ${escapeHtml(note)}
  • `).join("") : ""; + return ` +
    ${escapeHtml(overlay.label)}
    +
    Shape ${overlay.shape} frame ${overlay.frame}
    +
    +
    Family
    ${overlay.family}
    +
    World
    ${overlay.world.x}, ${overlay.world.y}, ${overlay.world.z}
    +
    Source
    ${escapeHtml(overlay.source)}
    +
    Flags
    ${escapeHtml(overlay.flags.hex)}
    +
    NPC
    ${overlay.npcNum || "-"}
    +
    Map
    ${overlay.mapNum || "-"}
    +
    Quality
    ${overlay.quality || "-"}
    +
    + ${notes ? `` : ""} + `; +} + +function hideOverlayTooltip() { + overlayTooltip.hidden = true; + overlayTooltip.innerHTML = ""; +} + +function showOverlayTooltip(overlay, anchor) { + overlayTooltip.innerHTML = renderOverlayTooltip(overlay); + overlayTooltip.hidden = false; + + if (anchor instanceof Event) { + positionOverlayTooltip(anchor.clientX, anchor.clientY); + return; + } + + const rect = anchor.getBoundingClientRect(); + positionOverlayTooltip(rect.right, rect.top + rect.height / 2); +} + +function createOverlayElement(overlay) { + const item = document.createElement("button"); + item.type = "button"; + item.className = `overlay-item ${overlay.kind === "helper" ? "overlay-helper" : `overlay-marker overlay-marker--${overlay.kind}`}`; + item.setAttribute("aria-label", `${overlay.label}, family ${overlay.family}, shape ${overlay.shape}, frame ${overlay.frame}`); + + if (overlay.kind === "helper") { + item.style.left = `${overlay.screen.left}px`; + item.style.top = `${overlay.screen.top}px`; + item.style.width = `${Math.max(14, overlay.screen.width)}px`; + item.style.height = `${Math.max(14, overlay.screen.height)}px`; + const badge = document.createElement("span"); + badge.className = "overlay-helper-badge"; + badge.textContent = `F${overlay.family}`; + item.append(badge); + } else { + item.style.left = `${overlay.screen.anchorX}px`; + item.style.top = `${overlay.screen.anchorY}px`; + item.textContent = String(overlay.family); + } + + item.addEventListener("pointerenter", (event) => { + showOverlayTooltip(overlay, event); + }); + item.addEventListener("pointermove", (event) => { + if (!overlayTooltip.hidden) { + positionOverlayTooltip(event.clientX, event.clientY); + } + }); + item.addEventListener("pointerleave", hideOverlayTooltip); + item.addEventListener("focus", () => { + showOverlayTooltip(overlay, item); + }); + item.addEventListener("blur", hideOverlayTooltip); + + return item; +} + function createTileElement(tileX, tileY, buildContext, metadata) { const tileSize = metadata.tileSize; const tile = document.createElement("img"); @@ -258,6 +378,7 @@ function waitForImage(tile) { async function buildLayer(buildContext) { const layer = document.createElement("div"); layer.className = "layer"; + layer.id = "active-layer"; const { metadata } = buildContext; layer.style.width = `${metadata.bounds.width}px`; layer.style.height = `${metadata.bounds.height}px`; @@ -271,6 +392,13 @@ async function buildLayer(buildContext) { } } + const overlayRoot = document.createElement("div"); + overlayRoot.className = "overlay-root"; + for (const overlay of buildContext.overlays.items) { + overlayRoot.append(createOverlayElement(overlay)); + } + layer.append(overlayRoot); + return { layer, ready: Promise.all(tilePromises) @@ -352,6 +480,7 @@ async function startBuild(selected) { const token = ++state.buildToken; const preserveView = currentSelectionMatches(selected); + hideOverlayTooltip(); setEmptyStateVisible(false); if (!state.current) { @@ -407,14 +536,15 @@ async function pollBuild(jobId, selected, token, preserveView) { return; } - const metadata = await fetchJson( - `/api/maps/${selected.game}/${selected.mapId}/metadata?buildId=${encodeURIComponent(jobId)}` - ); + const [metadata, overlays] = await Promise.all([ + fetchJson(`/api/maps/${selected.game}/${selected.mapId}/metadata?buildId=${encodeURIComponent(jobId)}`), + fetchJson(`/api/maps/${selected.game}/${selected.mapId}/overlays?buildId=${encodeURIComponent(jobId)}`) + ]); if (token !== state.buildToken) { return; } - const nextContext = { selected, jobId, metadata }; + const nextContext = { selected, jobId, metadata, overlays }; const nextLayerBuild = await buildLayer(nextContext); if (token !== state.buildToken) { return; @@ -544,6 +674,9 @@ viewport.addEventListener("pointerdown", (event) => { if (!state.current) { return; } + if (event.target.closest(".overlay-item")) { + return; + } event.preventDefault(); viewport.setPointerCapture(event.pointerId); state.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); @@ -625,6 +758,7 @@ setMeta(null); setDownloadState(false); setLoadingState(false); setEmptyStateVisible(true); +hideOverlayTooltip(); loadCatalog().catch((error) => { setStatus(error instanceof Error ? error.message : String(error)); }); diff --git a/map_renderer/src/public/index.html b/map_renderer/src/public/index.html index 70a2da9..b69a172 100644 --- a/map_renderer/src/public/index.html +++ b/map_renderer/src/public/index.html @@ -64,6 +64,7 @@
    +
    Choose a detected map to build and view it.
    diff --git a/map_renderer/src/server.js b/map_renderer/src/server.js index 2395724..a8dee78 100644 --- a/map_renderer/src/server.js +++ b/map_renderer/src/server.js @@ -61,6 +61,17 @@ app.get("/api/maps/:game/:mapId/metadata", (request, response) => { } }); +app.get("/api/maps/:game/:mapId/overlays", (request, response) => { + try { + const buildId = String(request.query.buildId ?? ""); + const mapId = Number.parseInt(request.params.mapId, 10); + const overlays = builds.getOverlayData(buildId, request.params.game, mapId); + response.json(overlays); + } catch (error) { + response.status(400).json({ error: error instanceof Error ? error.message : String(error) }); + } +}); + app.get("/api/maps/:game/:mapId/tiles/:tileX/:tileY.png", async (request, response) => { try { const buildId = String(request.query.buildId ?? "");