独家

Reading Map <mv-reading-map>

文章的实时缩略地图,基于其 DOM 构建而非截图:每一行文字(保留真实长度)、标题、列表项、图片、表格和代码行都变成一个抽象形状。

工作原理

文章的实时缩略图,由 DOM 构建而非截图:每一行文本(保留真实长度)、标题、列表项、图片、表格和代码行都会变成一个抽象形状。可拖动的镜头显示可见区域,阅读热力图会随时间把真正读过的内容“染上墨色”(每个区块的停留时间与预期阅读时间之比;快速滚动即视为略读),并按 URL 记忆。悬停时显示章节标题和状态;使用键盘时,各章节都是真正的链接。

分类滚动
类型Web Component(<mv-reading-map>)
状态稳定版
Keywordsexclusive, minimap, reading, progress, heatmap, toc, table-of-contents, article, blog, scroll, navigation, dwell-time

When to use

  • Long-form articles or docs need a minimap that shows the whole structure and lets readers jump to sections
  • Readers should see which sections they actually read, skimmed or skipped, remembered across visits
  • A docked side map should show time remaining and the current section during full-page reading

Avoid when

  • A simple scroll percentage bar is enough → use Scroll Progress instead
  • The content is short or has no headings; a minimap adds clutter without guidance
  • The page is a narrow mobile layout without room for a side map beside the article

安装

node scripts/add.mjs reading-map --out ./src/marvelous

