Enhance map rendering with WebP support and loading feedback

- Update tile rendering to support both PNG and WebP formats.
- Introduce a loading spinner and progress bar for build feedback.
- Remove the manual build button; builds now trigger automatically on map selection.
- Add a phase plan document outlining future improvements and goals.
This commit is contained in:
Marco 2026-03-27 11:02:30 +01:00
commit 549ff38334
6 changed files with 243 additions and 45 deletions

View file

@ -0,0 +1,36 @@
# Map Renderer Phase Plan
## Phase 1
Goal: tighten the current viewer flow without changing the map semantics.
- remove the explicit manual build button because map selection and filter changes already trigger builds automatically
- hide the viewport's "choose a detected map" message as soon as a map is selected and a build starts
- add build feedback in the side panel with a spinner and a simple phase-based loading bar
- remove visible tile seam lines and the synthetic background grid so the map reads as one surface
- use a more efficient streamed visualization format for interactive tiles while keeping PNG as the final export format
Phase 1 implementation choice:
- interactive tiles switch from PNG to lossless WebP because it is broadly supported in current browsers and is more bandwidth-efficient for repeated server-rendered tile delivery
- full-map download remains PNG so export quality and compatibility stay unchanged
## Phase 2
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
- 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
- on hover, slightly scale the hovered item and show a tooltip with its decoded metadata payload
- 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
- 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
- likely revisit ScummVM Crusader handling in `D:\source\scummvm` to confirm what editor/debug records carry and how best to decode them for display
Open questions for phase 2:
- which editor-only families should become interactive overlays versus remain baked into the base render
- what exact metadata fields are reliable enough to expose in the tooltip
- whether some editor-only entries should be clustered, filtered, or toggled by family to keep dense maps usable

View file

