Museum — Deep-Zoom Image Viewer
A refined deep-zoom study viewer for a fictional gigapixel scan of Eluned Varga's 1883 painting Cartography of a Quiet Sea. Explore a richly detailed SVG seascape with zoom in/out buttons, a precision slider, mouse-wheel and pinch zoom toward the cursor, click-drag panning, double-click to magnify, and a fullscreen toggle. A live percentage readout, a magnification meter, curated detail jumps, and a navigator minimap with a draggable viewport rectangle keep you oriented at every level of zoom.
MCP
Code
:root {
--paper: #f6f4ef;
--wall: #ffffff;
--charcoal: #1c1b19;
--ink: #2a2825;
--ink-2: #4a4640;
--muted: #8c857a;
--gold: #a98140;
--gold-d: #876631;
--gold-50: #f3ecdd;
--line: rgba(28, 27, 25, 0.12);
--line-2: rgba(28, 27, 25, 0.2);
--ok: #3f7d56;
--warn: #b8842c;
--danger: #b4493a;
--r-sm: 6px;
--r-md: 12px;
--r-lg: 18px;
--shadow-sm: 0 1px 2px rgba(28, 27, 25, 0.06), 0 4px 14px rgba(28, 27, 25, 0.05);
--shadow-md: 0 12px 40px rgba(28, 27, 25, 0.12);
--serif: "Cormorant Garamond", Georgia, serif;
--sans: "Inter", system-ui, -apple-system, sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
font-family: var(--sans);
background: var(--paper);
color: var(--ink);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button { font-family: inherit; }
kbd {
font-family: var(--sans);
font-size: 0.72em;
background: var(--wall);
border: 1px solid var(--line-2);
border-bottom-width: 2px;
border-radius: 4px;
padding: 0.5px 5px;
color: var(--ink-2);
}
.shell {
max-width: 1240px;
margin: 0 auto;
padding: 28px 24px 56px;
}
/* ---- masthead ---- */
.masthead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding-bottom: 20px;
border-bottom: 1px solid var(--line);
flex-wrap: wrap;
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
width: 40px;
height: 40px;
display: grid;
place-items: center;
border: 1px solid var(--gold);
border-radius: var(--r-sm);
color: var(--gold-d);
font-size: 18px;
}
.brand-name {
margin: 0;
font-family: var(--serif);
font-weight: 700;
font-size: 22px;
letter-spacing: 0.01em;
color: var(--charcoal);
}
.brand-sub {
margin: 0;
font-size: 12px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
}
.crumbs {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--muted);
}
.crumbs .sep { opacity: 0.5; }
.crumbs .cur { color: var(--ink); font-weight: 500; }
/* ---- layout ---- */
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 28px;
margin-top: 28px;
align-items: start;
}
/* ---- caption bar ---- */
.caption-bar { margin-bottom: 14px; }
.caption-id {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.badge {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 3px 9px;
border-radius: 999px;
border: 1px solid transparent;
}
.badge-gold { background: var(--gold-50); color: var(--gold-d); border-color: rgba(169, 129, 64, 0.3); }
.badge-ok { background: rgba(63, 125, 86, 0.1); color: var(--ok); border-color: rgba(63, 125, 86, 0.28); }
.acc {
font-size: 12px;
letter-spacing: 0.08em;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.art-title {
margin: 0;
font-family: var(--serif);
font-weight: 600;
font-size: 30px;
line-height: 1.1;
color: var(--charcoal);
}
.art-meta {
margin: 4px 0 0;
font-size: 14px;
color: var(--ink-2);
font-style: italic;
}
/* ---- stage / viewer ---- */
.stage {
position: relative;
margin: 0;
aspect-ratio: 4 / 3;
background: var(--charcoal);
border-radius: var(--r-md);
border: 1px solid var(--line-2);
overflow: hidden;
cursor: grab;
box-shadow: var(--shadow-sm);
/* gallery mat */
padding: 0;
outline: none;
}
.stage:focus-visible { box-shadow: 0 0 0 3px rgba(169, 129, 64, 0.45); }
.stage.dragging { cursor: grabbing; }
.stage::after {
/* inner mat frame */
content: "";
position: absolute;
inset: 0;
border: 14px solid #14130f;
border-radius: var(--r-md);
box-shadow: inset 0 0 0 1px rgba(169, 129, 64, 0.35), inset 0 0 60px rgba(0, 0, 0, 0.55);
pointer-events: none;
z-index: 4;
}
.canvas {
position: absolute;
inset: 0;
touch-action: none;
}
.artwork {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
transform-origin: 0 0;
will-change: transform;
}
.art-svg { display: block; width: 100%; height: 100%; }
/* zoom percentage pill */
.zoom-pill {
position: absolute;
left: 22px;
bottom: 22px;
z-index: 6;
background: rgba(20, 19, 15, 0.82);
color: var(--gold-50);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
padding: 5px 11px;
border-radius: 999px;
font-variant-numeric: tabular-nums;
border: 1px solid rgba(169, 129, 64, 0.4);
backdrop-filter: blur(4px);
}
/* navigator minimap */
.navigator {
position: absolute;
top: 22px;
right: 22px;
z-index: 6;
width: 148px;
}
.nav-thumb {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
border-radius: var(--r-sm);
overflow: hidden;
border: 1px solid rgba(169, 129, 64, 0.55);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
background: var(--gold-50);
}
.nav-mini {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
}
.nav-rect {
position: absolute;
border: 1.5px solid var(--gold);
background: rgba(169, 129, 64, 0.18);
box-shadow: 0 0 0 9999px rgba(20, 19, 15, 0.32);
border-radius: 2px;
pointer-events: none;
left: 0; top: 0; width: 100%; height: 100%;
}
.nav-label {
display: block;
margin-top: 5px;
text-align: center;
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(246, 244, 239, 0.85);
}
/* ---- controls ---- */
.controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-top: 16px;
flex-wrap: wrap;
}
.ctl-group {
display: flex;
align-items: center;
gap: 8px;
background: var(--wall);
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px 10px;
box-shadow: var(--shadow-sm);
}
.ctl {
border: 1px solid var(--line);
background: var(--wall);
color: var(--ink);
width: 36px;
height: 36px;
border-radius: 999px;
font-size: 20px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.1s;
}
.ctl-text {
width: auto;
padding: 0 16px;
font-size: 13px;
font-weight: 500;
letter-spacing: 0.02em;
}
.ctl:hover { background: var(--gold-50); border-color: var(--gold); color: var(--gold-d); }
.ctl:active { transform: translateY(1px); }
.ctl:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
.zoom-range {
width: 160px;
accent-color: var(--gold);
cursor: pointer;
}
.hint {
margin: 12px 0 0;
font-size: 12.5px;
color: var(--muted);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
/* ---- info column ---- */
.info-col { display: flex; flex-direction: column; gap: 18px; }
.panel {
background: var(--wall);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 18px 20px;
box-shadow: var(--shadow-sm);
}
.panel-h {
margin: 0 0 14px;
font-family: var(--serif);
font-weight: 600;
font-size: 19px;
color: var(--charcoal);
padding-bottom: 10px;
border-bottom: 1px solid var(--line);
}
.facts { margin: 0; display: grid; gap: 11px; }
.facts > div { display: grid; grid-template-columns: 96px 1fr; gap: 12px; align-items: baseline; }
.facts dt {
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.facts dd {
margin: 0;
font-size: 14px;
color: var(--ink);
}
.panel-body {
margin: 0 0 14px;
font-size: 14px;
color: var(--ink-2);
}
.detail-jumps { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
.jump {
background: var(--paper);
border: 1px solid var(--line);
color: var(--ink);
font-size: 12.5px;
padding: 6px 12px;
border-radius: 999px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.jump:hover { background: var(--gold-50); border-color: var(--gold); color: var(--gold-d); }
.jump:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
.meter-panel { display: grid; gap: 8px; }
.meter-row {
display: flex;
align-items: baseline;
justify-content: space-between;
font-size: 13px;
color: var(--ink-2);
}
.meter-row strong {
font-family: var(--serif);
font-size: 20px;
color: var(--charcoal);
font-variant-numeric: tabular-nums;
}
.meter-track {
height: 7px;
background: var(--gold-50);
border-radius: 999px;
overflow: hidden;
border: 1px solid var(--line);
}
.meter-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--gold), var(--gold-d));
border-radius: 999px;
transition: width 0.18s ease;
}
.panel-foot { margin: 0; font-size: 11.5px; color: var(--muted); }
/* ---- fullscreen ---- */
.stage:fullscreen,
.stage:-webkit-full-screen {
aspect-ratio: auto;
width: 100%;
height: 100%;
border-radius: 0;
background: #0c0b09;
}
.stage:fullscreen::after,
.stage:-webkit-full-screen::after { border-radius: 0; }
/* ---- toast ---- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translateX(-50%) translateY(16px);
background: var(--charcoal);
color: var(--paper);
font-size: 13.5px;
padding: 11px 20px;
border-radius: 999px;
box-shadow: var(--shadow-md);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s, transform 0.25s;
z-index: 50;
border: 1px solid rgba(169, 129, 64, 0.4);
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* ---- responsive ---- */
@media (max-width: 900px) {
.layout { grid-template-columns: 1fr; }
.info-col { order: 2; }
}
@media (max-width: 520px) {
.shell { padding: 20px 14px 44px; }
.masthead { gap: 12px; }
.crumbs { font-size: 12px; }
.art-title { font-size: 24px; }
.stage { aspect-ratio: 1 / 1; }
.navigator { width: 104px; top: 14px; right: 14px; }
.zoom-pill { left: 14px; bottom: 14px; }
.controls { gap: 10px; }
.ctl-group { flex: 1 1 auto; justify-content: center; }
.zoom-range { width: 100%; min-width: 90px; }
.facts > div { grid-template-columns: 84px 1fr; }
}/* Meridian Museum — Deep-Zoom Image Viewer
* Vanilla JS. Pan/zoom via CSS transform on .artwork (transform-origin: 0 0).
* Coordinate model: artwork natural size is the stage box; scale >= 1.
* transform = translate(tx, ty) scale(scale)
* tx,ty in stage-pixel units, clamped so the artwork always covers the stage.
*/
(function () {
"use strict";
var stage = document.getElementById("stage");
var canvas = document.getElementById("canvas");
var artwork = document.getElementById("artwork");
var zoomPct = document.getElementById("zoomPct");
var navigator_ = document.getElementById("navigator");
var navMini = document.getElementById("navMini");
var navRect = document.getElementById("navRect");
var magText = document.getElementById("magText");
var magFill = document.getElementById("magFill");
var zoomRange = document.getElementById("zoomRange");
var toastEl = document.getElementById("toast");
var MIN = 1;
var MAX = 8;
// state
var scale = 1;
var tx = 0;
var ty = 0;
/* ---------- toast helper ---------- */
var toastTimer = null;
function toast(msg) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("show");
}, 1800);
}
/* ---------- geometry ---------- */
function box() {
return stage.getBoundingClientRect();
}
// Keep the artwork covering the stage: translation bounds.
function clamp() {
var b = box();
var minX = b.width - b.width * scale; // <= 0
var minY = b.height - b.height * scale;
if (tx > 0) tx = 0;
if (ty > 0) ty = 0;
if (tx < minX) tx = minX;
if (ty < minY) ty = minY;
}
function apply() {
clamp();
artwork.style.transform =
"translate(" + tx + "px," + ty + "px) scale(" + scale + ")";
var pct = Math.round(scale * 100);
if (zoomPct) zoomPct.textContent = pct + "%";
if (zoomRange) zoomRange.value = String(pct);
if (magText) magText.textContent = (Math.round(scale * 10) / 10).toFixed(1) + "×";
if (magFill) {
var frac = (scale - MIN) / (MAX - MIN);
magFill.style.width = Math.max(0, Math.min(1, frac)) * 100 + "%";
}
updateNav();
}
// Zoom toward a point given in stage-local pixel coords (px, py).
function zoomTo(nextScale, px, py) {
nextScale = Math.max(MIN, Math.min(MAX, nextScale));
if (nextScale === scale) return;
var b = box();
if (px == null) px = b.width / 2;
if (py == null) py = b.height / 2;
// artwork-space point under the cursor stays fixed
var ax = (px - tx) / scale;
var ay = (py - ty) / scale;
scale = nextScale;
tx = px - ax * scale;
ty = py - ay * scale;
apply();
}
function zoomBy(factor, px, py) {
zoomTo(scale * factor, px, py);
}
/* ---------- navigator minimap ---------- */
function updateNav() {
if (!navRect) return;
var inv = 1 / scale; // fraction of artwork visible
var ox = -tx / (box().width * scale); // left edge as fraction
var oy = -ty / (box().height * scale);
navRect.style.left = ox * 100 + "%";
navRect.style.top = oy * 100 + "%";
navRect.style.width = inv * 100 + "%";
navRect.style.height = inv * 100 + "%";
}
// Build a low-res preview of the artwork SVG for the minimap background.
function buildMiniThumb() {
if (!navMini) return;
var svg = artwork.querySelector("svg");
if (!svg) return;
try {
var clone = svg.cloneNode(true);
clone.removeAttribute("preserveAspectRatio");
var xml = new XMLSerializer().serializeToString(clone);
var url = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(xml);
navMini.style.backgroundImage = "url('" + url + "')";
} catch (e) {
/* ignore — minimap still shows the static svg behind it */
}
}
/* Click on minimap to recentre */
if (navigator_) {
var navThumb = navigator_.querySelector(".nav-thumb");
if (navThumb) {
navThumb.addEventListener("click", function (e) {
var r = navThumb.getBoundingClientRect();
var fx = (e.clientX - r.left) / r.width; // 0..1 centre target
var fy = (e.clientY - r.top) / r.height;
var b = box();
// centre the viewport on (fx, fy)
tx = -(fx * b.width * scale - b.width / 2);
ty = -(fy * b.height * scale - b.height / 2);
apply();
});
}
}
/* ---------- pointer pan (mouse + touch) ---------- */
var dragging = false;
var lastX = 0;
var lastY = 0;
var moved = false;
function localPoint(e) {
var b = box();
return { x: e.clientX - b.left, y: e.clientY - b.top };
}
stage.addEventListener("pointerdown", function (e) {
if (e.button !== undefined && e.button !== 0) return;
dragging = true;
moved = false;
lastX = e.clientX;
lastY = e.clientY;
stage.classList.add("dragging");
stage.setPointerCapture(e.pointerId);
});
stage.addEventListener("pointermove", function (e) {
if (!dragging) return;
var dx = e.clientX - lastX;
var dy = e.clientY - lastY;
if (Math.abs(dx) + Math.abs(dy) > 2) moved = true;
lastX = e.clientX;
lastY = e.clientY;
tx += dx;
ty += dy;
apply();
});
function endDrag(e) {
if (!dragging) return;
dragging = false;
stage.classList.remove("dragging");
try {
stage.releasePointerCapture(e.pointerId);
} catch (err) {}
}
stage.addEventListener("pointerup", endDrag);
stage.addEventListener("pointercancel", endDrag);
/* ---------- wheel zoom ---------- */
stage.addEventListener(
"wheel",
function (e) {
e.preventDefault();
var p = localPoint(e);
var factor = e.deltaY < 0 ? 1.18 : 1 / 1.18;
zoomBy(factor, p.x, p.y);
},
{ passive: false }
);
/* ---------- double-click to zoom in toward cursor ---------- */
stage.addEventListener("dblclick", function (e) {
var p = localPoint(e);
zoomBy(1.8, p.x, p.y);
});
/* ---------- pinch zoom ---------- */
var pinch = null; // { d, cx, cy }
var activeTouches = {};
function dist(a, b) {
var dx = a.x - b.x;
var dy = a.y - b.y;
return Math.hypot(dx, dy);
}
stage.addEventListener(
"touchstart",
function (e) {
if (e.touches.length === 2) {
dragging = false;
stage.classList.remove("dragging");
var b = box();
var t0 = { x: e.touches[0].clientX, y: e.touches[0].clientY };
var t1 = { x: e.touches[1].clientX, y: e.touches[1].clientY };
pinch = {
d: dist(t0, t1),
cx: (t0.x + t1.x) / 2 - b.left,
cy: (t0.y + t1.y) / 2 - b.top,
};
}
},
{ passive: true }
);
stage.addEventListener(
"touchmove",
function (e) {
if (pinch && e.touches.length === 2) {
e.preventDefault();
var t0 = { x: e.touches[0].clientX, y: e.touches[0].clientY };
var t1 = { x: e.touches[1].clientX, y: e.touches[1].clientY };
var d = dist(t0, t1);
if (pinch.d > 0) zoomTo(scale * (d / pinch.d), pinch.cx, pinch.cy);
pinch.d = d;
}
},
{ passive: false }
);
function endPinch(e) {
if (e.touches.length < 2) pinch = null;
}
stage.addEventListener("touchend", endPinch);
stage.addEventListener("touchcancel", endPinch);
/* ---------- buttons ---------- */
document.getElementById("zoomIn").addEventListener("click", function () {
zoomBy(1.4);
});
document.getElementById("zoomOut").addEventListener("click", function () {
zoomBy(1 / 1.4);
});
if (zoomRange) {
zoomRange.addEventListener("input", function () {
zoomTo(parseInt(zoomRange.value, 10) / 100);
});
}
document.getElementById("resetBtn").addEventListener("click", function () {
scale = 1;
tx = 0;
ty = 0;
apply();
toast("View reset");
});
document.getElementById("fitBtn").addEventListener("click", function () {
scale = 1;
tx = 0;
ty = 0;
apply();
toast("Fit to frame");
});
/* ---------- fullscreen ---------- */
var fsBtn = document.getElementById("fsBtn");
function fsElement() {
return document.fullscreenElement || document.webkitFullscreenElement || null;
}
fsBtn.addEventListener("click", function () {
if (!fsElement()) {
var req =
stage.requestFullscreen ||
stage.webkitRequestFullscreen ||
stage.msRequestFullscreen;
if (req) {
req.call(stage);
} else {
toast("Fullscreen not supported");
}
} else {
var exit = document.exitFullscreen || document.webkitExitFullscreen;
if (exit) exit.call(document);
}
});
function onFsChange() {
var on = !!fsElement();
fsBtn.textContent = on ? "Exit fullscreen" : "Fullscreen";
toast(on ? "Fullscreen" : "Exited fullscreen");
// recompute clamp/nav after layout settles
setTimeout(apply, 60);
}
document.addEventListener("fullscreenchange", onFsChange);
document.addEventListener("webkitfullscreenchange", onFsChange);
/* ---------- detail jumps ---------- */
var jumps = document.querySelectorAll("#jumps .jump");
Array.prototype.forEach.call(jumps, function (btn) {
btn.addEventListener("click", function () {
var fx = parseFloat(btn.getAttribute("data-x"));
var fy = parseFloat(btn.getAttribute("data-y"));
var z = parseFloat(btn.getAttribute("data-z"));
scale = Math.max(MIN, Math.min(MAX, z));
var b = box();
// centre the chosen artwork fraction in the viewport
tx = -(fx * b.width * scale - b.width / 2);
ty = -(fy * b.height * scale - b.height / 2);
apply();
toast("Detail · " + btn.textContent.trim());
});
});
/* ---------- keyboard ---------- */
stage.addEventListener("keydown", function (e) {
switch (e.key) {
case "+":
case "=":
e.preventDefault();
zoomBy(1.4);
break;
case "-":
case "_":
e.preventDefault();
zoomBy(1 / 1.4);
break;
case "0":
e.preventDefault();
scale = 1;
tx = 0;
ty = 0;
apply();
break;
case "f":
case "F":
e.preventDefault();
fsBtn.click();
break;
case "ArrowLeft":
e.preventDefault();
tx += 60;
apply();
break;
case "ArrowRight":
e.preventDefault();
tx -= 60;
apply();
break;
case "ArrowUp":
e.preventDefault();
ty += 60;
apply();
break;
case "ArrowDown":
e.preventDefault();
ty -= 60;
apply();
break;
}
});
/* ---------- init ---------- */
buildMiniThumb();
apply();
window.addEventListener("resize", function () {
// keep view valid on layout changes
apply();
});
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Meridian Museum — Deep-Zoom Image Viewer</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="shell">
<header class="masthead">
<div class="brand">
<span class="brand-mark" aria-hidden="true">◇</span>
<div class="brand-text">
<p class="brand-name">Meridian Museum</p>
<p class="brand-sub">Conservation Imaging Lab</p>
</div>
</div>
<nav class="crumbs" aria-label="Breadcrumb">
<span>Collection</span>
<span class="sep" aria-hidden="true">/</span>
<span>European Painting</span>
<span class="sep" aria-hidden="true">/</span>
<span class="cur">Deep-Zoom</span>
</nav>
</header>
<main class="layout">
<section class="viewer-col">
<div class="caption-bar">
<div class="caption-id">
<span class="badge badge-gold">Gigapixel</span>
<span class="badge badge-ok">On View</span>
<span class="acc">Acc. 1987.214</span>
</div>
<h1 class="art-title">Cartography of a Quiet Sea</h1>
<p class="art-meta">Eluned Varga (1841–1909) · Oil and gold leaf on linen · 1883</p>
</div>
<figure class="stage" id="stage" tabindex="0"
role="application"
aria-label="Deep-zoom artwork viewer. Use plus and minus to zoom, drag to pan, double-click to zoom in.">
<div class="canvas" id="canvas">
<div class="artwork" id="artwork" aria-hidden="true">
<svg class="art-svg" viewBox="0 0 1000 1000" preserveAspectRatio="xMidYMid slice" role="img" aria-label="Highly detailed abstract seascape">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#f3ecdd" />
<stop offset="0.55" stop-color="#e7d8b6" />
<stop offset="1" stop-color="#caa86a" />
</linearGradient>
<radialGradient id="sun" cx="0.5" cy="0.34" r="0.42">
<stop offset="0" stop-color="#fbf3df" />
<stop offset="0.6" stop-color="#e9c882" stop-opacity="0.6" />
<stop offset="1" stop-color="#e9c882" stop-opacity="0" />
</radialGradient>
<pattern id="weave" width="14" height="14" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<rect width="14" height="14" fill="none" />
<path d="M0 7 H14 M7 0 V14" stroke="#1c1b19" stroke-opacity="0.05" stroke-width="1" />
</pattern>
<pattern id="scale" width="30" height="18" patternUnits="userSpaceOnUse">
<path d="M0 18 Q15 0 30 18" fill="none" stroke="#876631" stroke-opacity="0.32" stroke-width="1.2" />
<path d="M-15 18 Q0 0 15 18 M15 18 Q30 0 45 18" fill="none" stroke="#876631" stroke-opacity="0.18" stroke-width="1.2" transform="translate(0,9)" />
</pattern>
</defs>
<rect width="1000" height="1000" fill="url(#sky)" />
<rect width="1000" height="1000" fill="url(#sun)" />
<!-- horizon banding -->
<g opacity="0.5">
<rect y="560" width="1000" height="440" fill="#2b3f52" opacity="0.18" />
<rect y="640" width="1000" height="360" fill="#22425a" opacity="0.22" />
<rect y="730" width="1000" height="270" fill="#143247" opacity="0.28" />
</g>
<!-- scaled sea texture -->
<rect y="560" width="1000" height="440" fill="url(#scale)" />
<!-- distant ships -->
<g stroke="#1c1b19" stroke-width="1.4" fill="none" opacity="0.7">
<path d="M210 552 v-46 M196 552 q14 8 28 0 M210 506 l34 22 M210 512 l-26 16" />
<path d="M760 566 v-34 M750 566 q10 6 20 0 M760 532 l24 16" />
<path d="M520 560 v-58 M504 560 q16 9 32 0 M520 502 l40 26 M520 510 l-30 18" />
</g>
<!-- coastline / land mass with cartographic lines -->
<g>
<path d="M0 300 C150 260 240 320 320 300 C420 274 470 340 560 318 C660 292 720 360 820 332 C900 310 960 350 1000 332 L1000 0 L0 0 Z" fill="#b9986a" opacity="0.5" />
<path d="M0 300 C150 260 240 320 320 300 C420 274 470 340 560 318 C660 292 720 360 820 332 C900 310 960 350 1000 332" fill="none" stroke="#5a4628" stroke-width="1.6" opacity="0.6" />
</g>
<!-- compass rose -->
<g transform="translate(760 760)" opacity="0.85">
<circle r="92" fill="none" stroke="#876631" stroke-width="1.2" />
<circle r="64" fill="none" stroke="#876631" stroke-width="0.8" stroke-dasharray="3 4" />
<g stroke="#5a4628" stroke-width="1">
<path d="M0 -92 V92 M-92 0 H92" />
<path d="M-65 -65 L65 65 M-65 65 L65 -65" opacity="0.5" />
</g>
<path d="M0 -88 L13 0 L0 30 L-13 0 Z" fill="#a98140" />
<path d="M0 88 L13 0 L0 -30 L-13 0 Z" fill="#3a4856" opacity="0.7" />
<text x="0" y="-100" text-anchor="middle" font-size="20" fill="#5a4628" font-family="Georgia, serif">N</text>
</g>
<!-- fine latitude lines + tiny annotation marks (reward deep zoom) -->
<g stroke="#1c1b19" stroke-opacity="0.08" stroke-width="0.6">
<path d="M0 120 H1000 M0 180 H1000 M0 240 H1000 M0 420 H1000 M0 480 H1000 M0 870 H1000 M0 930 H1000" />
</g>
<g fill="#5a4628" font-family="Georgia, serif" font-size="9" opacity="0.7">
<text x="120" y="116">lat. 41°·12′</text>
<text x="630" y="236">depth iv</text>
<text x="80" y="876">soundings — fathoms</text>
<text x="430" y="426">cartouche · ev</text>
</g>
<!-- gold-leaf flecks -->
<g fill="#c79b4e" opacity="0.55">
<circle cx="140" cy="660" r="2.2" /><circle cx="300" cy="720" r="1.6" />
<circle cx="470" cy="690" r="2.6" /><circle cx="650" cy="640" r="1.8" />
<circle cx="880" cy="700" r="2.2" /><circle cx="540" cy="760" r="1.4" />
<circle cx="240" cy="900" r="2" /><circle cx="720" cy="880" r="1.7" />
</g>
<rect width="1000" height="1000" fill="url(#weave)" />
</svg>
</div>
</div>
<div class="zoom-pill" aria-hidden="true">
<span id="zoomPct">100%</span>
</div>
<!-- navigator minimap -->
<div class="navigator" id="navigator" aria-hidden="true">
<div class="nav-thumb">
<svg viewBox="0 0 1000 1000" preserveAspectRatio="none">
<use href="#sky-clone" />
</svg>
<div class="nav-mini" id="navMini"></div>
<div class="nav-rect" id="navRect"></div>
</div>
<span class="nav-label">Navigator</span>
</div>
</figure>
<div class="controls" role="toolbar" aria-label="Viewer controls">
<div class="ctl-group">
<button class="ctl" id="zoomOut" type="button" aria-label="Zoom out" title="Zoom out (−)">−</button>
<input class="zoom-range" id="zoomRange" type="range" min="100" max="800" value="100" step="1" aria-label="Zoom level" />
<button class="ctl" id="zoomIn" type="button" aria-label="Zoom in" title="Zoom in (+)">+</button>
</div>
<div class="ctl-group">
<button class="ctl ctl-text" id="resetBtn" type="button" title="Reset view (0)">Reset</button>
<button class="ctl ctl-text" id="fitBtn" type="button" title="Fit to frame">Fit</button>
<button class="ctl ctl-text" id="fsBtn" type="button" title="Fullscreen (F)">Fullscreen</button>
</div>
</div>
<p class="hint">Scroll or pinch to zoom · drag to pan · double-click to zoom in · keys <kbd>+</kbd> <kbd>−</kbd> <kbd>0</kbd> <kbd>F</kbd></p>
</section>
<aside class="info-col" aria-label="Object record">
<div class="panel">
<h2 class="panel-h">Object Record</h2>
<dl class="facts">
<div><dt>Artist</dt><dd>Eluned Varga</dd></div>
<div><dt>Title</dt><dd>Cartography of a Quiet Sea</dd></div>
<div><dt>Date</dt><dd>1883</dd></div>
<div><dt>Medium</dt><dd>Oil and gold leaf on linen</dd></div>
<div><dt>Dimensions</dt><dd>142 × 198 cm (55⅞ × 78 in.)</dd></div>
<div><dt>Accession</dt><dd>1987.214</dd></div>
<div><dt>Credit</dt><dd>Gift of the Holloway Foundation</dd></div>
</dl>
</div>
<div class="panel">
<h2 class="panel-h">Conservation Detail</h2>
<p class="panel-body">
Captured at 1.4 gigapixels under raking light, this scan reveals Varga’s
cartographic underdrawing and the soundings she lettered in iron-gall ink along the
lower margin. Zoom past 300% to read the cartouche beside the compass rose.
</p>
<ul class="detail-jumps" id="jumps">
<li><button type="button" class="jump" data-x="0.74" data-y="0.74" data-z="3.4">Compass rose</button></li>
<li><button type="button" class="jump" data-x="0.43" data-y="0.42" data-z="4.6">Cartouche</button></li>
<li><button type="button" class="jump" data-x="0.52" data-y="0.5" data-z="2.6">Distant fleet</button></li>
<li><button type="button" class="jump" data-x="0.18" data-y="0.86" data-z="5.2">Fathom soundings</button></li>
</ul>
</div>
<div class="panel meter-panel">
<div class="meter-row">
<span>Magnification</span><strong id="magText">1.0×</strong>
</div>
<div class="meter-track"><div class="meter-fill" id="magFill"></div></div>
<p class="panel-foot">Max effective resolution at 8.0×</p>
</div>
</aside>
</main>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Deep-Zoom Image Viewer
A conservation-lab style deep-zoom viewer for the fictional Meridian Museum, built around a high-detail SVG composition that stands in for a 1.4-gigapixel scan of Eluned Varga’s painting Cartography of a Quiet Sea (1883). The artwork sits in a dark gallery mat with a gilt inner frame, and rewards close inspection with a compass rose, a hand-lettered cartouche, distant ships and fathom soundings that only resolve once you push past 300% magnification.
Every common gesture is supported: the plus and minus buttons and a precision range slider step
the zoom, the mouse wheel and two-finger pinch zoom toward the pointer, and click-drag pans the
image with the viewport always clamped so the artwork covers the frame. Double-click magnifies
toward the cursor, and keyboard users get +, -, 0, F and arrow keys with a visible focus
ring on the stage. A live percentage pill and a magnification meter track the current scale.
A navigator minimap in the corner renders a low-resolution preview of the scan with a gilt
rectangle showing the current viewport; clicking the minimap recentres the view there. Curated
detail jumps in the object record panel fly the viewer to specific passages, a fullscreen toggle
expands the stage edge-to-edge, and a small toast() helper confirms resets, jumps and mode
changes. The whole layout is AA-contrast, keyboard-usable and responsive down to roughly 360px.
Illustrative UI only — demo data; not a real museum system.