← Files QuestforgeARCHIVED FILE
templates/panorama-viewer.html
25 KB · Sep 30, 2026 · 23:13 UTC
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__TITLE_TEXT__</title>
<style>
:root {
color-scheme: dark;
--surface: rgba(8, 10, 12, 0.52);
--surface-strong: rgba(8, 10, 12, 0.82);
--text: #f3efe6;
--muted: #c6bca7;
--accent: #78d7ba;
--danger: #ffb267;
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: #050607;
color: var(--text);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
letter-spacing: 0;
}
body {
touch-action: none;
user-select: none;
}
#viewer {
position: fixed;
inset: 0;
width: 100vw;
height: 100svh;
display: block;
cursor: grab;
}
#flat-viewer {
position: fixed;
inset: 0;
display: none;
width: 100vw;
height: 100svh;
background-color: #050607;
background-position: 50% 50%;
background-repeat: repeat-x;
background-size: auto 100%;
cursor: grab;
}
#viewer:active {
cursor: grabbing;
}
#flat-viewer:active {
cursor: grabbing;
}
.hud {
position: fixed;
left: clamp(14px, 2vw, 24px);
right: clamp(14px, 2vw, 24px);
bottom: clamp(14px, 2vw, 24px);
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
pointer-events: none;
}
.narration {
max-width: min(760px, calc(100vw - 180px));
padding: 14px 16px;
border-left: 2px solid rgba(120, 215, 186, 0.72);
background: var(--surface);
backdrop-filter: blur(16px);
}
.narration p {
margin: 0;
font-size: clamp(15px, 1.7vw, 21px);
line-height: 1.35;
font-weight: 520;
text-shadow: 0 1px 12px rgba(0, 0, 0, 0.92);
}
.controls {
position: fixed;
top: clamp(14px, 2vw, 24px);
right: clamp(14px, 2vw, 24px);
display: grid;
grid-template-columns: repeat(4, 40px);
gap: 8px;
padding: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 14px;
background: var(--surface);
backdrop-filter: blur(16px);
pointer-events: auto;
opacity: 0.72;
transition: opacity 160ms ease;
}
.controls[data-audio="off"] {
grid-template-columns: repeat(3, 40px);
}
.controls:hover,
.controls:focus-within {
opacity: 1;
}
button {
width: 40px;
height: 40px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 9px;
background: rgba(255, 255, 255, 0.08);
color: var(--text);
font: inherit;
font-size: 18px;
line-height: 1;
cursor: pointer;
}
button:hover {
border-color: rgba(120, 215, 186, 0.72);
color: var(--accent);
}
button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
button svg {
width: 19px;
height: 19px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
vertical-align: middle;
}
.audio-toggle[aria-pressed="true"] {
border-color: rgba(120, 215, 186, 0.72);
color: var(--accent);
}
.audio-toggle[aria-pressed="false"] .sound-wave {
display: none;
}
.audio-toggle[aria-pressed="true"] .mute-slash {
display: none;
}
.status {
position: fixed;
left: clamp(14px, 2vw, 24px);
top: clamp(14px, 2vw, 24px);
max-width: min(560px, calc(100vw - 28px));
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: var(--surface-strong);
color: var(--muted);
font-size: 12px;
line-height: 1.35;
pointer-events: none;
opacity: 0;
}
.status[data-state="error"] {
color: var(--danger);
opacity: 1;
}
.interaction-hint {
position: fixed;
left: 50%;
bottom: clamp(18px, 3vw, 32px);
margin: 0;
padding: 8px 11px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
background: var(--surface-strong);
color: var(--muted);
font-size: 12px;
line-height: 1;
pointer-events: none;
transform: translateX(-50%);
transition: opacity 220ms ease;
}
.interaction-hint[data-hidden="true"] {
opacity: 0;
}
#fallback {
position: fixed;
inset: 0;
display: none;
width: 100vw;
height: 100svh;
object-fit: contain;
background: #050607;
}
@media (max-width: 720px) {
.hud {
align-items: stretch;
}
.narration {
max-width: calc(100vw - 28px);
padding: 12px 14px;
}
.controls {
grid-template-columns: repeat(4, 36px);
}
.controls[data-audio="off"] {
grid-template-columns: repeat(3, 36px);
}
button {
width: 36px;
height: 36px;
}
}
@media (prefers-reduced-motion: reduce) {
.controls,
.interaction-hint {
transition: none;
}
}
</style>
</head>
<body>
<div id="flat-viewer" aria-label="Questforge 360 panorama fallback viewer"></div>
<canvas id="viewer" aria-label="Questforge 360 panorama viewer"></canvas>
<img id="fallback" alt="">
<div class="hud">
<section class="narration" aria-live="polite">
<p id="narration"></p>
</section>
<nav class="controls" id="controls" aria-label="Viewer controls">
<button
class="audio-toggle"
id="audio-toggle"
type="button"
title="Play ambience"
aria-label="Play ambience"
aria-pressed="false"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 9v6h4l5 4V5L8 9H4Z"></path>
<path class="sound-wave" d="M16 9.5a4 4 0 0 1 0 5"></path>
<path class="sound-wave" d="M18.5 7a7.5 7.5 0 0 1 0 10"></path>
<path class="mute-slash" d="M3 3l18 18"></path>
</svg>
</button>
<button id="zoom-out" type="button" title="Zoom out">-</button>
<button id="reset" type="button" title="Reset view">0</button>
<button id="zoom-in" type="button" title="Zoom in">+</button>
</nav>
</div>
<p class="interaction-hint" id="interaction-hint">
Drag to look · Scroll to zoom
</p>
<p class="status" id="status">Loading panorama.</p>
<audio id="ambient-audio" loop preload="auto"></audio>
<script>
const VIEWER_TITLE = __TITLE_JSON__;
const VIEWER_NARRATION = __NARRATION_JSON__;
const INITIAL_ZOOM_LEVEL = __INITIAL_ZOOM_LEVEL_JSON__;
const IMAGE_DATA_URI = __IMAGE_DATA_URI_JSON__;
const AUDIO_DATA_URI = __AUDIO_DATA_URI_JSON__;
const AUDIO_TITLE = __AUDIO_TITLE_JSON__;
const AUDIO_VOLUME = __AUDIO_VOLUME_JSON__;
const AUDIO_STORAGE_KEY = "questforgeAmbientAudioEnabled";
const canvas = document.getElementById("viewer");
const flatViewer = document.getElementById("flat-viewer");
const fallback = document.getElementById("fallback");
const controls = document.getElementById("controls");
const narration = document.getElementById("narration");
const interactionHint = document.getElementById("interaction-hint");
const status = document.getElementById("status");
const ambientAudio = document.getElementById("ambient-audio");
const audioToggle = document.getElementById("audio-toggle");
const zoomOut = document.getElementById("zoom-out");
const zoomIn = document.getElementById("zoom-in");
const reset = document.getElementById("reset");
document.title = VIEWER_TITLE;
narration.textContent = VIEWER_NARRATION || VIEWER_TITLE;
const clamp = (value, minimum, maximum) =>
Math.min(maximum, Math.max(minimum, value));
const MIN_FOV = Math.PI / 6;
const MAX_FOV = Math.PI * 0.53;
const MIN_ZOOM_LEVEL = 1;
const MAX_ZOOM_LEVEL = 22;
const INITIAL_FOV = zoomLevelToFov(INITIAL_ZOOM_LEVEL);
const INITIAL_FLAT_ZOOM = zoomLevelToFlatZoom(INITIAL_ZOOM_LEVEL);
const reducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
);
const state = {
yaw: 0,
pitch: 0,
fov: INITIAL_FOV,
targetFov: INITIAL_FOV,
dragging: false,
lastX: 0,
lastY: 0,
lastPointerTime: 0,
velocityYaw: 0,
velocityPitch: 0,
animationFrame: 0,
lastAnimationTime: 0,
ready: false,
};
const flatState = {
x: 50,
y: 50,
zoom: INITIAL_FLAT_ZOOM,
dragging: false,
lastX: 0,
lastY: 0,
installed: false,
};
let audioResumeListenersInstalled = false;
configureAmbientAudio();
const gl = canvas.getContext("webgl", {
antialias: false,
alpha: false,
preserveDrawingBuffer: true,
});
if (!gl) {
useFallback("WebGL is unavailable in this browser.");
} else {
initialize();
}
function initialize() {
const program = createProgram(vertexShaderSource(), fragmentShaderSource());
const positionLocation = gl.getAttribLocation(program, "a_position");
const yawLocation = gl.getUniformLocation(program, "u_yaw");
const pitchLocation = gl.getUniformLocation(program, "u_pitch");
const fovLocation = gl.getUniformLocation(program, "u_fov");
const aspectLocation = gl.getUniformLocation(program, "u_aspect");
const textureLocation = gl.getUniformLocation(program, "u_texture");
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
gl.STATIC_DRAW,
);
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
const image = new Image();
image.onload = () => {
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
gl.RGBA,
gl.UNSIGNED_BYTE,
image,
);
state.ready = true;
status.textContent = "Ready";
render();
};
image.onerror = () => useFallback("Unable to load panorama image.");
image.src = IMAGE_DATA_URI;
window.addEventListener("resize", render);
canvas.addEventListener("pointerdown", onPointerDown);
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerup", onPointerUp);
canvas.addEventListener("pointercancel", onPointerUp);
canvas.addEventListener("wheel", onWheel, { passive: false });
window.addEventListener("keydown", onKeyDown);
zoomOut.addEventListener("click", () => changeFov(0.12));
zoomIn.addEventListener("click", () => changeFov(-0.12));
reset.addEventListener("click", resetView);
function render() {
if (!state.ready) {
return;
}
resizeCanvas();
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.uniform1i(textureLocation, 0);
gl.uniform1f(yawLocation, state.yaw);
gl.uniform1f(pitchLocation, state.pitch);
gl.uniform1f(fovLocation, state.fov);
gl.uniform1f(aspectLocation, canvas.width / canvas.height);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
function onPointerDown(event) {
stopMotion();
hideInteractionHint();
state.dragging = true;
state.lastX = event.clientX;
state.lastY = event.clientY;
state.lastPointerTime = event.timeStamp;
canvas.setPointerCapture(event.pointerId);
}
function onPointerMove(event) {
if (!state.dragging) {
return;
}
const deltaX = event.clientX - state.lastX;
const deltaY = event.clientY - state.lastY;
const elapsed = clamp(
event.timeStamp - state.lastPointerTime,
8,
40,
);
const radiansPerPixel = (
state.fov / Math.max(canvas.clientHeight, 320)
) * 1.15;
const yawDelta = deltaX * radiansPerPixel;
const pitchDelta = deltaY * radiansPerPixel;
state.lastX = event.clientX;
state.lastY = event.clientY;
state.lastPointerTime = event.timeStamp;
state.yaw += yawDelta;
state.pitch = clamp(
state.pitch + pitchDelta,
-1.48,
1.48,
);
state.velocityYaw = yawDelta / elapsed;
state.velocityPitch = pitchDelta / elapsed;
render();
}
function onPointerUp(event) {
if (event.timeStamp - state.lastPointerTime > 100) {
state.velocityYaw = 0;
state.velocityPitch = 0;
}
state.dragging = false;
if (canvas.hasPointerCapture(event.pointerId)) {
canvas.releasePointerCapture(event.pointerId);
}
startMotion();
}
function onWheel(event) {
event.preventDefault();
hideInteractionHint();
changeFov(event.deltaY > 0 ? 0.08 : -0.08);
}
function onKeyDown(event) {
const step = 0.08;
hideInteractionHint();
if (event.key === "ArrowLeft") state.yaw -= step;
if (event.key === "ArrowRight") state.yaw += step;
if (event.key === "ArrowUp") state.pitch = clamp(state.pitch + step, -1.48, 1.48);
if (event.key === "ArrowDown") state.pitch = clamp(state.pitch - step, -1.48, 1.48);
if (event.key === "+") changeFov(-0.08);
if (event.key === "-") changeFov(0.08);
if (event.key === "0") resetView();
render();
}
function changeFov(delta) {
state.targetFov = clamp(
state.targetFov + delta,
MIN_FOV,
MAX_FOV,
);
startMotion();
}
function resetView() {
stopMotion();
state.yaw = 0;
state.pitch = 0;
state.fov = INITIAL_FOV;
state.targetFov = INITIAL_FOV;
render();
}
function startMotion() {
if (!state.ready || state.animationFrame) {
return;
}
state.animationFrame = window.requestAnimationFrame(animateMotion);
}
function stopMotion() {
if (state.animationFrame) {
window.cancelAnimationFrame(state.animationFrame);
}
state.animationFrame = 0;
state.lastAnimationTime = 0;
state.velocityYaw = 0;
state.velocityPitch = 0;
state.targetFov = state.fov;
}
function animateMotion(timestamp) {
state.animationFrame = 0;
if (state.dragging) {
return;
}
const elapsed = state.lastAnimationTime
? clamp(timestamp - state.lastAnimationTime, 1, 32)
: 16.67;
state.lastAnimationTime = timestamp;
if (reducedMotion.matches) {
state.velocityYaw = 0;
state.velocityPitch = 0;
state.fov = state.targetFov;
} else {
state.yaw += state.velocityYaw * elapsed;
const nextPitch = clamp(
state.pitch + state.velocityPitch * elapsed,
-1.48,
1.48,
);
if (nextPitch === -1.48 || nextPitch === 1.48) {
state.velocityPitch = 0;
}
state.pitch = nextPitch;
const damping = Math.pow(0.9, elapsed / 16.67);
state.velocityYaw *= damping;
state.velocityPitch *= damping;
const zoomProgress = 1 - Math.exp(-elapsed * 0.022);
state.fov += (state.targetFov - state.fov) * zoomProgress;
}
render();
const moving = (
Math.abs(state.velocityYaw) > 0.00001
|| Math.abs(state.velocityPitch) > 0.00001
|| Math.abs(state.targetFov - state.fov) > 0.0001
);
if (moving) {
state.animationFrame = window.requestAnimationFrame(animateMotion);
} else {
state.velocityYaw = 0;
state.velocityPitch = 0;
state.fov = state.targetFov;
state.lastAnimationTime = 0;
}
}
}
function activateFlatViewer(message) {
canvas.style.display = "none";
fallback.style.display = "none";
flatViewer.style.display = "block";
flatViewer.style.backgroundImage = `url("${IMAGE_DATA_URI}")`;
status.textContent = message;
status.dataset.state = "fallback";
renderFlatViewer();
installFlatViewerInteractions();
}
function installFlatViewerInteractions() {
if (flatState.installed) {
return;
}
flatState.installed = true;
flatViewer.addEventListener("pointerdown", (event) => {
flatState.dragging = true;
flatState.lastX = event.clientX;
flatState.lastY = event.clientY;
hideInteractionHint();
flatViewer.setPointerCapture(event.pointerId);
});
flatViewer.addEventListener("pointermove", (event) => {
if (!flatState.dragging) {
return;
}
const deltaX = event.clientX - flatState.lastX;
const deltaY = event.clientY - flatState.lastY;
flatState.lastX = event.clientX;
flatState.lastY = event.clientY;
flatState.x = ((flatState.x + deltaX * 0.08) % 100 + 100) % 100;
flatState.y = clamp(flatState.y + deltaY * 0.08, 12, 88);
renderFlatViewer();
});
flatViewer.addEventListener("pointerup", (event) => {
flatState.dragging = false;
if (flatViewer.hasPointerCapture(event.pointerId)) {
flatViewer.releasePointerCapture(event.pointerId);
}
});
flatViewer.addEventListener("pointercancel", () => {
flatState.dragging = false;
});
flatViewer.addEventListener("wheel", (event) => {
event.preventDefault();
hideInteractionHint();
flatState.zoom = clamp(
flatState.zoom + (event.deltaY > 0 ? -0.08 : 0.08),
0.7,
2.4,
);
renderFlatViewer();
}, { passive: false });
zoomOut.addEventListener("click", () => {
flatState.zoom = clamp(flatState.zoom - 0.12, 0.7, 2.4);
renderFlatViewer();
});
zoomIn.addEventListener("click", () => {
flatState.zoom = clamp(flatState.zoom + 0.12, 0.7, 2.4);
renderFlatViewer();
});
reset.addEventListener("click", () => {
flatState.x = 50;
flatState.y = 50;
flatState.zoom = INITIAL_FLAT_ZOOM;
renderFlatViewer();
});
}
function renderFlatViewer() {
flatViewer.style.backgroundPosition = `${flatState.x}% ${flatState.y}%`;
flatViewer.style.backgroundSize = `auto ${100 * flatState.zoom}%`;
}
function hideInteractionHint() {
interactionHint.dataset.hidden = "true";
}
function configureAmbientAudio() {
if (!AUDIO_DATA_URI) {
controls.dataset.audio = "off";
audioToggle.hidden = true;
return;
}
controls.dataset.audio = "on";
ambientAudio.src = AUDIO_DATA_URI;
ambientAudio.volume = AUDIO_VOLUME;
updateAudioToggle(audioPreferenceEnabled());
audioToggle.addEventListener("click", toggleAmbientAudio);
if (audioPreferenceEnabled()) {
playAmbientAudio();
installAudioResumeListeners();
}
}
async function toggleAmbientAudio(event) {
event.stopPropagation();
if (ambientAudio.paused) {
saveAudioPreference(true);
installAudioResumeListeners();
await playAmbientAudio();
} else {
ambientAudio.pause();
saveAudioPreference(false);
updateAudioToggle(false);
}
}
async function playAmbientAudio() {
if (!AUDIO_DATA_URI) {
return;
}
try {
await ambientAudio.play();
updateAudioToggle(true);
} catch (error) {
updateAudioToggle(audioPreferenceEnabled());
}
}
function installAudioResumeListeners() {
if (audioResumeListenersInstalled) {
return;
}
audioResumeListenersInstalled = true;
const resumeAudio = () => {
if (audioPreferenceEnabled() && ambientAudio.paused) {
playAmbientAudio();
}
};
canvas.addEventListener("pointerdown", resumeAudio);
flatViewer.addEventListener("pointerdown", resumeAudio);
document.addEventListener("keydown", resumeAudio);
}
function updateAudioToggle(isEnabled) {
audioToggle.setAttribute("aria-pressed", isEnabled ? "true" : "false");
const action = isEnabled ? "Mute ambience" : "Play ambience";
const label = AUDIO_TITLE ? `${action}: ${AUDIO_TITLE}` : action;
audioToggle.title = label;
audioToggle.setAttribute("aria-label", label);
}
function audioPreferenceEnabled() {
try {
return localStorage.getItem(AUDIO_STORAGE_KEY) === "true";
} catch (error) {
return false;
}
}
function saveAudioPreference(isEnabled) {
try {
localStorage.setItem(AUDIO_STORAGE_KEY, isEnabled ? "true" : "false");
} catch (error) {
return;
}
}
function zoomLevelToFov(level) {
const normalized = (clamp(level, MIN_ZOOM_LEVEL, MAX_ZOOM_LEVEL) - 1)
/ (MAX_ZOOM_LEVEL - MIN_ZOOM_LEVEL);
return MIN_FOV + normalized * (MAX_FOV - MIN_FOV);
}
function zoomLevelToFlatZoom(level) {
const normalized = (clamp(level, MIN_ZOOM_LEVEL, MAX_ZOOM_LEVEL) - 1)
/ (MAX_ZOOM_LEVEL - MIN_ZOOM_LEVEL);
return 1.45 - normalized * 0.65;
}
function resizeCanvas() {
const ratio = Math.min(window.devicePixelRatio || 1, 2);
const width = Math.max(1, Math.floor(window.innerWidth * ratio));
const height = Math.max(1, Math.floor(window.innerHeight * ratio));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
gl.viewport(0, 0, width, height);
}
}
function createProgram(vertexSource, fragmentSource) {
const vertexShader = compileShader(gl.VERTEX_SHADER, vertexSource);
const fragmentShader = compileShader(gl.FRAGMENT_SHADER, fragmentSource);
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
throw new Error(gl.getProgramInfoLog(program));
}
return program;
}
function compileShader(type, sourceText) {
const shader = gl.createShader(type);
gl.shaderSource(shader, sourceText);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
throw new Error(gl.getShaderInfoLog(shader));
}
return shader;
}
function vertexShaderSource() {
return `
attribute vec2 a_position;
varying vec2 v_uv;
void main() {
v_uv = a_position * 0.5 + 0.5;
gl_Position = vec4(a_position, 0.0, 1.0);
}
`;
}
function fragmentShaderSource() {
return `
precision highp float;
uniform sampler2D u_texture;
uniform float u_yaw;
uniform float u_pitch;
uniform float u_fov;
uniform float u_aspect;
varying vec2 v_uv;
const float PI = 3.141592653589793;
mat3 rotationY(float angle) {
float s = sin(angle);
float c = cos(angle);
return mat3(c, 0.0, -s, 0.0, 1.0, 0.0, s, 0.0, c);
}
mat3 rotationX(float angle) {
float s = sin(angle);
float c = cos(angle);
return mat3(1.0, 0.0, 0.0, 0.0, c, -s, 0.0, s, c);
}
void main() {
vec2 xy = v_uv * 2.0 - 1.0;
xy.x *= u_aspect;
float focal = 1.0 / tan(u_fov * 0.5);
vec3 direction = normalize(vec3(xy.x, -xy.y, -focal));
direction = rotationY(u_yaw) * rotationX(u_pitch) * direction;
float longitude = atan(direction.x, -direction.z);
float latitude = asin(clamp(direction.y, -1.0, 1.0));
vec2 sampleUv = vec2(
fract(0.5 + longitude / (2.0 * PI)),
0.5 + latitude / PI
);
gl_FragColor = texture2D(u_texture, sampleUv);
}
`;
}
function useFallback(message) {
canvas.style.display = "none";
fallback.style.display = "block";
fallback.src = IMAGE_DATA_URI;
status.textContent = message;
status.dataset.state = "error";
}
</script>
</body>
</html>
SHA-256: b01da0730fbcd96b88fa84b3da0e03599ff9e61006a984050c81d7101210b99e