Exclusivobeta

Step Zoom <mv-step-zoom>

Inspetor de imagens com zoom profundo baseado em etapas discretas e endereçáveis, em vez de pinça e rolagem livres. Uma grade com letras (A-H × 1-6 por padrão, com réguas) é sobreposta à visualização atual; cada etapa é um nível de zoom fixo (2× por padrão, até 16×) em torno de coordenadas exatas: clique ou pressione Enter em uma célula, arraste uma caixa (ajustada ao nível mais profundo em que ela ainda cabe), repita “zoom in” no ponto visado, desloque uma célula com Shift+setas, ou digite comandos em um pequeno campo (“C4”, “zoom in”, “pan left”, “zoom out”, “back”, “41.2, 61.8, 4”, vários unidos por “;”). Cada etapa fica registrada em uma faixa de miniaturas recortadas ao vivo para as quais você pode voltar, uma mira mostra a região em que você está entrando (ou de onde está saindo) e o minimapa mantém você orientado. A visualização atual é serializada em uma string curta (“73.47,73.8,8”: centro x %, y %, zoom) refletida no atributo view e, com hash, na URL, para que o link de um colega reproduza exatamente o mesmo recorte; mv-view também informa o recorte em pixels da imagem original. Além de hires-at, uma imagem de alta resolução é pré-carregada uma etapa antes e aparece por cima com um fade (e as opções do srcset são selecionadas de novo para o tamanho ampliado); um chip avisa quando a visualização ultrapassa a resolução real da imagem.

CategoriaMídia
TipoWeb Component (<mv-step-zoom>)
Statusbeta
Também instalainput
Keywordsexclusive, culture, zoom, deep-zoom, image-viewer, inspector, grid, coordinates, crop, permalink, share-link, history, breadcrumb, minimap, hires, srcset, qa, inspection, claims, keyboard, command-line

When to use

  • Reviewers inspect product, packaging or board photos for defects and must point teammates to the exact spot
  • Insurance or inspection photos must be examined step by step with a trail of what was zoomed and where
  • A diagram, floor plan or map needs keyboard-complete zoom with named grid cells instead of free panning
  • A design review or bug report should link to a precise crop of a screenshot rather than describe where to look

Avoid when

  • Shoppers just want a quick magnified look under the cursor while browsing → use Lens instead
  • A product page needs several photos with thumbnails, swipe and hover zoom → use Product Gallery instead
  • The image is a gigapixel slide or satellite map that needs streame d tiles and continuous pinc h zoo min

Instalação

node scripts/add.mjs step-zoom --out ./src/marvelous

Agente de IA com o servidor MCP do Marvelous UI: install_components({ slugs: ["step-zoom"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Arquivos copiados (dependências incluídas): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/focus.js, core/motion.js, core/observe.js, components/step-zoom/step-zoom.js, components/step-zoom/step-zoom.css, components/input/input.css.

Uso

Marcação de referência, para usar como ponto de partida e personalizar com atributos, data-* e variáveis CSS:

<div id="sz-demo" style="width:min(100%,74rem);margin-inline:auto">
  <style>
    #sz-demo { display:grid; gap:1.5rem }
    #sz-demo .sz-layout { display:grid; grid-template-columns:minmax(0,1fr) 18.5rem; gap:1.25rem; align-items:start }
    #sz-demo .sz-panel { display:grid; gap:1rem; padding:1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); font-size:.8125rem }
    #sz-demo .sz-k { color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.05em; text-transform:uppercase }
    #sz-demo .sz-head h3 { margin:.25rem 0 .125rem; font-size:.9375rem; letter-spacing:-.01em }
    #sz-demo .sz-head p { margin:0; color:var(--mv-fg-muted); font-size:.75rem }
    #sz-demo .sz-findings { display:grid; gap:.5rem; margin:0; padding:0; list-style:none }
    #sz-demo .sz-finding { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:.375rem .5rem; align-items:center; padding:.625rem .75rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle) }
    #sz-demo .sz-finding[data-active] { border-color:color-mix(in oklab, var(--mv-accent) 55%, var(--mv-border)); background:var(--mv-accent-subtle) }
    #sz-demo .sz-finding b { grid-column:1; font-weight:600; line-height:1.35 }
    #sz-demo .sz-finding .mv-badge { justify-self:start }
    #sz-demo .sz-finding .mv-button { grid-column:2; grid-row:1 / span 3 }
    #sz-demo .sz-finding code, #sz-demo .sz-view code { color:var(--mv-fg-muted); font:.6875rem/1.3 var(--mv-font-mono) }
    #sz-demo .sz-view { display:grid; gap:.375rem; padding-top:.875rem; border-top:1px solid var(--mv-border) }
    #sz-demo .sz-view-row { display:flex; align-items:center; justify-content:space-between; gap:.5rem }
    #sz-demo .sz-view code { padding:.375rem .5rem; border-radius:var(--mv-radius-md); background:var(--mv-bg-muted); color:var(--mv-fg); font-size:.75rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #sz-demo .sz-log { min-height:2.25rem; margin:0; color:var(--mv-fg-muted); font-size:.75rem; line-height:1.5 }
    #sz-demo .sz-switches { display:grid; gap:.25rem; padding-top:.75rem; border-top:1px solid var(--mv-border) }
    #sz-demo .sz-switches .mv-choice { font-size:.8125rem }
    #sz-demo .sz-claim { display:grid; grid-template-columns:17rem minmax(0,1fr); gap:1.25rem; align-items:start; padding:1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg-subtle) }
    #sz-demo .sz-claim h3 { margin:.25rem 0 .375rem; font-size:.9375rem }
    #sz-demo .sz-claim p { margin:0 0 .75rem; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5 }
    #sz-demo .sz-claim dl { display:grid; grid-template-columns:auto 1fr; gap:.25rem .75rem; margin:0 0 1rem; font-size:.75rem }
    #sz-demo .sz-claim dt { color:var(--mv-fg-muted) }
    #sz-demo .sz-claim dd { margin:0; font-variant-numeric:tabular-nums }
    #sz-demo .sz-actions { display:flex; flex-wrap:wrap; gap:.5rem }
    @media (max-width:60rem) {
      #sz-demo .sz-layout, #sz-demo .sz-claim { grid-template-columns:minmax(0,1fr) }
    }
  </style>

  <!-- Main: pre-shipment QA of a controller board photo (low-res JPEG + high-res source past 2×) -->
  <div class="sz-layout">
    <mv-step-zoom id="sz-board" grid="8x6" step="2" max-zoom="16" hires-at="2" hash="board" label="Board photo, NW-IO-24 Rev C">
      <img id="sz-board-img" alt="Top side of the NW-IO-24 Rev C controller board on an anti-static mat" width="1500" height="1000">
    </mv-step-zoom>

    <aside class="sz-panel" aria-label="Inspection report">
      <div class="sz-head">
        <span class="sz-k">Pre-shipment QA</span>
        <h3>NW-IO-24 Rev C · SN 26-091-0417</h3>
        <p>Lot 2451 · Inspected Sep 23, 2026 by Priya Raman</p>
      </div>
      <ol class="sz-findings" id="sz-findings">
        <li class="sz-finding" data-view="73.47,73.8,8">
          <span class="mv-badge" data-variant="danger"><span class="mv-badge-dot"></span>Critical</span>
          <b>Solder bridge, U7 pins 3-4</b>
          <code>73.47,73.8,8</code>
          <button type="button" class="mv-button" data-variant="outline" data-size="sm">Show</button>
        </li>
        <li class="sz-finding" data-view="55.67,54,8">
          <span class="mv-badge" data-variant="warning"><span class="mv-badge-dot"></span>Major</span>
          <b>Capacitor C14 not placed</b>
          <code>55.67,54,8</code>
          <button type="button" class="mv-button" data-variant="outline" data-size="sm">Show</button>
        </li>
        <li class="sz-finding" data-view="42.67,46.9,8">
          <span class="mv-badge" data-variant="secondary">Minor</span>
          <b>U1 lot code reads 2447, BOM says 2451</b>
          <code>42.67,46.9,8</code>
          <button type="button" class="mv-button" data-variant="outline" data-size="sm">Show</button>
        </li>
      </ol>
      <div class="sz-view">
        <span class="sz-k">Current view</span>
        <div class="sz-view-row">
          <code id="sz-view">50,50,1</code>
          <button type="button" class="mv-button" data-size="sm" id="sz-pin">Pin as finding</button>
        </div>
        <p class="sz-log" id="sz-log">Try “zoom in”, “pan right” or “back” in the command field, or drag a box on the photo.</p>
      </div>
      <div class="sz-switches">
        <label class="mv-choice" data-control="end"><input type="checkbox" role="switch" class="mv-switch" id="sz-sw-cmd" checked><span class="mv-choice-text"><span class="mv-choice-title">Command field</span></span></label>
        <label class="mv-choice" data-control="end"><input type="checkbox" role="switch" class="mv-switch" id="sz-sw-map" checked><span class="mv-choice-text"><span class="mv-choice-title">Minimap</span></span></label>
      </div>
    </aside>
  </div>

  <!-- Variant: insurance claim photo, 6×4 grid, 3× steps, no command field -->
  <section class="sz-claim" aria-label="Parcel damage claim">
    <div>
      <span class="mv-badge" data-variant="info"><span class="mv-badge-dot"></span>Under review</span>
      <h3>Claim CLM-20931 · Damaged parcel</h3>
      <p>Customer photo of the delivered box. Step in 3× at a time; each step is kept so the adjuster can retrace the review.</p>
      <dl>
        <dt>Declared value</dt><dd>$1,240.00</dd>
        <dt>Delivered</dt><dd>Sep 19, 2026</dd>
        <dt>Claimant</dt><dd>Amara Okafor</dd>
      </dl>
      <div class="sz-actions">
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="sz-claim-seal">Tamper seal</button>
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="sz-claim-corner">Crushed corner</button>
      </div>
    </div>
    <mv-step-zoom id="sz-claim" grid="6x4" step="3" max-zoom="9" commands="off" minimap="off" label="Claim photo, delivered parcel">
      <img id="sz-claim-img" alt="A delivered cardboard parcel with a shipping label, a crushed lower corner and packing tape" width="1200" height="900">
    </mv-step-zoom>
  </section>

  <script type="module">
    /* ── Procedural "photos": deterministic SVG, so the demo ships no binary assets ── */
    const rng = (seed) => () => {
      seed = (seed + 0x6d2b79f5) | 0;
      let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
      t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
      return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
    };
    const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;");

    function boardSVG(detail) {
      const r = rng(11);
      const o = [];
      const silk = "#eef3ee";
      const pin = "#cfd4da";
      const gold = "#c9a44c";
      const text = (x, y, s, size, fill = silk, extra = "") => o.push(`<text x="${x}" y="${y}" font-family="Arial, Helvetica, sans-serif" font-size="${size}" fill="${fill}" ${extra}>${esc(s)}</text>`);
      o.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1500 1000" width="${detail ? 7200 : 1500}" height="${detail ? 4800 : 1000}">`);
      o.push(`<defs>
        <linearGradient id="mat" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#34404c"/><stop offset="1" stop-color="#1f262e"/></linearGradient>
        <pattern id="matg" width="50" height="50" patternUnits="userSpaceOnUse"><path d="M50 0H0V50" fill="none" stroke="#ffffff" stroke-opacity=".05" stroke-width="2"/></pattern>
        <radialGradient id="sheen" cx=".35" cy=".3" r=".9"><stop offset="0" stop-color="#ffffff" stop-opacity=".13"/><stop offset=".6" stop-color="#ffffff" stop-opacity="0"/><stop offset="1" stop-color="#000000" stop-opacity=".18"/></radialGradient>
        <linearGradient id="metal" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#eef1f4"/><stop offset=".5" stop-color="#aab1b9"/><stop offset="1" stop-color="#d9dde2"/></linearGradient>
        <radialGradient id="can" cx=".4" cy=".35" r=".7"><stop offset="0" stop-color="#f2f4f6"/><stop offset=".7" stop-color="#b7bec6"/><stop offset="1" stop-color="#8e969f"/></radialGradient>
        <linearGradient id="gloss" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#ffffff" stop-opacity=".1"/><stop offset=".5" stop-color="#ffffff" stop-opacity="0"/></linearGradient>
      </defs>`);
      o.push(`<rect width="1500" height="1000" fill="url(#mat)"/><rect width="1500" height="1000" fill="url(#matg)"/>`);
      o.push(`<rect x="132" y="104" width="1260" height="820" rx="26" fill="#000" opacity=".35"/>`);
      o.push(`<rect x="120" y="90" width="1260" height="820" rx="26" fill="#1c6a47" stroke="#124630" stroke-width="5"/>`);

      // Traces (45° routing) and vias.
      const route = (a, b, w = 4) => {
        const dx = b[0] - a[0];
        const dy = b[1] - a[1];
        const mid = Math.abs(dx) > Math.abs(dy) ? `H${b[0] - Math.sign(dx) * Math.abs(dy)}` : `V${b[1] - Math.sign(dy) * Math.abs(dx)}`;
        o.push(`<path d="M${a[0]} ${a[1]} ${mid} L${b[0]} ${b[1]}" fill="none" stroke="#2b8a5b" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"/>`);
      };
      const via = (x, y) => o.push(`<circle cx="${x}" cy="${y}" r="6" fill="${gold}"/><circle cx="${x}" cy="${y}" r="2.4" fill="#0e2f1f"/>`);
      for (let i = 0; i < 16; i++) {
        const y = 342 + i * 11;
        route([530, y], [300 - (i % 4) * 22, 180 + i * 38]);
        via(300 - (i % 4) * 22, 180 + i * 38);
        route([750, y], [930, 250 + i * 34]);
      }
      for (let i = 0; i < 16; i++) {
        const x = 552 + i * 11;
        route([x, 540], [380 + i * 42, 780 + (i % 3) * 18]);
        via(380 + i * 42, 780 + (i % 3) * 18);
        route([x, 320], [470 + i * 30, 200 - (i % 2) * 20]);
      }
      route([1080, 671], [1150, 590], 5); route([1150, 530], [1230, 420], 7); route([1230, 300], [1310, 180], 7);
      for (let i = 0; i < 22; i++) via(Math.round(160 + r() * 1180), Math.round(140 + r() * 720));

      // Mounting holes, fiducials.
      for (const [x, y] of [[166, 136], [1334, 136], [166, 864], [1334, 864]]) o.push(`<circle cx="${x}" cy="${y}" r="26" fill="${gold}"/><circle cx="${x}" cy="${y}" r="15" fill="#11161b"/>`);
      for (const [x, y] of [[230, 136], [1270, 864], [230, 864]]) o.push(`<circle cx="${x}" cy="${y}" r="12" fill="#124630"/><circle cx="${x}" cy="${y}" r="6" fill="${pin}"/>`);

      // QFP microcontroller U1.
      const qx = 540;
      const qy = 330;
      for (let i = 0; i < 16; i++) {
        const p = 17 + i * 11;
        o.push(`<rect x="${qx + p - 2.5}" y="${qy - 14}" width="5" height="16" rx="1" fill="${pin}"/>`);
        o.push(`<rect x="${qx + p - 2.5}" y="${qy + 198}" width="5" height="16" rx="1" fill="${pin}"/>`);
        o.push(`<rect x="${qx - 14}" y="${qy + p - 2.5}" width="16" height="5" rx="1" fill="${pin}"/>`);
        o.push(`<rect x="${qx + 198}" y="${qy + p - 2.5}" width="16" height="5" rx="1" fill="${pin}"/>`);
      }
      o.push(`<rect x="${qx}" y="${qy}" width="200" height="200" rx="6" fill="#17191d"/><rect x="${qx}" y="${qy}" width="200" height="200" rx="6" fill="url(#gloss)"/>`);
      o.push(`<circle cx="${qx + 22}" cy="${qy + 22}" r="7" fill="#0d0e10" stroke="#2a2d33" stroke-width="1.5"/>`);
      text(qx + 100, qy + 96, "NW32F4", 28, "#a3abb3", `text-anchor="middle" font-weight="700" letter-spacing="1"`);
      if (detail) {
        text(qx + 100, qy + 124, "G7B6 · 2447", 13, "#8d959d", `text-anchor="middle" letter-spacing=".5"`);
        text(qx + 100, qy + 142, "TAIWAN  ARM®", 9, "#7d858d", `text-anchor="middle" letter-spacing=".8"`);
      } else {
        o.push(`<rect x="${qx + 62}" y="${qy + 113}" width="76" height="12" rx="2" fill="#8d959d" opacity=".22"/>`);
      }
      text(qx - 4, qy - 24, "U1", 16, silk, `font-weight="700"`);

      // SOIC-8 chips.
      const soic = (cx, cy, name, mark, bridge = false) => {
        for (let i = 0; i < 4; i++) {
          const x = cx - 33 + i * 22;
          o.push(`<rect x="${x - 4.5}" y="${cy - 43}" width="9" height="15" rx="1.5" fill="${pin}"/>`);
          o.push(`<rect x="${x - 4.5}" y="${cy + 28}" width="9" height="15" rx="1.5" fill="${pin}"/>`);
        }
        o.push(`<rect x="${cx - 48}" y="${cy - 29}" width="96" height="58" rx="4" fill="#1a1c20"/><rect x="${cx - 48}" y="${cy - 29}" width="96" height="58" rx="4" fill="url(#gloss)"/>`);
        o.push(`<circle cx="${cx - 36}" cy="${cy + 16}" r="4" fill="#0c0d0f" stroke="#2b2e34"/>`);
        if (detail) text(cx, cy + 4, mark, 12, "#9aa2aa", `text-anchor="middle" letter-spacing=".6"`);
        text(cx - 48, cy - 50, name, 14, silk, `font-weight="700"`);
        if (bridge) {
          const x3 = cx - 33 + 2 * 22;
          if (detail) {
            o.push(`<path d="M${x3 + 1} ${cy + 33} C${x3 + 7} ${cy + 31.5} ${x3 + 15} ${cy + 31.5} ${x3 + 21} ${cy + 33} C${x3 + 24} ${cy + 36} ${x3 + 23} ${cy + 40} ${x3 + 19} ${cy + 41} C${x3 + 14} ${cy + 39.5} ${x3 + 8} ${cy + 39.5} ${x3 + 3} ${cy + 41} C${x3 - 1} ${cy + 40} ${x3 - 2} ${cy + 36} ${x3 + 1} ${cy + 33}Z" fill="#d6dbe0" stroke="#aeb5bc" stroke-width=".6"/>`);
            o.push(`<path d="M${x3 + 6} ${cy + 34.5} C${x3 + 10} ${cy + 33.6} ${x3 + 13} ${cy + 33.6} ${x3 + 17} ${cy + 34.5}" fill="none" stroke="#fff" stroke-width=".9" stroke-linecap="round" opacity=".85"/>`);
          } else {
            o.push(`<rect x="${x3}" y="${cy + 32}" width="22" height="10" rx="5" fill="${pin}" opacity=".55"/>`);
          }
        }
      };
      soic(980, 300, "U2", "24C64 WP");
      soic(980, 440, "U3", "TPS562 31A");
      soic(330, 690, "U4", "65HVD72");
      soic(1170, 560, "U6", "LM393 D");
      soic(1080, 700, "U7", "TXS0104", true);

      // 0603 passives in rows; C14 is left unplaced (bare pads).
      let n = 12;
      const part = (x, y, kind, vertical = false, missing = false) => {
        const ref = `${kind}${n++}`;
        const g = vertical ? `transform="rotate(90 ${x} ${y})"` : "";
        o.push(`<g ${g}>`);
        o.push(`<rect x="${x - 15}" y="${y - 7}" width="8" height="14" rx="1.5" fill="${pin}"/><rect x="${x + 7}" y="${y - 7}" width="8" height="14" rx="1.5" fill="${pin}"/>`);
        if (!missing) {
          o.push(`<rect x="${x - 9}" y="${y - 6.5}" width="18" height="13" rx="1" fill="${kind === "R" ? "#15171a" : "#b88a5a"}"/>`);
          if (detail && kind === "R") o.push(`<text x="${x}" y="${y + 3}" font-family="Arial" font-size="7.5" fill="#e9ecef" text-anchor="middle">${["103", "472", "220", "10R", "331"][n % 5]}</text>`);
        }
        o.push(`</g>`);
        if (missing) o.push(`<rect x="${x - 21}" y="${y - 12}" width="42" height="24" rx="3" fill="none" stroke="${silk}" stroke-width="1.6" stroke-dasharray="4 3"/>`);
        if (detail || missing) vertical ? text(x - 13, y + 3, ref, 9, silk, `text-anchor="end"`) : text(x - 12, y - 16, ref, 9, silk);
        return ref;
      };
      part(835, 510, "C");
      part(875, 510, "C");
      part(835, 540, "C", false, true); // C14: bare pads
      part(875, 540, "R");
      for (let i = 0; i < 7; i++) part(560 + i * 30, 290, i % 2 ? "C" : "R");
      for (let i = 0; i < 6; i++) part(790, 360 + i * 30, i % 2 ? "R" : "C", true);
      for (let i = 0; i < 5; i++) part(900 + i * 34, 800, i % 2 ? "C" : "R");
      for (let i = 0; i < 3; i++) part(1000 + i * 34, 380, i % 2 ? "R" : "C");
      for (let i = 0; i < 3; i++) part(900, 620 + i * 30, "C", true);
      for (let i = 0; i < 4; i++) part(470 + i * 34, 620, i % 2 ? "R" : "C");
      for (let i = 0; i < 4; i++) part(1270, 640 + i * 32, "R", true);

      // Power inductor, small transistors, a 4-pin wire-to-board connector.
      o.push(`<rect x="1068" y="416" width="56" height="56" rx="6" fill="#2a2e34"/><rect x="1068" y="416" width="56" height="56" rx="6" fill="url(#gloss)"/>`);
      if (detail) text(1096, 449, "4R7", 12, "#a7afb7", `text-anchor="middle" font-weight="700"`);
      text(1068, 408, "L1", 13, silk, `font-weight="700"`);
      for (const [x, y, name] of [[440, 440, "Q1"], [440, 500, "Q2"]]) {
        o.push(`<rect x="${x - 12}" y="${y - 16}" width="7" height="10" fill="${pin}"/><rect x="${x + 5}" y="${y - 16}" width="7" height="10" fill="${pin}"/><rect x="${x - 3.5}" y="${y + 6}" width="7" height="10" fill="${pin}"/><rect x="${x - 15}" y="${y - 9}" width="30" height="17" rx="2" fill="#1a1c20"/>`);
        text(x - 22, y + 4, name, 11, silk, `font-weight="700" text-anchor="end"`);
      }
      o.push(`<rect x="1100" y="112" width="120" height="44" rx="4" fill="#ece3cf" stroke="#b9ae96"/><rect x="1108" y="120" width="104" height="18" rx="2" fill="#d8ccb1"/>`);
      for (let i = 0; i < 4; i++) o.push(`<rect x="${1118 + i * 26}" y="146" width="10" height="16" fill="${pin}"/>`);
      text(1228, 142, "J3", 13, silk, `font-weight="700"`);

      // Electrolytic caps, crystal, USB-C, LEDs, test points.
      for (const [x, y, rr, name] of [[1235, 250, 50, "C31"], [1235, 385, 40, "C32"]]) {
        o.push(`<circle cx="${x}" cy="${y}" r="${rr + 8}" fill="none" stroke="${silk}" stroke-width="2"/>`);
        o.push(`<circle cx="${x}" cy="${y}" r="${rr}" fill="url(#can)" stroke="#7d858e" stroke-width="2"/>`);
        o.push(`<path d="M${x - rr * 0.7} ${y - rr * 0.7} A${rr} ${rr} 0 0 0 ${x - rr * 0.7} ${y + rr * 0.7}" fill="none" stroke="#2c323a" stroke-width="${rr * 0.28}"/>`);
        if (detail) o.push(`<path d="M${x - rr * 0.45} ${y} H${x + rr * 0.45} M${x} ${y - rr * 0.45} V${y + rr * 0.45}" stroke="#8a929b" stroke-width="2"/>`);
        text(x + rr + 12, y - rr + 8, name, 13, silk, `font-weight="700"`);
      }
      o.push(`<rect x="430" y="286" width="72" height="30" rx="14" fill="url(#metal)" stroke="#8a939c"/>`);
      if (detail) text(466, 305, "16.000", 10, "#4a525b", `text-anchor="middle" font-weight="700"`);
      text(430, 278, "Y1", 13, silk, `font-weight="700"`);
      o.push(`<rect x="96" y="392" width="88" height="124" rx="12" fill="url(#metal)" stroke="#7f8891" stroke-width="2"/><rect x="112" y="420" width="56" height="68" rx="9" fill="#23282e"/><rect x="124" y="436" width="32" height="36" rx="4" fill="#3a4048"/>`);
      text(194, 408, "J1", 13, silk, `font-weight="700"`);
      for (const [y, lens, name] of [[720, "#72e29d", "D1"], [760, "#ffc46b", "D2"], [800, "#8fc5ff", "D3"]]) {
        o.push(`<rect x="1318" y="${y - 8}" width="30" height="16" rx="2" fill="${pin}"/><rect x="1324" y="${y - 7}" width="18" height="14" rx="2" fill="${lens}"/>`);
        text(1292, y + 5, name, 11, silk, `font-weight="700" text-anchor="end"`);
      }
      for (let i = 0; i < 6; i++) {
        const x = 300 + i * 150 + Math.round(r() * 40);
        const y = 205 + Math.round(r() * 30);
        o.push(`<circle cx="${x}" cy="${y}" r="9" fill="${gold}"/><circle cx="${x}" cy="${y}" r="4" fill="#e6c877"/>`);
        if (detail) text(x + 13, y + 4, `TP${i + 1}`, 9, silk);
      }

      // Header J2 (2×10).
      for (let i = 0; i < 10; i++) {
        for (const y of [842, 876]) o.push(`<rect x="${426 + i * 34}" y="${y - 12}" width="24" height="24" rx="${i === 0 && y === 842 ? 2 : 12}" fill="${gold}"/><circle cx="${438 + i * 34}" cy="${y}" r="6" fill="#12171c"/>`);
      }
      o.push(`<rect x="416" y="820" width="346" height="68" rx="4" fill="none" stroke="${silk}" stroke-width="2"/>`);
      text(416, 812, "J2", 13, silk, `font-weight="700"`);

      // Serial sticker and board title.
      o.push(`<rect x="220" y="560" width="150" height="58" rx="4" fill="#f6f5f0"/>`);
      for (let i = 0, x = 230; x < 360; i++) {
        const w = 1 + Math.floor(r() * 3.2);
        if (i % 2 === 0) o.push(`<rect x="${x}" y="568" width="${w}" height="26" fill="#1b1d20"/>`);
        x += w + 1;
      }
      if (detail) text(295, 609, "SN 26-091-0417", 11, "#1b1d20", `text-anchor="middle" font-weight="700" letter-spacing=".3"`);
      text(1004, 882, "NORTHWIND IO-24", 30, silk, `font-weight="700" letter-spacing="2"`);
      if (detail) text(1006, 900, "REV C · 09/2026 · IPC-A-610 CLASS 2", 11, silk, `letter-spacing="1"`);
      o.push(`<rect x="120" y="90" width="1260" height="820" rx="26" fill="url(#sheen)"/>`);
      o.push(`</svg>`);
      return o.join("");
    }

    function parcelSVG() {
      const r = rng(5);
      const o = [];
      o.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 900" width="7200" height="5400">`);
      o.push(`<defs>
        <linearGradient id="floor" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#8e8a84"/><stop offset="1" stop-color="#6c6863"/></linearGradient>
        <linearGradient id="card" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#d2a56b"/><stop offset="1" stop-color="#b98a52"/></linearGradient>
        <linearGradient id="top" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#dcb57f"/><stop offset="1" stop-color="#c79a5f"/></linearGradient>
        <radialGradient id="stain" cx=".5" cy=".5" r=".5"><stop offset="0" stop-color="#6b4a25" stop-opacity=".32"/><stop offset=".82" stop-color="#6b4a25" stop-opacity=".2"/><stop offset=".95" stop-color="#5a3c1b" stop-opacity=".42"/><stop offset="1" stop-color="#5a3c1b" stop-opacity="0"/></radialGradient>
      </defs>`);
      o.push(`<rect width="1200" height="900" fill="url(#floor)"/>`);
      for (let i = 0; i < 60; i++) o.push(`<circle cx="${Math.round(r() * 1200)}" cy="${Math.round(r() * 900)}" r="${(r() * 3 + 1).toFixed(1)}" fill="#000" opacity="${(r() * 0.08).toFixed(2)}"/>`);
      o.push(`<ellipse cx="610" cy="800" rx="470" ry="40" fill="#000" opacity=".28"/>`);
      // Box: top face + front face, crushed lower-right corner.
      o.push(`<path d="M190 250 L330 150 L1030 150 L1010 250 Z" fill="url(#top)"/>`);
      o.push(`<path d="M190 250 L1010 250 L1010 690 L960 740 L905 790 L190 790 Z" fill="url(#card)"/>`);
      o.push(`<path d="M1010 250 L1030 150 L1030 640 L1010 690 Z" fill="#a67943"/>`);
      o.push(`<path d="M905 790 L925 735 L975 700 L1010 690 L960 740 Z" fill="#8f6435"/><path d="M905 790 L930 742 M935 770 L965 725 M960 740 L990 705" stroke="#6e4a22" stroke-width="3" fill="none"/>`);
      o.push(`<path d="M600 150 L590 250" stroke="#9f7542" stroke-width="3"/>`);
      // Tape with a torn tamper strip.
      o.push(`<path d="M540 150 L655 150 L648 250 L533 250 Z" fill="#e8d7b5" opacity=".7"/>`);
      o.push(`<rect x="533" y="250" width="115" height="160" fill="#e8d7b5" opacity=".62"/>`);
      o.push(`<path d="M548 180 L600 176 L612 186 L632 181 L640 196 L552 200 Z" fill="#fff" opacity=".85"/>`);
      o.push(`<path d="M600 176 L606 190 L596 205 L610 214" fill="none" stroke="#7a5630" stroke-width="2.2"/>`);
      o.push(`<text x="596" y="235" font-family="Arial" font-size="9" fill="#9b2d24" text-anchor="middle" font-weight="700" letter-spacing="1">VOID IF OPENED</text>`);
      // Water stain.
      o.push(`<ellipse cx="330" cy="620" rx="95" ry="70" fill="url(#stain)"/>`);
      // Shipping label.
      o.push(`<rect x="660" y="300" width="300" height="230" rx="6" fill="#fbfaf6" stroke="#d8d3c8"/>`);
      o.push(`<rect x="660" y="300" width="300" height="40" rx="6" fill="#1d2126"/><text x="676" y="327" font-family="Arial" font-size="20" font-weight="700" fill="#fff" letter-spacing="2">PRIORITY</text><text x="944" y="326" font-family="Arial" font-size="12" fill="#fff" text-anchor="end">2 LB 4 OZ</text>`);
      const line = (y, s, size = 11, w = 400) => o.push(`<text x="676" y="${y}" font-family="Arial" font-size="${size}" font-weight="${w}" fill="#23272c">${esc(s)}</text>`);
      line(360, "SHIP TO:", 9, 700);
      line(376, "AMARA OKAFOR", 12, 700);
      line(391, "418 HARBOR STREET, APT 5C");
      line(405, "PORTLAND, OR 97209");
      for (let i = 0, x = 676; x < 940; i++) {
        const w = 1 + Math.floor(r() * 3.4);
        if (i % 2 === 0) o.push(`<rect x="${x}" y="420" width="${w}" height="62" fill="#111"/>`);
        x += w + 1.2;
      }
      line(500, "TRK 7741 2093 5518 2270", 11, 700);
      line(516, "PACKED 09/17/2026 · STATION 12 · INSPECTED", 7.5);
      o.push(`<rect x="220" y="300" width="190" height="84" rx="4" fill="#d9362c" transform="rotate(-4 315 342)"/><text x="315" y="352" font-family="Arial" font-size="34" font-weight="700" fill="#fff" text-anchor="middle" letter-spacing="3" transform="rotate(-4 315 342)">FRAGILE</text>`);
      o.push(`<path d="M250 470 l40 -34 l40 34 M270 452 v48 M310 452 v48" stroke="#5b3d1c" stroke-width="5" fill="none" stroke-linecap="round" opacity=".7"/><text x="290" y="528" font-family="Arial" font-size="11" fill="#5b3d1c" text-anchor="middle" opacity=".8">THIS SIDE UP</text>`);
      o.push(`</svg>`);
      return o.join("");
    }

    const blobURL = (svg) => URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));

    // Low-res board: the plain SVG at once, then a soft 960px JPEG made from it (what a phone upload looks like).
    const boardImg = document.getElementById("sz-board-img");
    const lowSVG = blobURL(boardSVG(false));
    boardImg.src = lowSVG;
    const raster = new Image();
    raster.onload = () => {
      try {
        const c = document.createElement("canvas");
        c.width = 960;
        c.height = 640;
        c.getContext("2d").drawImage(raster, 0, 0, 960, 640);
        boardImg.src = c.toDataURL("image/jpeg", 0.78);
      } catch {}
    };
    raster.src = lowSVG;

    const board = document.getElementById("sz-board");
    board.hires = blobURL(boardSVG(true));
    const claimImg = document.getElementById("sz-claim-img");
    claimImg.src = blobURL(parcelSVG());

    /* ── Wiring ── */
    const $ = (id) => document.getElementById(id);
    const viewOut = $("sz-view");
    const log = $("sz-log");
    const findings = $("sz-findings");
    const mark = (view) => {
      for (const li of findings.querySelectorAll(".sz-finding")) li.toggleAttribute("data-active", li.dataset.view === view);
    };

    board.addEventListener("mv-view", (e) => {
      viewOut.textContent = e.detail.view;
      const px = e.detail.sourceRect;
      log.textContent = `${e.detail.label} · ${e.detail.zoom}×${px ? ` · ${px.width}×${px.height} px crop` : ""}`;
      mark(e.detail.view);
    });
    board.addEventListener("mv-hires", () => { log.textContent = "Full-resolution source loaded."; });

    findings.addEventListener("click", (e) => {
      const li = e.target.closest(".mv-button") && e.target.closest(".sz-finding");
      if (li) board.go(li.dataset.view, { label: li.querySelector("b").textContent });
    });

    let pinned = 0;
    $("sz-pin").addEventListener("click", () => {
      const view = board.view;
      const zoom = board.zoom;
      const li = document.createElement("li");
      li.className = "sz-finding";
      li.dataset.view = view;
      const badge = document.createElement("span");
      badge.className = "mv-badge";
      badge.dataset.variant = "outline";
      badge.textContent = "Note";
      const title = document.createElement("b");
      title.textContent = `Pinned view ${++pinned} · ${zoom}×`;
      const code = document.createElement("code");
      code.textContent = view;
      const btn = document.createElement("button");
      btn.type = "button";
      btn.className = "mv-button";
      btn.dataset.variant = "outline";
      btn.dataset.size = "sm";
      btn.textContent = "Show";
      li.append(badge, title, code, btn);
      findings.append(li);
      mark(view);
      log.textContent = `Pinned ${view}. Anyone opening this link lands on the same crop.`;
    });

    $("sz-sw-cmd").addEventListener("change", (e) => { board.commands = e.target.checked ? null : "off"; });
    $("sz-sw-map").addEventListener("change", (e) => { board.minimap = e.target.checked ? null : "off"; });

    const claim = $("sz-claim");
    $("sz-claim-seal").addEventListener("click", () => claim.go("49.75,21.5,9", { label: "Tamper seal" }));
    $("sz-claim-corner").addEventListener("click", () => claim.go("80,83.33,3", { label: "Crushed corner" }));

    // Open mid-inspection (unless the URL already carries a shared view).
    customElements.whenDefined("mv-step-zoom").then(() => {
      if (!/(^|[#&])board=/.test(location.hash)) board.run("zoom in F5; zoom in E3; zoom out", { animate: false });
    });
  </script>
</div>

Referência cultural

Blade Runner: o caçador de androides, Ridley Scott (1982, filme). O detetive coloca uma fotografia em uma máquina e a inspeciona com comandos falados e discretos (ampliar uma região, acompanhar, parar), cada um uma etapa reproduzível em torno de coordenadas de uma grade, até que surja um detalhe escondido. Na interface, uma foto é inspecionada por células de grade endereçáveis e etapas de zoom fixas, controladas por cliques, teclas ou comandos digitados, cada etapa fica registrada e pode ser refeita, e a visualização exata viaja como um link curto.

API

Attributes

NameTipoDefaultDescription
grid"<columns>x<rows>"8x6Cells laid over the current view: columns are letters (A…, up to 26), rows are numbers (up to 99). Addresses are relative to the view, so “zoom in C4” always means the cell you see labelled C4.
stepnumber2Zoom factor of one step (1.25 to 8). Levels are powers of it: 1, 2, 4, 8, 16… Every view is snapped to a level, which keeps views reproducible.
max-zoomnumber16Deepest zoom factor; the last level is the largest power of step that does not exceed it. At the deepest level, zooming in pans to the cell instead.
view"x,y,zoom"Current view: center in % of the image width and height, and zoom factor (e.g. "73.47,73.8,8"). Read it, or set it to navigate (recorded as a step). Reflected by the component after every step; also accepts "full".
hashstringKey under which the view is kept in the URL hash (#photo=73.47,73.8,8, merged with other key=value pairs, updated with replaceState, removed at full frame). Read on first connection and on hashchange; enables the Copy link button.
hiresURLHigh-resolution source, preloaded and decoded once the next step would reach hires-at, then faded in over the base image (never swapped mid-frame). Same crossorigin and referrerpolicy as the <img>.
hires-atnumber2Zoom factor from which the high-resolution source is needed (a “Loading detail…” chip shows while it is not ready).
labelstringAccessible name of the grid (default: the image alt, else “Image”); the zoom level is appended.
commands"off"Hides the command field (keyboard and pointer steps still work).
history"off"Hides the step strip (steps are still recorded; back and forward still work).
minimap"off"Hides the overview inset shown while zoomed.
toolbar"off"Hides the whole toolbar (readout, command field, buttons), for a fully custom UI driven by the API.
history-limitnumber50Maximum recorded steps; the oldest after the first (full frame) are dropped.
data-zoomed / data-max / data-resolving / data-upscaled / data-dragging / data-pointerset by the componentOn the host: zoomed past 1×, at the deepest level, high-res source loading, view beyond the source resolution (more than 1.5 screen pixels per source pixel), box drag in progress, last interaction was a pointer (cells focused by a click show no keyboard ring or preview).

Properties

NameTipoDescription
viewstringLive view string (see the attribute). Setting it navigates.
zoomnumberCurrent zoom factor (read-only).
center{ x, y }Center of the view in image fractions, 0..1 (read-only).
rect{ x, y, width, height }Visible region in image fractions (read-only).
sourceRect{ x, y, width, height } | nullVisible region in source pixels, from the <img> width/height attributes or its natural size, e.g. to crop server-side for a ticket (read-only).
stepsArray<{ view, label, action, cell, zoom }>Recorded steps, oldest first (read-only copy).
indexnumberPosition of the current step in steps (read-only).
targetstring | nullTargeted cell in the current view (the point a bare “zoom in” goes to), e.g. "E4" (read-only).
shareURLstringThe current page URL with the view in its hash (key: hash attribute, else "view").
stringsPartial<Record<string, string>>Overrides for every visible text and announcement (labels, button titles, help texts, step captions with {cell}/{dir}/{n}/{zoom}, announceStep with {label}/{zoom}/{x}/{y}…). English defaults.
grid / step / maxZoom / hires / hiresAt / hash / label / commands / history / minimap / toolbar / historyLimitreflectedMirror the attributes.

Methods

NameDescription
zoomIn(cell?)One step deeper into a cell ("C4", an index, or { col, row }), else into the targeted point, else around the center. At the deepest level it pans to the cell instead. Returns false if nothing changed or the step was cancelled.
zoomOut()One step out around the current center.
pan(direction, cells = 1)Pans by whole grid cells: "left" | "right" | "up" | "down". Stops at the image edge.
reset()Back to the full frame (recorded as a step).
back() / forward() / goTo(index)Moves through the recorded steps without recording new ones; a new step after going back drops the steps ahead.
go(view, { label?, animate? })Navigates to a view string or { x, y, zoom } (fractions), snapped to a level and kept inside the image. Recorded with label (default “Linked view”).
aim(cell)Targets a cell without zooming: the next bare zoomIn() goes there.
run(text, { animate? })Runs text commands, several separated by “;” or “then”: C4 · zoom in [C4] · pan to C4 · pan left|right|up|down [n] · zoom out · back · forward · reset · zoom 4 · 41.2, 61.8, 4. Returns false if one was not understood.
copyLink()Copies shareURL (Clipboard API, with a fallback) after the cancelable mv-copy. Resolves to true when copied.

Events

NameDescription
mv-stepCancelable, before any view change. detail: { action: "zoom-in" | "zoom-out" | "pan" | "box" | "reset" | "view" | "history", label, cell, from, to (view strings), zoom }. preventDefault() refuses the step (e.g. lock the zoom until a finding is saved).
mv-viewAfter every step. detail: { view, zoom, center, rect, sourceRect, action, label, cell, index }.
mv-commandCancelable, for every command typed or passed to run(). detail: { command, known }. preventDefault() means your app handled it (add custom verbs such as “flag”); unknown commands otherwise show an error.
mv-hiresThe high-resolution source is decoded and shown. detail: { src }.
mv-copyCancelable, before the view link is copied. detail: { url, view }. preventDefault() to use your own share sheet.

Content structure

NameDescription
(img)A direct <img> child with src and/or srcset, alt, and ideally width/height (they give the aspect ratio before load and the source-pixel crop). It stays in the DOM, hidden once upgraded; the viewer renders its own copies (framework-safe) and follows later changes to it.

CSS classes

NameDescription
mv-step-zoom-frame / -view / -ruler / -stageCard, rulers + stage grid, letter and number rulers (the hovered or focused column and row light up), the clipped stage (aspect ratio of the image).
mv-step-zoom-canvas / -img / -hiresThe transformed layer (scale + translate, transform-origin 0 0), the base image and the high-res layer (data-ready once shown).
mv-step-zoom-grid / -row / -cell / -tagrole=grid of role=gridcell cells (data-cell="C4", data-target on the targeted one) with their address tag.
mv-step-zoom-band / -reticle / -minimap / -statusBox being dragged, corner reticle (data-preview, data-active while animating), overview inset with -minimap-view, loading / beyond-resolution chip (data-kind).
mv-step-zoom-bar / -readout / -level / -coords / -at / -command / -tools / -tool / -msgToolbar: zoom level and center readout, hovered cell, command field (a .mv-input), grouped icon buttons (data-tool, aria-disabled at the limits), command error line.
mv-step-zoom-history / -history-item / -history-entry / -thumb / -history-num / -history-label / -history-zoomStep strip: entry buttons with a live crop thumbnail; aria-current="step" on the current one, data-future on steps ahead after going back.

CSS variables

NameDefaultDescription
--mv-step-zoom-ratiofrom the imageAspect ratio of the stage and thumbnails (width / height), set from the <img> width/height or natural size.
--mv-step-zoom-max-height80vhMaximum stage height; the view narrows and centers to keep the ratio.
--mv-step-zoom-accentvar(--mv-accent)Focus, hover, band and targeted-cell tint over the image.
--mv-step-zoom-ink / --mv-step-zoom-shade#fff / rgb(8 10 14 / 0.62)Text and backdrop of the overlays drawn on top of the photo (tags, chip, reticle, minimap frame), chosen to read on any image.
--mv-step-zoom-grid / --mv-step-zoom-grid-idlergb(255 255 255 / 0.26) / 0.1Grid line color while the stage is hovered or focused, and at rest.

Accessibility

The image is exposed as a real ARIA grid (role=grid named after the image and the zoom level, rows of role=gridcell cells named “A1”…“H6”) with a single tab stop and a description of the keys. Arrow keys move between cells, typing a letter then digits (“c4”) jumps to a cell, Home/End go to the row ends (Ctrl for the first and last cell); Enter, Space or + zooms into the focused cell, − zooms out, Shift+arrows pan the view one cell, Backspace goes back a step (Shift+Backspace forward), 0 returns to the full frame. After a keyboard step, focus follows the content to the cell that now holds the targeted point. A keyboard-focused cell previews, with corner marks, the exact region the next step will show. Every step is announced once in a polite live region (“Zoom C4. 4× zoom, centered at 73% across and 74% down.”, plus “Beyond source resolution.” when relevant); limits are announced instead of failing silently (maximum zoom, full frame, image edge, first or latest step). The targeted cell has aria-selected. The command field has a label, a description of the grammar, aria-invalid and an announced message on unknown commands, and Up/Down recall earlier commands. Toolbar buttons are real buttons with names and shortcut hints, set to aria-disabled (never disabled, so focus is never lost) at the limits. The step strip is a labelled list of buttons (“Step 3 of 4: Zoom E4, 4× zoom”) with a roving tab stop and aria-current="step". Pointer: click a cell to zoom in, drag a box, Alt-click to zoom out; touch taps zoom in and page scrolling is never blocked (boxes need a mouse or pen); the wheel is never hijacked. Reduced motion (OS or data-motion="reduce"): steps are instant, with no reticle flight, minimap glide or spinner. Forced colors: cells, reticle, band and minimap frame use Highlight and system colors. Overlays on the photo use their own ink and shade colors so they read on any image, in light and dark themes.

Esta página foi traduzida com IA. Informar um problema de tradução