使用 Marvelous UI MCP 服务器的 AI 智能体:install_components({ slugs: ["reading-map"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。

复制的文件(含依赖):tokens/tokens.css、core/base.css、core/canvas.js、core/dom.js、core/element.js、core/motion.js、core/observe.js、core/position.js、components/reading-map/reading-map.js、components/reading-map/reading-map.css。

用法

标准标记,可在此基础上通过属性、data-* 和 CSS 变量进行定制:

<div id="rm-demo" style="width:min(100%,56rem);margin-inline:auto">
  <style>
    #rm-demo .rm-shell { display:flex; gap:1rem; align-items:stretch; height:34rem }
    #rm-demo .rm-scroll { flex:1; min-width:0; overflow:auto; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); scroll-behavior:auto; overscroll-behavior:contain }
    #rm-demo article { max-width:38rem; margin:0 auto; padding:2rem 1.75rem 3rem; font-size:.9375rem; line-height:1.7; color:var(--mv-fg) }
    #rm-demo .rm-kicker { margin:0 0 .5rem; color:var(--mv-accent-fg); font-size:.75rem; font-weight:600; letter-spacing:.06em; text-transform:uppercase }
    #rm-demo h1 { margin:0 0 .75rem; font-size:1.75rem; line-height:1.2; letter-spacing:-.02em }
    #rm-demo .rm-lead { margin:0 0 1.5rem; color:var(--mv-fg-muted); font-size:1.0625rem }
    #rm-demo .rm-byline { display:flex; gap:.5rem; align-items:center; margin:0 0 2rem; color:var(--mv-fg-muted); font-size:.8125rem }
    #rm-demo h2 { margin:2.25rem 0 .75rem; font-size:1.25rem; line-height:1.3; letter-spacing:-.01em; scroll-margin-top:1rem }
    #rm-demo h3 { margin:1.5rem 0 .5rem; font-size:1rem }
    #rm-demo p, #rm-demo ul, #rm-demo ol { margin:0 0 1rem }
    #rm-demo li { margin:.25rem 0 }
    #rm-demo pre { margin:0 0 1.25rem; padding:1rem 1.125rem; overflow:auto; border-radius:var(--mv-radius-lg); background:light-dark(var(--mv-gray-950), var(--mv-gray-900)); color:var(--mv-gray-100); font:.8125rem/1.6 var(--mv-font-mono) }
    #rm-demo code { font-family:var(--mv-font-mono); font-size:.85em }
    #rm-demo blockquote { margin:1.25rem 0; padding:.25rem 0 .25rem 1rem; border-left:3px solid var(--mv-accent); color:var(--mv-fg-muted); font-style:italic }
    #rm-demo figure { margin:1.5rem 0 }
    #rm-demo .rm-art { aspect-ratio:16/7; border-radius:var(--mv-radius-lg); background:
        linear-gradient(180deg, transparent 60%, color-mix(in oklab, var(--mv-accent) 30%, transparent)),
        radial-gradient(120% 90% at 20% 10%, color-mix(in oklab, var(--mv-info) 45%, transparent), transparent 60%),
        linear-gradient(135deg, color-mix(in oklab, var(--mv-accent) 55%, var(--mv-bg)), color-mix(in oklab, var(--mv-warning) 40%, var(--mv-bg))) }
    #rm-demo .rm-chart { aspect-ratio:16/6; display:flex; align-items:flex-end; gap:6%; padding:1rem 8% 0; border-radius:var(--mv-radius-lg); background:var(--mv-bg-muted) }
    #rm-demo .rm-chart span { flex:1; border-radius:6px 6px 0 0; background:var(--mv-accent); opacity:.85 }
    #rm-demo figcaption { margin-top:.5rem; color:var(--mv-fg-muted); font-size:.8125rem }
    #rm-demo table { width:100%; margin:0 0 1.25rem; border-collapse:collapse; font-size:.8125rem }
    #rm-demo th, #rm-demo td { padding:.45rem .5rem; border-bottom:1px solid var(--mv-border); text-align:left }
    #rm-demo td:last-child, #rm-demo th:last-child { text-align:right; font-variant-numeric:tabular-nums }
    #rm-demo mv-reading-map { --mv-reading-map-height: 100% }
    @media (max-width:40rem) { #rm-demo mv-reading-map { --mv-reading-map-width: 4.25rem } #rm-demo article { padding:1.5rem 1.25rem 2.5rem } }
  </style>

  <div class="rm-shell">
    <div class="rm-scroll" id="rm-scroll" tabindex="0" aria-label="Article">
      <article id="rm-article">
        <p class="rm-kicker">Engineering blog · Performance</p>
        <h1>How we made our store load three times faster</h1>
        <p class="rm-lead">Six weeks, no redesign, and a mobile Largest Contentful Paint down from 4.1 s to 1.3 s. Here’s what actually mattered, and what didn’t help at all.</p>
        <p class="rm-byline"><span>By Sofia Martinez, Front-end team</span><span aria-hidden="true">·</span><span>12 min read</span></p>

        <h2 id="rm-constat">Where we started</h2>
        <p>In March, our mobile conversion rate dropped for the third month in a row. User tests all told the same story: the product page “takes a while to make up its mind.” The hero image showed up last, buttons shifted right as you tapped them, and the cart responded half a second late.</p>
        <p>Our dashboards, meanwhile, were all green. They were measuring a MacBook on the office fiber line. Our customers were shopping on the subway, on mid-range phones.</p>
        <figure>
          <div class="rm-chart" role="img" aria-label="Load time per week, down from 4.1 to 1.3 seconds"><span style="height:92%"></span><span style="height:80%"></span><span style="height:61%"></span><span style="height:44%"></span><span style="height:33%"></span><span style="height:29%"></span></div>
          <figcaption>Median mobile LCP, week over week (field data, 75th percentile).</figcaption>
        </figure>

        <h2 id="rm-mesurer">Measure before you optimize</h2>
        <p>First decision: no more optimizing without field data. We hooked the <code>web-vitals</code> library up to 10% of traffic and sent the results to our data warehouse, broken down by phone model, page type and network quality.</p>
        <pre><code>import { onLCP, onINP, onCLS } from "web-vitals";

const send = (metric) =&gt; {
  navigator.sendBeacon("/rum", JSON.stringify({
    name: metric.name,
    value: Math.round(metric.value),
    page: document.body.dataset.template,
  }));
};

onLCP(send);
onINP(send);
onCLS(send);</code></pre>
        <p>Within a week, the picture was crystal clear. Three items explained most of the delay:</p>
        <ul>
          <li>product images served at 2,400 pixels to 390-pixel screens;</li>
          <li>180 KB of JavaScript executed before first paint;</li>
          <li>a CDN cache that expired every five minutes, for no good reason.</li>
        </ul>

        <h2 id="rm-images">Images, the biggest win</h2>
        <p>Our photographers deliver gorgeous files, and we were serving them as is. Switching to responsive images, in AVIF with a WebP fallback, was the simplest and most profitable project of the quarter.</p>
        <figure>
          <div class="rm-art" role="img" aria-label="Product shot: a ceramic lamp on a pastel background"></div>
          <figcaption>The hero image now weighs 38 KB instead of 610 KB, with no visible loss.</figcaption>
        </figure>
        <p>We also reserved space for every image with <code>width</code> and <code>height</code> attributes. No more buttons jumping under your thumb: CLS went from 0.21 to 0.02.</p>
        <p>One last, often forgotten detail: the hero image is the only one loaded with high priority. All the others wait patiently until they get close to the viewport.</p>

        <h2 id="rm-js">Less JavaScript</h2>
        <p>Most of our JavaScript powered features almost nobody used on load: product zoom, the engraving configurator, the chat. We now load them on first interaction.</p>
        <pre><code>button.addEventListener("pointerenter", async () =&gt; {
  const { openZoom } = await import("./zoom.js");
  button.onclick = openZoom;
}, { once: true });</code></pre>
        <blockquote>“The fastest code is the code you don’t ship. The second fastest is the code you ship later.”</blockquote>
        <p>The result: 112 KB less on the critical path, and INP under 200 ms for 80% of sessions.</p>

        <h2 id="rm-cache">Caching, finally done right</h2>
        <p>Our product pages rarely change more than once a day. Yet the CDN treated them as stale after five minutes. We adopted <code>stale-while-revalidate</code> and a targeted purge on every catalog update.</p>
        <table>
          <thead><tr><th>Page</th><th>Cache hit rate before</th><th>After</th></tr></thead>
          <tbody>
            <tr><td>Product page</td><td>41%</td><td>96%</td></tr>
            <tr><td>Category</td><td>55%</td><td>94%</td></tr>
            <tr><td>Home</td><td>72%</td><td>99%</td></tr>
          </tbody>
        </table>

        <h2 id="rm-resultats">Results</h2>
        <p>Six weeks later, median mobile LCP was at 1.3 s, INP at 160 ms and CLS close to zero. Above all, mobile conversion rose 11%, and the bounce rate on product pages fell by a quarter.</p>
        <p>None of these projects was spectacular. None required rewriting the app. They were simply tackled in the order the data dictated.</p>

        <h2 id="rm-suite">What’s next?</h2>
        <p>Performance isn’t a project, it’s a habit. Every pull request now shows its impact on page weight, and a budget blocks anything over 10 KB added to the critical path. Next up: fonts, which still account for 90 KB at first paint.</p>
        <p>Questions, or a story of your own? Write to us. We’ll publish the best replies in an upcoming post.</p>
      </article>
    </div>
    <mv-reading-map id="rm-map" target="#rm-article" scroller="#rm-scroll" label="Article reading map"></mv-reading-map>
  </div>

  <script type="module">
    const map = document.getElementById("rm-map");
    const box = document.getElementById("rm-scroll");
    await customElements.whenDefined("mv-reading-map");
    // First visit: pretend a previous reading session (the map remembers real ones per URL).
    requestAnimationFrame(() => {
      if (Object.keys(map.dwell).length) return;
      const dwell = {};
      const give = (key, seconds, n = 24) => { for (let i = 0; i < n; i++) dwell[`${key}:${i}`] = seconds; };
      give("intro", 999);
      give("rm-constat", 999);
      give("rm-mesurer", 999, 2);
      for (let i = 3; i < 8; i++) dwell[`rm-mesurer:${i}`] = 1.4;
      give("rm-cache", 2.5, 3);
      map.dwell = dwell;
      box.scrollTop += document.getElementById("rm-mesurer").getBoundingClientRect().top - box.getBoundingClientRect().top - 24;
    });
  </script>
</div>

API

Attributes

Name类型DefaultDescription
targetselector | idContent to map (the article). Without the attribute: the previous sibling element.
scrollerselector | windowScroll container. Defaults to the content’s closest scrolling ancestor, otherwise the page.
sectionsselectorh2, h3Headings that split the sections (text before the first heading forms the introduction).
wpmnumber230Reading speed (words per minute) for the expected time per block; code counts double.
persistlocal | offlocalStores the reading heat in localStorage.
storage-keystringStorage key (default: page path + content id).
dockright | leftPins the map to the edge of the screen (full-page reading).
labelstringReading mapAccessible name of the navigation.
data-reading-mapheading | text | quote | code | media | table | ruleOn a content element: forces its shape on the map.
data-reading-map-ignorebooleanOn a content element: left out of the map.

Properties

Name类型Description
dwellRecord<string, number>Seconds of reading per block (“section:index”). Assignable, e.g. to sync reading progress across devices.
progress{ read, remaining, sections }Share read (0 to 1), estimated seconds remaining, and the state of each section (read | skimmed | unread).

Methods

NameDescription
reset()Clears the reading heat.
refresh()Rebuilds the map (automatic on resize, content changes, image and font loads).

Events

NameDescription
mv-progressA section changes state (unread → skimmed → read). detail: progress.

CSS classes

NameDescription
mv-reading-map-stage / -canvasDrawing area (canvas) for the shapes.
mv-reading-map-sectionSection link, laid over its height; data-state="read | skimmed | unread", aria-current="location" on the current section.
mv-reading-map-lensLens over the visible area (drag to scroll).
mv-reading-map-meta / -pct / -leftSummary: percent read and time remaining.
mv-reading-map-tipHover tooltip (popover).

CSS variables

NameDefaultDescription
--mv-reading-map-width6.5remMap width.
--mv-reading-map-height100% (dock: min(72vh, 36rem))Map height.
--mv-reading-map-read-colorvar(--mv-accent)Ink for what has been read, and the lens.
--mv-reading-map-ink / -heading / -codeColors of text lines, headings and the code block background.
--mv-reading-map-readShare read 0 → 1 (read-only, usable in your styles).

Accessibility

The map is a named <nav> holding an ordered list of real section links: Tab enters it, Up/Down arrows and Home/End move between sections, Enter scrolls and moves focus to the heading. Each link announces its state (“Measure before you optimize, skimmed (40%)”) and the visible section carries aria-current="location". The drawing, lens and tooltip are aria-hidden. Reduced motion: instant scrolling. The reading clock (1 Hz) only runs while the content is on screen, the tab is visible and the user was active within the last 45 s; no animation loop. Storage is wrapped in try/catch (private browsing, quotas). Forced colors: the heat strips, lens and progress use system colors (Highlight for read). A focused section link keeps focus when the map rebuilds. Known limits: reading is inferred from time on screen and scroll speed, not from gaze; sections split only on the sections headings; the heat is stored per browser (localStorage), sync it across devices with the dwell property (a dwell assigned before the first connection wins over the stored heat); a section whose heading has no id gets a link without a URL fragment and is scrolled to by script.

本页面由 AI 翻译。报告翻译问题