@ -343,7 +343,7 @@ export class BuildManager {
DOWNLOAD_CACHE_ROOT, DOWNLOAD_CACHE_ROOT,
gameId, gameId,
`map-${mapId}`, `map-${mapId}`,
`${buildOptionSuffix(job.options)}.png` `${gameId}-map-${mapId}-${buildOptionSuffix(job.options)}.png`
); );
if (fs.existsSync(outputPath)) { if (fs.existsSync(outputPath)) {
return outputPath; return outputPath;
@ -354,7 +354,7 @@ export class BuildManager {
for (let tileY = 0; tileY < job.metadata.tileCountY; tileY += 1) { for (let tileY = 0; tileY < job.metadata.tileCountY; tileY += 1) {
for (let tileX = 0; tileX < job.metadata.tileCountX; tileX += 1) { for (let tileX = 0; tileX < job.metadata.tileCountX; tileX += 1) {
composites.push({ composites.push({
input: this.renderTile(jobId, gameId, mapId, tileX, tileY), input: await this.renderTile(jobId, gameId, mapId, tileX, tileY, "png"),
left: tileX * job.metadata.tileSize, left: tileX * job.metadata.tileSize,
top: tileY * job.metadata.tileSize top: tileY * job.metadata.tileSize
}); });
@ -377,19 +377,21 @@ export class BuildManager {
return outputPath; return outputPath;
} }
renderTile(jobId, gameId, mapId, tileX, tileY) { async renderTile(jobId, gameId, mapId, tileX, tileY, format = "webp") {
const job = this.requireReadyJob(jobId, gameId, mapId); const job = this.requireReadyJob(jobId, gameId, mapId);
const tileKey = `${job.id}:${tileX}:${tileY}`; const tileKey = `${job.id}:${format}:${tileX}:${tileY}`;
if (this.tileCache.has(tileKey)) { if (this.tileCache.has(tileKey)) {
return this.tileCache.get(tileKey); return this.tileCache.get(tileKey);
} }
const extension = format === "png" ? "png" : "webp";
const tilePath = path.join( const tilePath = path.join(
TILE_CACHE_ROOT, TILE_CACHE_ROOT,
gameId, gameId,
`map-${mapId}`, `map-${mapId}`,
buildOptionSuffix(job.options), buildOptionSuffix(job.options),
`${tileX}-${tileY}.png` `${tileX}-${tileY}.${extension}`
); );
if (fs.existsSync(tilePath)) { if (fs.existsSync(tilePath)) {
const cached = fs.readFileSync(tilePath); const cached = fs.readFileSync(tilePath);
@ -428,11 +430,25 @@ export class BuildManager {
); );
} }
const png = encodePng(tileWidth, tileHeight, buffer); let output;
if (format === "png") {
output = encodePng(tileWidth, tileHeight, buffer);
} else {
output = await sharp(buffer, {
raw: {
width: tileWidth,
height: tileHeight,
channels: 4
},
limitInputPixels: false
})
.webp({ lossless: true, effort: 4 })
.toBuffer();
}
ensureDir(path.dirname(tilePath)); ensureDir(path.dirname(tilePath));
fs.writeFileSync(tilePath, png); fs.writeFileSync(tilePath, output);
this.tileCache.set(tileKey, png); this.tileCache.set(tileKey, output);
return png; return output;
} }
requireReadyJob(jobId, gameId, mapId) { requireReadyJob(jobId, gameId, mapId) {

View file

@ -2,6 +2,7 @@
color-scheme: light dark; color-scheme: light dark;
--bg: #f1ead6; --bg: #f1ead6;
--panel: rgba(255, 248, 232, 0.92); --panel: rgba(255, 248, 232, 0.92);
--card: rgba(255, 255, 255, 0.58);
--panel-border: rgba(94, 73, 37, 0.25); --panel-border: rgba(94, 73, 37, 0.25);
--ink: #2d2212; --ink: #2d2212;
--muted: #6e5a37; --muted: #6e5a37;
@ -17,6 +18,7 @@
:root { :root {
--bg: #12161d; --bg: #12161d;
--panel: rgba(22, 28, 38, 0.92); --panel: rgba(22, 28, 38, 0.92);
--card: rgba(28, 35, 46, 0.96);
--panel-border: rgba(166, 187, 211, 0.16); --panel-border: rgba(166, 187, 211, 0.16);
--ink: #edf2fa; --ink: #edf2fa;
--muted: #aab8cc; --muted: #aab8cc;
@ -79,7 +81,6 @@ label {
} }
select, select,
button,
.action-link { .action-link {
width: 100%; width: 100%;
border-radius: 12px; border-radius: 12px;
@ -88,7 +89,6 @@ button,
font: inherit; font: inherit;
} }
button,
.action-link { .action-link {
cursor: pointer; cursor: pointer;
color: white; color: white;
@ -97,7 +97,6 @@ button,
text-align: center; text-align: center;
} }
button:disabled,
select:disabled, select:disabled,
.action-link.is-disabled { .action-link.is-disabled {
cursor: not-allowed; cursor: not-allowed;
@ -115,7 +114,7 @@ select:disabled,
.meta-panel { .meta-panel {
padding: 12px 14px; padding: 12px 14px;
border-radius: 14px; border-radius: 14px;
background: rgba(255, 255, 255, 0.58); background: var(--card);
border: 1px solid rgba(65, 48, 21, 0.08); border: 1px solid rgba(65, 48, 21, 0.08);
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@ -147,7 +146,47 @@ select:disabled,
} }
.status { .status {
min-height: 72px; min-height: 92px;
}
.status-row {
display: flex;
align-items: center;
gap: 10px;
}
.status-text {
min-height: 24px;
}
.spinner {
width: 18px;
height: 18px;
border-radius: 999px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: var(--accent);
animation: spin 0.8s linear infinite;
flex: 0 0 auto;
}
.progress-wrap {
margin-top: 12px;
}
.progress-track {
width: 100%;
height: 8px;
border-radius: 999px;
overflow: hidden;
background: rgba(0, 0, 0, 0.08);
}
.progress-fill {
height: 100%;
width: 0%;
border-radius: inherit;
background: linear-gradient(90deg, var(--accent) 0%, var(--accent-strong) 100%);
transition: width 180ms ease;
} }
.meta-panel { .meta-panel {
@ -202,12 +241,7 @@ select:disabled,
height: calc(100vh - 36px); height: calc(100vh - 36px);
overflow: hidden; overflow: hidden;
border-radius: 24px; border-radius: 24px;
background: background: radial-gradient(circle at top left, rgba(255,255,255,0.04), transparent 26%), var(--viewport);
linear-gradient(0deg, rgba(255,255,255,0.03), rgba(255,255,255,0.03)),
linear-gradient(90deg, rgba(255,255,255,0.035) 1px, transparent 1px),
linear-gradient(rgba(255,255,255,0.035) 1px, transparent 1px),
var(--viewport);
background-size: auto, 32px 32px, 32px 32px, auto;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.05), var(--shadow); box-shadow: inset 0 0 0 1px rgba(255,255,255,0.05), var(--shadow);
touch-action: none; touch-action: none;
cursor: grab; cursor: grab;
@ -231,10 +265,19 @@ select:disabled,
position: absolute; position: absolute;
image-rendering: pixelated; image-rendering: pixelated;
image-rendering: crisp-edges; image-rendering: crisp-edges;
border: 1px solid var(--tile-border);
pointer-events: none; pointer-events: none;
} }
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.viewport.is-dragging { .viewport.is-dragging {
cursor: grabbing; cursor: grabbing;
} }
@ -262,6 +305,10 @@ select:disabled,
text-align: center; text-align: center;
} }
.empty-state.is-hidden {
display: none;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.shell { .shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View file

@ -1,9 +1,11 @@
const mapForm = document.querySelector("#map-form"); const mapForm = document.querySelector("#map-form");
const mapSelect = document.querySelector("#map-select"); const mapSelect = document.querySelector("#map-select");
const buildButton = document.querySelector("#build-button");
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 downloadButton = document.querySelector("#download-button"); const downloadButton = document.querySelector("#download-button");
const spinner = document.querySelector("#spinner");
const progressWrap = document.querySelector("#progress-wrap");
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");
@ -34,6 +36,11 @@ const state = {
const ZOOM_FACTOR = 1.2; const ZOOM_FACTOR = 1.2;
const FIT_PADDING = 24; const FIT_PADDING = 24;
function setEmptyStateVisible(visible) {
emptyState.hidden = !visible;
emptyState.classList.toggle("is-hidden", !visible);
}
async function fetchJson(url, init) { async function fetchJson(url, init) {
const response = await fetch(url, init); const response = await fetch(url, init);
if (!response.ok) { if (!response.ok) {
@ -55,6 +62,29 @@ function setStatus(message) {
statusBox.textContent = message; statusBox.textContent = message;
} }
function phaseProgress(build) {
const phaseToValue = {
queued: 5,
"loading-assets": 18,
"loading-map": 32,
"collecting-items": 58,
sorting: 84,
ready: 100,
failed: 100
};
return phaseToValue[build?.phase] ?? 8;
}
function setLoadingState(active, build = null) {
spinner.hidden = !active;
progressWrap.hidden = !active;
if (active) {
progressFill.style.width = `${phaseProgress(build)}%`;
} else {
progressFill.style.width = "0%";
}
}
function setMeta(metadata) { function setMeta(metadata) {
if (!metadata) { if (!metadata) {
metaBox.innerHTML = '<p class="meta-empty">Select a map to see render metadata.</p>'; metaBox.innerHTML = '<p class="meta-empty">Select a map to see render metadata.</p>';
@ -189,7 +219,7 @@ function fitMap() {
function tileUrl(buildContext, tileX, tileY) { function tileUrl(buildContext, tileX, tileY) {
const { selected, jobId } = buildContext; const { selected, jobId } = buildContext;
return `/api/maps/${selected.game}/${selected.mapId}/tiles/${tileX}/${tileY}.png?buildId=${encodeURIComponent(jobId)}`; return `/api/maps/${selected.game}/${selected.mapId}/tiles/${tileX}/${tileY}.webp?buildId=${encodeURIComponent(jobId)}`;
} }
function createTileElement(tileX, tileY, buildContext, metadata) { function createTileElement(tileX, tileY, buildContext, metadata) {
@ -241,8 +271,10 @@ async function buildLayer(buildContext) {
} }
} }
await Promise.all(tilePromises); return {
return layer; layer,
ready: Promise.all(tilePromises)
};
} }
function getSelectedMap() { function getSelectedMap() {
@ -270,9 +302,11 @@ function currentFiltersMatch() {
function scheduleAutoBuild() { function scheduleAutoBuild() {
clearTimeout(autoBuildTimer); clearTimeout(autoBuildTimer);
setEmptyStateVisible(false);
autoBuildTimer = window.setTimeout(() => { autoBuildTimer = window.setTimeout(() => {
const selected = getSelectedMap(); const selected = getSelectedMap();
if (!selected) { if (!selected) {
setEmptyStateVisible(true);
return; return;
} }
if (currentSelectionMatches(selected) && currentFiltersMatch()) { if (currentSelectionMatches(selected) && currentFiltersMatch()) {
@ -305,7 +339,6 @@ function populateCatalog(catalog) {
} }
mapSelect.disabled = catalog.games.length === 0; mapSelect.disabled = catalog.games.length === 0;
buildButton.disabled = catalog.games.length === 0;
setDownloadState(false); setDownloadState(false);
if (catalog.games.length === 0) { if (catalog.games.length === 0) {
setStatus("No usable STATIC folders were detected under the app root."); setStatus("No usable STATIC folders were detected under the app root.");
@ -319,13 +352,16 @@ async function startBuild(selected) {
const token = ++state.buildToken; const token = ++state.buildToken;
const preserveView = currentSelectionMatches(selected); const preserveView = currentSelectionMatches(selected);
setEmptyStateVisible(false);
if (!state.current) { if (!state.current) {
emptyState.hidden = false;
enableZoomControls(false); enableZoomControls(false);
setMeta(null); setMeta(null);
setDownloadState(false); setDownloadState(false);
} }
setLoadingState(true, { phase: "queued" });
setStatus( setStatus(
preserveView preserveView
? `Rebuilding ${selected.game} map ${selected.mapId}. The current view stays visible until the new tiles are ready.` ? `Rebuilding ${selected.game} map ${selected.mapId}. The current view stays visible until the new tiles are ready.`
@ -356,8 +392,10 @@ async function pollBuild(jobId, selected, token, preserveView) {
} }
const latest = build.progress.at(-1); const latest = build.progress.at(-1);
setLoadingState(build.status !== "ready" && build.status !== "failed", build);
setStatus(latest ? `${build.phase}: ${latest.message}` : `${build.phase}...`); setStatus(latest ? `${build.phase}: ${latest.message}` : `${build.phase}...`);
if (build.status === "failed") { if (build.status === "failed") {
setLoadingState(false);
throw new Error(build.error || "Build failed"); throw new Error(build.error || "Build failed");
} }
if (build.status !== "ready") { if (build.status !== "ready") {
@ -377,31 +415,62 @@ async function pollBuild(jobId, selected, token, preserveView) {
} }
const nextContext = { selected, jobId, metadata }; const nextContext = { selected, jobId, metadata };
const nextLayer = await buildLayer(nextContext); const nextLayerBuild = await buildLayer(nextContext);
if (token !== state.buildToken) { if (token !== state.buildToken) {
return; return;
} }
state.current = nextContext; state.current = nextContext;
activeLayer.replaceWith(nextLayer);
activeLayer = nextLayer;
if (!preserveView) {
fitMap();
} else {
clampOffsets();
updateSceneLayout();
updateZoomLabel();
}
setMeta(metadata); setMeta(metadata);
setDownloadState( setDownloadState(
true, true,
`/api/maps/${selected.game}/${selected.mapId}/download.png?buildId=${encodeURIComponent(jobId)}` `/api/maps/${selected.game}/${selected.mapId}/download.png?buildId=${encodeURIComponent(jobId)}`
); );
setStatus(`Ready. ${selected.game} map ${selected.mapId} is fully loaded.`); setEmptyStateVisible(false);
emptyState.hidden = true;
enableZoomControls(true); enableZoomControls(true);
if (!preserveView) {
activeLayer.replaceWith(nextLayerBuild.layer);
activeLayer = nextLayerBuild.layer;
fitMap();
setStatus(`Rendering complete. Streaming tiles for ${selected.game} map ${selected.mapId}...`);
nextLayerBuild.ready.then(() => {
if (token !== state.buildToken) {
return;
}
setLoadingState(false);
setStatus(`Ready. ${selected.game} map ${selected.mapId} is fully loaded.`);
}).catch((error) => {
if (token !== state.buildToken) {
return;
}
setLoadingState(false);
setStatus(error instanceof Error ? error.message : String(error));
});
return;
}
setStatus(`Rendering complete. Streaming updated tiles for ${selected.game} map ${selected.mapId}...`);
try {
await nextLayerBuild.ready;
} catch (error) {
if (token !== state.buildToken) {
return;
}
setLoadingState(false);
throw error;
}
if (token !== state.buildToken) {
return;
}
activeLayer.replaceWith(nextLayerBuild.layer);
activeLayer = nextLayerBuild.layer;
clampOffsets();
updateSceneLayout();
updateZoomLabel();
setLoadingState(false);
setStatus(`Ready. ${selected.game} map ${selected.mapId} is fully loaded.`);
} }
async function loadCatalog() { async function loadCatalog() {
@ -554,6 +623,8 @@ enableZoomControls(false);
updateZoomLabel(); updateZoomLabel();
setMeta(null); setMeta(null);
setDownloadState(false); setDownloadState(false);
setLoadingState(false);
setEmptyStateVisible(true);
loadCatalog().catch((error) => { loadCatalog().catch((error) => {
setStatus(error instanceof Error ? error.message : String(error)); setStatus(error instanceof Error ? error.message : String(error));
}); });

View file

@ -21,7 +21,6 @@
<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>
</div> </div>
<button id="build-button" type="submit" disabled>Rebuild now</button>
</form> </form>
<div class="stack controls"> <div class="stack controls">
@ -38,7 +37,17 @@
<div class="stack"> <div class="stack">
<label>Status</label> <label>Status</label>
<div id="status" class="status">Idle.</div> <div class="status">
<div class="status-row">
<span id="spinner" class="spinner" hidden></span>
<div id="status" class="status-text">Idle.</div>
</div>
<div id="progress-wrap" class="progress-wrap" hidden>
<div class="progress-track">
<div id="progress-fill" class="progress-fill"></div>
</div>
</div>
</div>
</div> </div>
<div class="stack"> <div class="stack">

View file

@ -61,7 +61,7 @@ app.get("/api/maps/:game/:mapId/metadata", (request, response) => {
} }
}); });
app.get("/api/maps/:game/:mapId/tiles/:tileX/:tileY.png", (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 ?? "");
const mapId = Number.parseInt(request.params.mapId, 10); const mapId = Number.parseInt(request.params.mapId, 10);
@ -71,7 +71,7 @@ app.get("/api/maps/:game/:mapId/tiles/:tileX/:tileY.png", (request, response) =>
response.status(400).json({ error: "Invalid tile coordinates" }); response.status(400).json({ error: "Invalid tile coordinates" });
return; return;
} }
const png = builds.renderTile(buildId, request.params.game, mapId, tileX, tileY); const png = await builds.renderTile(buildId, request.params.game, mapId, tileX, tileY, "png");
response.setHeader("Content-Type", "image/png"); response.setHeader("Content-Type", "image/png");
response.setHeader("Cache-Control", "public, max-age=31536000, immutable"); response.setHeader("Cache-Control", "public, max-age=31536000, immutable");
response.end(png); response.end(png);
@ -80,6 +80,25 @@ app.get("/api/maps/:game/:mapId/tiles/:tileX/:tileY.png", (request, response) =>
} }
}); });
app.get("/api/maps/:game/:mapId/tiles/:tileX/:tileY.webp", async (request, response) => {
try {
const buildId = String(request.query.buildId ?? "");
const mapId = Number.parseInt(request.params.mapId, 10);
const tileX = Number.parseInt(request.params.tileX, 10);
const tileY = Number.parseInt(request.params.tileY, 10);
if (!Number.isInteger(tileX) || !Number.isInteger(tileY) || tileX < 0 || tileY < 0) {
response.status(400).json({ error: "Invalid tile coordinates" });
return;
}
const webp = await builds.renderTile(buildId, request.params.game, mapId, tileX, tileY, "webp");
response.setHeader("Content-Type", "image/webp");
response.setHeader("Cache-Control", "public, max-age=31536000, immutable");
response.end(webp);
} catch (error) {
response.status(400).json({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get("/api/maps/:game/:mapId/download.png", async (request, response) => { app.get("/api/maps/:game/:mapId/download.png", async (request, response) => {
try { try {
const buildId = String(request.query.buildId ?? ""); const buildId = String(request.query.buildId ?? "");