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:
parent
24a4d90a3e
commit
549ff38334
6 changed files with 243 additions and 45 deletions
36
map_renderer/phase-plan.md
Normal file
36
map_renderer/phase-plan.md
Normal 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
|
||||
|
|
@ -343,7 +343,7 @@ export class BuildManager {
|
|||
DOWNLOAD_CACHE_ROOT,
|
||||
gameId,
|
||||
`map-${mapId}`,
|
||||
`${buildOptionSuffix(job.options)}.png`
|
||||
`${gameId}-map-${mapId}-${buildOptionSuffix(job.options)}.png`
|
||||
);
|
||||
if (fs.existsSync(outputPath)) {
|
||||
return outputPath;
|
||||
|
|
@ -354,7 +354,7 @@ export class BuildManager {
|
|||
for (let tileY = 0; tileY < job.metadata.tileCountY; tileY += 1) {
|
||||
for (let tileX = 0; tileX < job.metadata.tileCountX; tileX += 1) {
|
||||
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,
|
||||
top: tileY * job.metadata.tileSize
|
||||
});
|
||||
|
|
@ -377,19 +377,21 @@ export class BuildManager {
|
|||
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 tileKey = `${job.id}:${tileX}:${tileY}`;
|
||||
const tileKey = `${job.id}:${format}:${tileX}:${tileY}`;
|
||||
if (this.tileCache.has(tileKey)) {
|
||||
return this.tileCache.get(tileKey);
|
||||
}
|
||||
|
||||
const extension = format === "png" ? "png" : "webp";
|
||||
|
||||
const tilePath = path.join(
|
||||
TILE_CACHE_ROOT,
|
||||
gameId,
|
||||
`map-${mapId}`,
|
||||
buildOptionSuffix(job.options),
|
||||
`${tileX}-${tileY}.png`
|
||||
`${tileX}-${tileY}.${extension}`
|
||||
);
|
||||
if (fs.existsSync(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));
|
||||
fs.writeFileSync(tilePath, png);
|
||||
this.tileCache.set(tileKey, png);
|
||||
return png;
|
||||
fs.writeFileSync(tilePath, output);
|
||||
this.tileCache.set(tileKey, output);
|
||||
return output;
|
||||
}
|
||||
|
||||
requireReadyJob(jobId, gameId, mapId) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
color-scheme: light dark;
|
||||
--bg: #f1ead6;
|
||||
--panel: rgba(255, 248, 232, 0.92);
|
||||
--card: rgba(255, 255, 255, 0.58);
|
||||
--panel-border: rgba(94, 73, 37, 0.25);
|
||||
--ink: #2d2212;
|
||||
--muted: #6e5a37;
|
||||
|
|
@ -17,6 +18,7 @@
|
|||
:root {
|
||||
--bg: #12161d;
|
||||
--panel: rgba(22, 28, 38, 0.92);
|
||||
--card: rgba(28, 35, 46, 0.96);
|
||||
--panel-border: rgba(166, 187, 211, 0.16);
|
||||
--ink: #edf2fa;
|
||||
--muted: #aab8cc;
|
||||
|
|
@ -79,7 +81,6 @@ label {
|
|||
}
|
||||
|
||||
select,
|
||||
button,
|
||||
.action-link {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
|
|
@ -88,7 +89,6 @@ button,
|
|||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
.action-link {
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
|
|
@ -97,7 +97,6 @@ button,
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
select:disabled,
|
||||
.action-link.is-disabled {
|
||||
cursor: not-allowed;
|
||||
|
|
@ -115,7 +114,7 @@ select:disabled,
|
|||
.meta-panel {
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
background: var(--card);
|
||||
border: 1px solid rgba(65, 48, 21, 0.08);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
|
@ -147,7 +146,47 @@ select:disabled,
|
|||
}
|
||||
|
||||
.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 {
|
||||
|
|
@ -202,12 +241,7 @@ select:disabled,
|
|||
height: calc(100vh - 36px);
|
||||
overflow: hidden;
|
||||
border-radius: 24px;
|
||||
background:
|
||||
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;
|
||||
background: radial-gradient(circle at top left, rgba(255,255,255,0.04), transparent 26%), var(--viewport);
|
||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.05), var(--shadow);
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
|
|
@ -231,10 +265,19 @@ select:disabled,
|
|||
position: absolute;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
border: 1px solid var(--tile-border);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.viewport.is-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
|
@ -262,6 +305,10 @@ select:disabled,
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
const mapForm = document.querySelector("#map-form");
|
||||
const mapSelect = document.querySelector("#map-select");
|
||||
const buildButton = document.querySelector("#build-button");
|
||||
const includeEditorCheckbox = document.querySelector("#include-editor");
|
||||
const includeRoofsCheckbox = document.querySelector("#include-roofs");
|
||||
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 metaBox = document.querySelector("#meta");
|
||||
const viewport = document.querySelector("#viewport");
|
||||
|
|
@ -34,6 +36,11 @@ const state = {
|
|||
const ZOOM_FACTOR = 1.2;
|
||||
const FIT_PADDING = 24;
|
||||
|
||||
function setEmptyStateVisible(visible) {
|
||||
emptyState.hidden = !visible;
|
||||
emptyState.classList.toggle("is-hidden", !visible);
|
||||
}
|
||||
|
||||
async function fetchJson(url, init) {
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) {
|
||||
|
|
@ -55,6 +62,29 @@ function setStatus(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) {
|
||||
if (!metadata) {
|
||||
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) {
|
||||
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) {
|
||||
|
|
@ -241,8 +271,10 @@ async function buildLayer(buildContext) {
|
|||
}
|
||||
}
|
||||
|
||||
await Promise.all(tilePromises);
|
||||
return layer;
|
||||
return {
|
||||
layer,
|
||||
ready: Promise.all(tilePromises)
|
||||
};
|
||||
}
|
||||
|
||||
function getSelectedMap() {
|
||||
|
|
@ -270,9 +302,11 @@ function currentFiltersMatch() {
|
|||
|
||||
function scheduleAutoBuild() {
|
||||
clearTimeout(autoBuildTimer);
|
||||
setEmptyStateVisible(false);
|
||||
autoBuildTimer = window.setTimeout(() => {
|
||||
const selected = getSelectedMap();
|
||||
if (!selected) {
|
||||
setEmptyStateVisible(true);
|
||||
return;
|
||||
}
|
||||
if (currentSelectionMatches(selected) && currentFiltersMatch()) {
|
||||
|
|
@ -305,7 +339,6 @@ function populateCatalog(catalog) {
|
|||
}
|
||||
|
||||
mapSelect.disabled = catalog.games.length === 0;
|
||||
buildButton.disabled = catalog.games.length === 0;
|
||||
setDownloadState(false);
|
||||
if (catalog.games.length === 0) {
|
||||
setStatus("No usable STATIC folders were detected under the app root.");
|
||||
|
|
@ -319,13 +352,16 @@ async function startBuild(selected) {
|
|||
const token = ++state.buildToken;
|
||||
const preserveView = currentSelectionMatches(selected);
|
||||
|
||||
setEmptyStateVisible(false);
|
||||
|
||||
if (!state.current) {
|
||||
emptyState.hidden = false;
|
||||
enableZoomControls(false);
|
||||
setMeta(null);
|
||||
setDownloadState(false);
|
||||
}
|
||||
|
||||
setLoadingState(true, { phase: "queued" });
|
||||
|
||||
setStatus(
|
||||
preserveView
|
||||
? `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);
|
||||
setLoadingState(build.status !== "ready" && build.status !== "failed", build);
|
||||
setStatus(latest ? `${build.phase}: ${latest.message}` : `${build.phase}...`);
|
||||
if (build.status === "failed") {
|
||||
setLoadingState(false);
|
||||
throw new Error(build.error || "Build failed");
|
||||
}
|
||||
if (build.status !== "ready") {
|
||||
|
|
@ -377,31 +415,62 @@ async function pollBuild(jobId, selected, token, preserveView) {
|
|||
}
|
||||
|
||||
const nextContext = { selected, jobId, metadata };
|
||||
const nextLayer = await buildLayer(nextContext);
|
||||
const nextLayerBuild = await buildLayer(nextContext);
|
||||
if (token !== state.buildToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.current = nextContext;
|
||||
activeLayer.replaceWith(nextLayer);
|
||||
activeLayer = nextLayer;
|
||||
|
||||
if (!preserveView) {
|
||||
fitMap();
|
||||
} else {
|
||||
clampOffsets();
|
||||
updateSceneLayout();
|
||||
updateZoomLabel();
|
||||
}
|
||||
|
||||
setMeta(metadata);
|
||||
setDownloadState(
|
||||
true,
|
||||
`/api/maps/${selected.game}/${selected.mapId}/download.png?buildId=${encodeURIComponent(jobId)}`
|
||||
);
|
||||
setStatus(`Ready. ${selected.game} map ${selected.mapId} is fully loaded.`);
|
||||
emptyState.hidden = true;
|
||||
setEmptyStateVisible(false);
|
||||
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() {
|
||||
|
|
@ -554,6 +623,8 @@ enableZoomControls(false);
|
|||
updateZoomLabel();
|
||||
setMeta(null);
|
||||
setDownloadState(false);
|
||||
setLoadingState(false);
|
||||
setEmptyStateVisible(true);
|
||||
loadCatalog().catch((error) => {
|
||||
setStatus(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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-roofs" type="checkbox"> Show roofs</label>
|
||||
</div>
|
||||
<button id="build-button" type="submit" disabled>Rebuild now</button>
|
||||
</form>
|
||||
|
||||
<div class="stack controls">
|
||||
|
|
@ -38,7 +37,17 @@
|
|||
|
||||
<div class="stack">
|
||||
<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 class="stack">
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
const buildId = String(request.query.buildId ?? "");
|
||||
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" });
|
||||
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("Cache-Control", "public, max-age=31536000, immutable");
|
||||
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) => {
|
||||
try {
|
||||
const buildId = String(request.query.buildId ?? "");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue