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,
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 ?? "");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue