Exclusifbêta

Step Zoom <mv-step-zoom>

Inspecteur d’image à zoom profond, fondé sur des étapes discrètes et adressables plutôt que sur le pincement et le défilement libres. Une grille lettrée (A-H × 1-6 par défaut, avec règles) se superpose à la vue actuelle ; chaque étape est un niveau de zoom fixe (2× par défaut, jusqu’à 16×) autour de coordonnées exactes : cliquez ou appuyez sur Entrée sur une cellule, tracez un cadre (calé sur le niveau le plus profond qui le contient encore), répétez « zoom in » sur le point ciblé, décalez d’une cellule avec Maj+flèches, ou tapez des commandes dans un petit champ (« C4 », « zoom in », « pan left », « zoom out », « back », « 41.2, 61.8, 4 », plusieurs à la suite séparées par « ; »). Chaque étape est enregistrée dans une bande de miniatures recadrées en direct, vers lesquelles vous pouvez revenir, un réticule montre la région dans laquelle vous zoomez (ou que vous quittez) et la minimap vous garde orienté. La vue actuelle se sérialise en une courte chaîne (« 73.47,73.8,8 » : centre x %, y %, zoom) reflétée dans l’attribut view et, avec hash, dans l’URL, pour que le lien d’un collègue reproduise exactement le même recadrage ; mv-view indique aussi le recadrage en pixels de la source. Au-delà de hires-at, une source haute résolution est préchargée une étape à l’avance et apparaît en fondu par-dessus (et les candidats srcset sont resélectionnés pour la taille zoomée) ; une pastille avertit quand la vue dépasse la résolution réelle de la source.

CatégorieMédias
TypeWeb Component (<mv-step-zoom>)
Statutbêta
Installe aussiinput
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 j tiles and continuous pinc h zoo min

Installation

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

Agent IA avec le serveur MCP Marvelous UI : install_components({ slugs: ["step-zoom"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Fichiers copiés (dépendances comprises) : 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.

Utilisation

Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables 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>

Référence culturelle

Blade Runner, Ridley Scott (1982, film). Le détective confie une photographie à une machine et l’inspecte par des commandes vocales discrètes (agrandir une zone, suivre, arrêter), chacune une étape reproductible autour de coordonnées de grille, jusqu’à ce qu’un détail caché apparaisse. Dans l’interface, une photo s’inspecte par des cellules de grille adressables et des paliers de zoom fixes pilotés par des clics, des touches ou des commandes tapées ; chaque étape est enregistrée et peut être rejouée, et la vue exacte se partage sous forme de lien court.

API

Attributes

NameTypeDefaultDescription
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

NameTypeDescription
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.

Cette page a été traduite par IA. Signaler un problème de traduction