Add overlay support with interactive tooltips and metadata display
This commit is contained in:
parent
549ff38334
commit
d8940a1b1d
7 changed files with 659 additions and 30 deletions
|
|
@ -26,6 +26,7 @@ Viewer behavior:
|
||||||
- use the scroll wheel to zoom directly at the pointer
|
- use the scroll wheel to zoom directly at the pointer
|
||||||
- pinch to zoom on touch devices
|
- pinch to zoom on touch devices
|
||||||
- toggle roofs and editor-only elements independently before building
|
- 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:
|
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.
|
- `POST /api/builds` starts or reuses a build.
|
||||||
- `GET /api/builds/:id` returns build status.
|
- `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/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.
|
- `GET /api/maps/:game/:mapId/tiles/:tileX/:tileY.png?buildId=...` returns rendered PNG tiles.
|
||||||
|
|
||||||
No raw Crusader asset files are exposed over HTTP.
|
No raw Crusader asset files are exposed over HTTP.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import sharp from "sharp";
|
||||||
|
|
||||||
import { TILE_CACHE_ROOT, TILE_SIZE } from "../config.js";
|
import { TILE_CACHE_ROOT, TILE_SIZE } from "../config.js";
|
||||||
import {
|
import {
|
||||||
|
EGG_FAMILIES,
|
||||||
|
FLAG_INVISIBLE,
|
||||||
FLAG_FLIPPED,
|
FLAG_FLIPPED,
|
||||||
ShapeArchive,
|
ShapeArchive,
|
||||||
collectRenderItems,
|
collectRenderItems,
|
||||||
|
|
@ -16,7 +18,7 @@ import {
|
||||||
summarizeRenderClasses
|
summarizeRenderClasses
|
||||||
} from "./formats.js";
|
} from "./formats.js";
|
||||||
import { blitFrame, encodePng, rgbaBuffer } from "./png.js";
|
import { blitFrame, encodePng, rgbaBuffer } from "./png.js";
|
||||||
import { prepareSortedItems } from "./sorting.js";
|
import { prepareSortedItems, projectItemGeometry } from "./sorting.js";
|
||||||
|
|
||||||
function nowIso() {
|
function nowIso() {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
|
|
@ -40,6 +42,231 @@ function buildOptionSuffix(options) {
|
||||||
return `editor-${options.includeEditor ? "on" : "off"}_roofs-${options.includeRoofs ? "on" : "off"}`;
|
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) {
|
function makeUsageInfo(gameId, mapId, baseItems, renderItems) {
|
||||||
const itemMapNums = [...new Set(baseItems.map((item) => item.mapNum))].sort((left, right) => left - right);
|
const itemMapNums = [...new Set(baseItems.map((item) => item.mapNum))].sort((left, right) => left - right);
|
||||||
return {
|
return {
|
||||||
|
|
@ -63,10 +290,13 @@ function buildEmptyMetadata(gameConfig, mapId, baseItems, reason) {
|
||||||
map: mapId,
|
map: mapId,
|
||||||
rawItemCount: baseItems.length,
|
rawItemCount: baseItems.length,
|
||||||
itemCount: 0,
|
itemCount: 0,
|
||||||
|
baseRasterItemCount: 0,
|
||||||
|
overlayItemCount: 0,
|
||||||
paintedItemCount: 0,
|
paintedItemCount: 0,
|
||||||
occludedItemCount: 0,
|
occludedItemCount: 0,
|
||||||
invalidItemCount: 0,
|
invalidItemCount: 0,
|
||||||
invalidItems: [],
|
invalidItems: [],
|
||||||
|
overlaySummary: summarizeOverlayItems([]),
|
||||||
usage: makeUsageInfo(gameConfig.id, mapId, baseItems, []),
|
usage: makeUsageInfo(gameConfig.id, mapId, baseItems, []),
|
||||||
baseItemSummary: {
|
baseItemSummary: {
|
||||||
roofItems: 0,
|
roofItems: 0,
|
||||||
|
|
@ -179,6 +409,7 @@ export class BuildManager {
|
||||||
job.build = {
|
job.build = {
|
||||||
assets,
|
assets,
|
||||||
prepared: [],
|
prepared: [],
|
||||||
|
overlays: [],
|
||||||
minLeft: 0,
|
minLeft: 0,
|
||||||
minTop: 0,
|
minTop: 0,
|
||||||
width: TILE_SIZE,
|
width: TILE_SIZE,
|
||||||
|
|
@ -195,16 +426,42 @@ export class BuildManager {
|
||||||
return;
|
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";
|
job.phase = "sorting";
|
||||||
const sorted = prepareSortedItems(renderItems, assets.shapeArchive, assets.shapeInfos, {
|
const sorted = splitItems.baseItems.length
|
||||||
checkpointEvery: 2000,
|
? prepareSortedItems(splitItems.baseItems, assets.shapeArchive, assets.shapeInfos, {
|
||||||
maxInvalidDetails: 20,
|
checkpointEvery: 2000,
|
||||||
progress: (message) => this.touchJob(job, message)
|
maxInvalidDetails: 20,
|
||||||
});
|
progress: (message) => this.touchJob(job, message)
|
||||||
if (!sorted.prepared.length) {
|
})
|
||||||
|
: {
|
||||||
|
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 = {
|
job.build = {
|
||||||
assets,
|
assets,
|
||||||
prepared: [],
|
prepared: [],
|
||||||
|
overlays: [],
|
||||||
minLeft: 0,
|
minLeft: 0,
|
||||||
minTop: 0,
|
minTop: 0,
|
||||||
width: TILE_SIZE,
|
width: TILE_SIZE,
|
||||||
|
|
@ -226,22 +483,42 @@ export class BuildManager {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const width = sorted.maxRight - sorted.minLeft;
|
const bounds = mergeBounds([
|
||||||
const height = sorted.maxBottom - sorted.minTop;
|
sorted.prepared.length
|
||||||
if (width <= 0 || height <= 0) {
|
? {
|
||||||
|
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");
|
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 = {
|
const metadata = {
|
||||||
game: gameConfig.id,
|
game: gameConfig.id,
|
||||||
gameLabel: gameConfig.label,
|
gameLabel: gameConfig.label,
|
||||||
map: job.mapId,
|
map: job.mapId,
|
||||||
rawItemCount: baseItems.length,
|
rawItemCount: baseItems.length,
|
||||||
itemCount: renderItems.length,
|
itemCount: renderItems.length,
|
||||||
|
baseRasterItemCount: splitItems.baseItems.length,
|
||||||
|
overlayItemCount: overlays.length,
|
||||||
paintedItemCount: sorted.prepared.length,
|
paintedItemCount: sorted.prepared.length,
|
||||||
occludedItemCount: sorted.occludedCount,
|
occludedItemCount: sorted.occludedCount,
|
||||||
invalidItemCount: sorted.invalidItemCount,
|
invalidItemCount,
|
||||||
invalidItems: sorted.invalidItems,
|
invalidItems,
|
||||||
|
overlaySummary: summarizeOverlayItems(overlays),
|
||||||
usage: makeUsageInfo(gameConfig.id, job.mapId, baseItems, renderItems),
|
usage: makeUsageInfo(gameConfig.id, job.mapId, baseItems, renderItems),
|
||||||
baseItemSummary: summarizeRenderClasses(baseItems, assets.shapeInfos),
|
baseItemSummary: summarizeRenderClasses(baseItems, assets.shapeInfos),
|
||||||
sorter: "scummvm_dependency_graph",
|
sorter: "scummvm_dependency_graph",
|
||||||
|
|
@ -252,16 +529,16 @@ export class BuildManager {
|
||||||
includeRoofs: job.options.includeRoofs
|
includeRoofs: job.options.includeRoofs
|
||||||
},
|
},
|
||||||
bounds: {
|
bounds: {
|
||||||
screenLeft: sorted.minLeft,
|
screenLeft: bounds.minLeft,
|
||||||
screenTop: sorted.minTop,
|
screenTop: bounds.minTop,
|
||||||
screenRight: sorted.maxRight,
|
screenRight: bounds.maxRight,
|
||||||
screenBottom: sorted.maxBottom,
|
screenBottom: bounds.maxBottom,
|
||||||
width,
|
width: bounds.width,
|
||||||
height
|
height: bounds.height
|
||||||
},
|
},
|
||||||
tileSize: TILE_SIZE,
|
tileSize: TILE_SIZE,
|
||||||
tileCountX: Math.ceil(width / TILE_SIZE),
|
tileCountX: Math.ceil(bounds.width / TILE_SIZE),
|
||||||
tileCountY: Math.ceil(height / TILE_SIZE),
|
tileCountY: Math.ceil(bounds.height / TILE_SIZE),
|
||||||
zoom: {
|
zoom: {
|
||||||
min: 0.01,
|
min: 0.01,
|
||||||
max: 8,
|
max: 8,
|
||||||
|
|
@ -273,10 +550,11 @@ export class BuildManager {
|
||||||
job.build = {
|
job.build = {
|
||||||
assets,
|
assets,
|
||||||
prepared: sorted.prepared,
|
prepared: sorted.prepared,
|
||||||
minLeft: sorted.minLeft,
|
overlays,
|
||||||
minTop: sorted.minTop,
|
minLeft: bounds.minLeft,
|
||||||
width,
|
minTop: bounds.minTop,
|
||||||
height
|
width: bounds.width,
|
||||||
|
height: bounds.height
|
||||||
};
|
};
|
||||||
job.metadata = metadata;
|
job.metadata = metadata;
|
||||||
job.status = "ready";
|
job.status = "ready";
|
||||||
|
|
@ -337,6 +615,14 @@ export class BuildManager {
|
||||||
return job.metadata;
|
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) {
|
async renderFullMap(jobId, gameId, mapId) {
|
||||||
const job = this.requireReadyJob(jobId, gameId, mapId);
|
const job = this.requireReadyJob(jobId, gameId, mapId);
|
||||||
const outputPath = path.join(
|
const outputPath = path.join(
|
||||||
|
|
|
||||||
|
|
@ -294,6 +294,61 @@ function resolvePaintOrder(ordered, progress, checkpointEvery = 0) {
|
||||||
return painted;
|
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 = {}) {
|
export function prepareSortedItems(items, archive, shapeInfos, options = {}) {
|
||||||
const { progress, checkpointEvery = 0, maxInvalidDetails = 20 } = options;
|
const { progress, checkpointEvery = 0, maxInvalidDetails = 20 } = options;
|
||||||
const ordered = [];
|
const ordered = [];
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@
|
||||||
--muted: #6e5a37;
|
--muted: #6e5a37;
|
||||||
--accent: #0d6c7d;
|
--accent: #0d6c7d;
|
||||||
--accent-strong: #114f59;
|
--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;
|
--viewport: #0e1218;
|
||||||
--tile-border: rgba(255, 255, 255, 0.04);
|
--tile-border: rgba(255, 255, 255, 0.04);
|
||||||
--shadow: 0 18px 45px rgba(59, 40, 8, 0.16);
|
--shadow: 0 18px 45px rgba(59, 40, 8, 0.16);
|
||||||
|
|
@ -268,6 +273,141 @@ select:disabled,
|
||||||
pointer-events: none;
|
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 {
|
@keyframes spin {
|
||||||
from {
|
from {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ 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 scene = document.querySelector("#scene");
|
const scene = document.querySelector("#scene");
|
||||||
|
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");
|
||||||
const zoomInButton = document.querySelector("#zoom-in");
|
const zoomInButton = document.querySelector("#zoom-in");
|
||||||
|
|
@ -62,6 +63,14 @@ function setStatus(message) {
|
||||||
statusBox.textContent = message;
|
statusBox.textContent = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """);
|
||||||
|
}
|
||||||
|
|
||||||
function phaseProgress(build) {
|
function phaseProgress(build) {
|
||||||
const phaseToValue = {
|
const phaseToValue = {
|
||||||
queued: 5,
|
queued: 5,
|
||||||
|
|
@ -91,6 +100,14 @@ function setMeta(metadata) {
|
||||||
return;
|
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 = `
|
metaBox.innerHTML = `
|
||||||
<section class="meta-section">
|
<section class="meta-section">
|
||||||
<h2 class="meta-title">Overview</h2>
|
<h2 class="meta-title">Overview</h2>
|
||||||
|
|
@ -105,12 +122,22 @@ function setMeta(metadata) {
|
||||||
<h2 class="meta-title">Render</h2>
|
<h2 class="meta-title">Render</h2>
|
||||||
<dl class="meta-grid">
|
<dl class="meta-grid">
|
||||||
<dt>Raw items</dt><dd>${metadata.rawItemCount}</dd>
|
<dt>Raw items</dt><dd>${metadata.rawItemCount}</dd>
|
||||||
<dt>Render items</dt><dd>${metadata.itemCount}</dd>
|
<dt>Total renderables</dt><dd>${metadata.itemCount}</dd>
|
||||||
<dt>Painted items</dt><dd>${metadata.paintedItemCount}</dd>
|
<dt>Base raster</dt><dd>${metadata.baseRasterItemCount}</dd>
|
||||||
|
<dt>Overlay items</dt><dd>${metadata.overlayItemCount}</dd>
|
||||||
|
<dt>Painted base</dt><dd>${metadata.paintedItemCount}</dd>
|
||||||
<dt>Occluded</dt><dd>${metadata.occludedItemCount}</dd>
|
<dt>Occluded</dt><dd>${metadata.occludedItemCount}</dd>
|
||||||
<dt>Invalid</dt><dd>${metadata.invalidItemCount}</dd>
|
<dt>Invalid</dt><dd>${metadata.invalidItemCount}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="meta-section">
|
||||||
|
<h2 class="meta-title">Overlay</h2>
|
||||||
|
<dl class="meta-grid">
|
||||||
|
<dt>Helper geometry</dt><dd>${metadata.overlaySummary?.helperCount ?? 0}</dd>
|
||||||
|
<dt>Kinds</dt><dd>${overlayKinds || "None"}</dd>
|
||||||
|
<dt>Top families</dt><dd>${overlayFamilies || "None"}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
<section class="meta-section">
|
<section class="meta-section">
|
||||||
<h2 class="meta-title">Filters</h2>
|
<h2 class="meta-title">Filters</h2>
|
||||||
<dl class="meta-grid">
|
<dl class="meta-grid">
|
||||||
|
|
@ -222,6 +249,99 @@ function tileUrl(buildContext, tileX, tileY) {
|
||||||
return `/api/maps/${selected.game}/${selected.mapId}/tiles/${tileX}/${tileY}.webp?buildId=${encodeURIComponent(jobId)}`;
|
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) => `<li>${escapeHtml(note)}</li>`).join("") : "";
|
||||||
|
return `
|
||||||
|
<div class="tooltip-eyebrow">${escapeHtml(overlay.label)}</div>
|
||||||
|
<div class="tooltip-title">Shape ${overlay.shape} frame ${overlay.frame}</div>
|
||||||
|
<dl class="tooltip-grid">
|
||||||
|
<dt>Family</dt><dd>${overlay.family}</dd>
|
||||||
|
<dt>World</dt><dd>${overlay.world.x}, ${overlay.world.y}, ${overlay.world.z}</dd>
|
||||||
|
<dt>Source</dt><dd>${escapeHtml(overlay.source)}</dd>
|
||||||
|
<dt>Flags</dt><dd>${escapeHtml(overlay.flags.hex)}</dd>
|
||||||
|
<dt>NPC</dt><dd>${overlay.npcNum || "-"}</dd>
|
||||||
|
<dt>Map</dt><dd>${overlay.mapNum || "-"}</dd>
|
||||||
|
<dt>Quality</dt><dd>${overlay.quality || "-"}</dd>
|
||||||
|
</dl>
|
||||||
|
${notes ? `<ul class="tooltip-notes">${notes}</ul>` : ""}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
function createTileElement(tileX, tileY, buildContext, metadata) {
|
||||||
const tileSize = metadata.tileSize;
|
const tileSize = metadata.tileSize;
|
||||||
const tile = document.createElement("img");
|
const tile = document.createElement("img");
|
||||||
|
|
@ -258,6 +378,7 @@ function waitForImage(tile) {
|
||||||
async function buildLayer(buildContext) {
|
async function buildLayer(buildContext) {
|
||||||
const layer = document.createElement("div");
|
const layer = document.createElement("div");
|
||||||
layer.className = "layer";
|
layer.className = "layer";
|
||||||
|
layer.id = "active-layer";
|
||||||
const { metadata } = buildContext;
|
const { metadata } = buildContext;
|
||||||
layer.style.width = `${metadata.bounds.width}px`;
|
layer.style.width = `${metadata.bounds.width}px`;
|
||||||
layer.style.height = `${metadata.bounds.height}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 {
|
return {
|
||||||
layer,
|
layer,
|
||||||
ready: Promise.all(tilePromises)
|
ready: Promise.all(tilePromises)
|
||||||
|
|
@ -352,6 +480,7 @@ async function startBuild(selected) {
|
||||||
const token = ++state.buildToken;
|
const token = ++state.buildToken;
|
||||||
const preserveView = currentSelectionMatches(selected);
|
const preserveView = currentSelectionMatches(selected);
|
||||||
|
|
||||||
|
hideOverlayTooltip();
|
||||||
setEmptyStateVisible(false);
|
setEmptyStateVisible(false);
|
||||||
|
|
||||||
if (!state.current) {
|
if (!state.current) {
|
||||||
|
|
@ -407,14 +536,15 @@ async function pollBuild(jobId, selected, token, preserveView) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata = await fetchJson(
|
const [metadata, overlays] = await Promise.all([
|
||||||
`/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)}`)
|
||||||
|
]);
|
||||||
if (token !== state.buildToken) {
|
if (token !== state.buildToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextContext = { selected, jobId, metadata };
|
const nextContext = { selected, jobId, metadata, overlays };
|
||||||
const nextLayerBuild = await buildLayer(nextContext);
|
const nextLayerBuild = await buildLayer(nextContext);
|
||||||
if (token !== state.buildToken) {
|
if (token !== state.buildToken) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -544,6 +674,9 @@ viewport.addEventListener("pointerdown", (event) => {
|
||||||
if (!state.current) {
|
if (!state.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (event.target.closest(".overlay-item")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
viewport.setPointerCapture(event.pointerId);
|
viewport.setPointerCapture(event.pointerId);
|
||||||
state.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
state.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||||
|
|
@ -625,6 +758,7 @@ setMeta(null);
|
||||||
setDownloadState(false);
|
setDownloadState(false);
|
||||||
setLoadingState(false);
|
setLoadingState(false);
|
||||||
setEmptyStateVisible(true);
|
setEmptyStateVisible(true);
|
||||||
|
hideOverlayTooltip();
|
||||||
loadCatalog().catch((error) => {
|
loadCatalog().catch((error) => {
|
||||||
setStatus(error instanceof Error ? error.message : String(error));
|
setStatus(error instanceof Error ? error.message : String(error));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@
|
||||||
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
app.get("/api/maps/:game/:mapId/tiles/:tileX/:tileY.png", 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