独家

To Scale <mv-to-scale>

把抽象的数量换算成读者熟悉的人类尺度,与原封不动的精确数值并列显示,让它们变得容易理解:2.4 TB ≈ 800 小时高清视频,412 t CO₂e ≈ 7,000 棵树苗吸收的 CO₂,$9,000 ≈ 在里斯本 6 个月的房租,每天 21,600 单 ≈ 每 4 秒一单,38% 的客户 ≈ 每 5 位客户中有 2 位。

文化出处

《格列佛游记》,乔纳森·斯威夫特(1726 年,小说)。同一个人在小人国是巨人,在大人国却是玩偶:他的大小只有相对于衡量他的人才有意义。在界面中,原始数量会用读者已经熟悉的标尺重新衡量,组件会挑选能让数字变成一个整齐、易于理解的数值的标尺,其他标尺只需轻点一下即可切换。

工作原理

把抽象的数量换算成读者已经熟悉的人类尺度,与未经改动的精确值并列显示,让人一看就懂:2.4 TB ≈ 800 小时高清视频,412 t CO₂e ≈ 7,000 棵树苗吸收的 CO₂,$9,000 ≈ 里斯本 6 个月的房租,每天 21,600 单 ≈ 每 4 秒一单,38% 的客户 ≈ 每 5 位客户中有 2 位。可以包裹单个数字(行内注释或卡片),也可以扫描整段文本中的 data-scale span。可插拔的对比库涵盖数据、能源、CO₂e、质量、时间、距离、人数、金额、速率和占比,内置标尺始终显示其假设和来源;应用可以按受众、语言区域或用户画像添加自己的标尺(读者所在城市的房租、你产品的单位)。每个候选都会按易理解程度打分,因此选中的结果会落在一到几十之间的整数上,而不是“0.003 of…”或“612,345 of…”,标尺也会随数量级变化;两三个备选只需轻点或悬停即可查看,换算过程和取整方式放在一个可展开区域中,公制或英制跟随语言区域,同一段文本中同类的数字会共用一个标尺,以便相互比较。可选的比例圆点或分段条(附文字图例)、可取消的 mv-scale-pick 事件,以及一个纯静态的 MvToScale.express(value, unit, options),可在服务器端返回同样的句子、计算过程和备选项。

分类数据展示
类型Web Component(<mv-to-scale>)
状态稳定版
套件会自我解释的数字
Keywordsexclusive, culture, numbers, units, comparison, human-scale, data-literacy, numeracy, explainer, co2, carbon, energy, storage, bytes, money, cost-of-living, rate, percentage, natural-frequency, metric, imperial, ssr, reports, journalism

When to use

  • A dashboard or plan page shows storage, bandwidth or quota figures that users cannot picture (GB, TB, requests)
  • A sustainability, energy or impact report must make tonnes of CO₂e or gigawatt-hours tangible without losing the exact figure
  • A price, budget or savings goal should be restated in terms of the reader's own cost of living, supplied per city or persona
  • An article or AI answer is full of large numbers, rates and percentages that should each get an honest everyday equivalent

Avoid when

  • The number is a headline KPI that should count up or animate on arrival rather than be restated → use Number Ticker instead
  • Readers need to audit how a figure was computed from its inputs, not what it is comparable to → use Curtain instead
  • Several options are weighed against each other point by point (pros and cons, build vs buy) → use Balance Sheet instead

安装

node scripts/add.mjs to-scale --out ./src/marvelous

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

复制的文件(含依赖):tokens/tokens.css、core/base.css、core/dismiss.js、core/dom.js、core/element.js、core/position.js、components/to-scale/to-scale.js、components/to-scale/to-scale.css。

用法

快速开始,最小可运行标记:

<p>Your backup holds <mv-to-scale value="2.4 TB">2.4 TB</mv-to-scale>.</p>

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

<div id="ts-demo" style="width:min(100%,64rem);margin-inline:auto">
  <style>
    #ts-demo { display:grid; gap:1.25rem; align-content:start }
    #ts-demo .ts-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1.25rem; align-items:start }
    #ts-demo .ts-col { display:grid; gap:.75rem; min-width:0 }
    #ts-demo .ts-bar { display:flex; align-items:center; justify-content:space-between; gap:.5rem .75rem; flex-wrap:wrap }
    #ts-demo .ts-bar h3 { margin:0; font-size:.9375rem; font-weight:650; letter-spacing:-.01em }
    #ts-demo .ts-bar p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
    #ts-demo .ts-note { margin:0; padding:0 .25rem; color:var(--mv-fg-muted); font-size:.875rem; line-height:1.6 }
    #ts-demo .ts-report { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs); overflow:hidden }
    #ts-demo .ts-report-head { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:1rem 1.25rem; border-bottom:1px solid var(--mv-border) }
    #ts-demo .ts-report-head h3 { display:flex; align-items:center; gap:.5rem; margin:0; font-size:1rem; font-weight:650; letter-spacing:-.01em }
    #ts-demo .ts-report-head p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
    #ts-demo .ts-report mv-to-scale { padding:1.125rem 1.25rem .25rem; max-width:46rem }
    #ts-demo #ts-report > p { margin:0 0 1rem; font-size:.9375rem; line-height:1.85; text-wrap:pretty }
    #ts-demo .ts-report [data-scale] { font-weight:600; font-variant-numeric:tabular-nums; white-space:nowrap }
    #ts-demo .ts-foot { display:flex; align-items:center; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap; padding:.75rem 1.25rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
    #ts-demo .ts-log { margin:0; min-height:1.25rem; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono) }
    #ts-demo .mv-choice { font-size:.8125rem }
    @media (max-width:46rem) { #ts-demo .ts-grid { grid-template-columns:minmax(0,1fr) } }
  </style>

  <div class="ts-grid">
    <!-- Storage: the yardstick changes with the magnitude -->
    <section class="ts-col" aria-labelledby="ts-storage-t">
      <div class="ts-bar">
        <div><h3 id="ts-storage-t">Cloud backup</h3><p>Same number, human scale.</p></div>
        <mv-segmented id="ts-plan" aria-label="Backup size" value="2.4 TB">
          <button value="250 GB">250 GB</button>
          <button value="2.4 TB">2.4 TB</button>
          <button value="18 TB">18 TB</button>
        </mv-segmented>
      </div>
      <mv-to-scale id="ts-storage" variant="card" label="Photo library backup" value="2.4 TB" visual="dots"></mv-to-scale>
      <p class="ts-note">This month you added <mv-to-scale value="86" unit="GB">86 GB</mv-to-scale> across 3 devices.</p>
    </section>

    <!-- Money: the app supplies the reader's own reference set -->
    <section class="ts-col" aria-labelledby="ts-fund-t">
      <div class="ts-bar">
        <div><h3 id="ts-fund-t">Savings goal</h3><p>Priced in the reader’s city.</p></div>
        <mv-segmented id="ts-city" aria-label="Reader’s city" value="lisbon">
          <button value="lisbon">Lisbon</button>
          <button value="austin">Austin</button>
          <button value="bengaluru">Bengaluru</button>
        </mv-segmented>
      </div>
      <mv-to-scale id="ts-fund" variant="card" label="Emergency fund target" value="9000" unit="USD" visual="bar" audience="lisbon" defaults="false"></mv-to-scale>
    </section>
  </div>

  <!-- Report: every [data-scale] figure in the text gets a gloss; one yardstick per unit family -->
  <section class="ts-report" aria-labelledby="ts-report-t">
    <header class="ts-report-head">
      <div>
        <h3 id="ts-report-t">Q3 2026 impact report <span class="mv-badge" data-variant="secondary" data-size="sm">Audited</span></h3>
        <p>Tidewell Cloud · published Oct 5, 2026</p>
      </div>
      <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="ts-metric"> Metric units</label>
    </header>
    <mv-to-scale id="ts-report" scan visual="dots">
      <p>Our three data centers used <span data-scale>1.8 GWh</span> of electricity this quarter and emitted <span data-scale>412 t CO2e</span>, down from <span data-scale>530 t CO2e</span> in Q3 2025.</p>
      <p>At peak, the storefront handled <span data-scale>21,600 orders/day</span>, and <span data-scale>38%</span> of customers picked carbon-neutral shipping. Delivery vans covered <span data-scale>3.2 million km</span>, and employees volunteered <span data-scale>2,400 hours</span> with local schools.</p>
    </mv-to-scale>
    <div class="ts-foot">
      <p class="ts-log" id="ts-log" aria-live="polite">Hover or tap a gloss to see alternatives and the math.</p>
    </div>
  </section>

  <script type="module">
    const storage = document.getElementById("ts-storage");
    const fund = document.getElementById("ts-fund");
    const report = document.getElementById("ts-report");
    const log = document.getElementById("ts-log");

    document.getElementById("ts-plan").addEventListener("mv-change", (e) => { storage.value = e.detail.value; });

    // The reader's reference set, supplied by the app (demo figures, in USD).
    const src = "Demo reference set, 2026. Replace with your own data.";
    const city = (id, name, rent, groceries, transit) => [
      { id: `${id}-rent`, audience: id, unit: "USD", per: rent, one: `month of rent in ${name}`, other: `months of rent in ${name}`, assumption: `a one-bedroom apartment in central ${name} at about $${rent.toLocaleString("en-US")} a month`, source: src, weight: 1.4 },
      { id: `${id}-groceries`, audience: id, unit: "USD", per: groceries, one: `month of groceries in ${name}`, other: `months of groceries in ${name}`, assumption: `groceries for one person in ${name} at about $${groceries} a month`, source: src },
      { id: `${id}-transit`, audience: id, unit: "USD", per: transit, one: `monthly transit pass in ${name}`, other: `monthly transit passes in ${name}`, assumption: `an unlimited monthly public transit pass in ${name} at about $${transit}`, source: src, weight: 0.8 },
    ];
    fund.comparisons = [
      ...city("lisbon", "Lisbon", 1500, 380, 45),
      ...city("austin", "Austin", 1750, 450, 41),
      ...city("bengaluru", "Bengaluru", 420, 170, 25),
    ];
    document.getElementById("ts-city").addEventListener("mv-change", (e) => { fund.audience = e.detail.value; });

    document.getElementById("ts-metric").addEventListener("change", (e) => {
      report.system = e.target.checked ? "metric" : "imperial";
    });

    document.getElementById("ts-demo").addEventListener("mv-scale-pick", (e) => {
      log.textContent = `mv-scale-pick · ${e.detail.original} → ${e.detail.text}`;
    });
  </script>
</div>

API

Attributes

Name类型DefaultDescription
valuenumber | stringThe quantity. With unit, a machine number (“.” decimal, optional “,” grouping). Without unit, a full quantity string: "2.4 TB", "$9,000", "$4.2M", "1.2 million users", "21,600 orders/day", "38%", "412 t CO2e". Ignored with scan.
unitstringUnit of value: data (B, KB, MB, GB, TB, PB, KiB…GiB), energy (Wh…TWh, J, kJ, MJ, GJ, kcal, BTU, therm), CO₂e (g/kg/t/Mt CO2e, lb CO2e), mass (mg, g, kg, t, lb, oz), time (ms, s, min, h, days, weeks, months, years), distance (mm, cm, m, km, in, ft, yd, mi), people (people, users, customers, visitors, members…), share (%), money (ISO code or $, €, £, ¥, ₹), rates (“orders/day”, “per hour”, “sign-ups per week”).
variantinline | cardinlineinline: the precise value followed by a “≈ 800 hours of HD video” gloss button that opens a popover (alternatives, picture, math). card: a block with label, large precise value, the comparison sentence, alternatives as chips and the math disclosure.
scanbooleanContainer mode: every descendant [data-scale] element (its attribute value, else its text, is the quantity) gets a gloss right after it and shares one popover. The app's text is never rewritten. Added, removed or edited figures are picked up automatically.
visualnone | dots | barnoneTiny proportional picture of the pick, always with a text key: dots (whole things, or round groups of them when many: “Each dot is 100 hours of HD video”), bar (the same as a segmented bar). Shares draw an icon array (2 of 5 filled); rates shown as intervals of 0.3-30 s draw a dot that pulses at that tempo (static under reduced motion).
localeBCP 47 tagclosest [lang], else en-USNumber formatting (grouping, “1.5 million”) and the default measurement system.
systemauto | metric | imperialautoWhich built-in yardsticks apply (miles driven vs kilometers driven, gallons vs liters, US football fields vs soccer pitches). auto: imperial for US, Liberia and Myanmar locales, metric elsewhere.
audiencestring (space or comma separated)Activates the app comparisons tagged with a matching audience (a city, persona, country, customer segment). Audience comparisons get a strong bonus so the reader's own reference points win when they fit.
prefercomparison idsBias the pick toward these ids (in order) whenever they give a sensible figure. data-scale-prefer does the same on one scanned figure.
excludecomparison idsNever use these ids.
alternativesnumber3How many other yardsticks are offered next to the pick (only those scoring close enough to the best).
consistent"true" | "false"trueIn scan mode, figures of the same family (and currency) use one shared yardstick when it fits all of them, so “412 t” and “530 t” read as 7,000 and 9,000 tree seedlings; picking an alternative on one applies it to the others where it fits. "false": each figure gets its own best pick.
defaults"true" | "false"true"false" drops the built-in library: only your comparisons (property, MvToScale.register) are used, e.g. for money in a non-US audience.
subjectstring ("order|orders" or a plural)What is being counted, for rates, shares and people: “one order every 4 seconds”, “2 in 5 customers”. Inferred from the unit (“orders/day”, “users”) when omitted. data-scale-subject on a scanned figure.
labelstringCard variant: caption above the precise value (“Photo library backup”).
data-scale / data-scale-unit / data-scale-subject / data-scale-preferon scanned elementsMarks a figure in scan mode. data-scale holds the quantity (or leave it empty to read the element's text); data-scale-unit gives the unit separately; the others override subject and prefer for that figure. The component sets data-scale-state="ready | none | invalid" on it.
data-state / data-variant / data-familyset by the componentOn the host: ready | none (no comparison fits) | invalid (unknown unit); inline | card | scan; the unit family of the first figure.

Properties

Name类型Description
comparisonsComparison[]App comparisons merged with the built-ins (same id replaces a built-in). Comparison: { id, unit, per (how many units one thing is), one, other (noun labels), phrase? (sentence with {about} and {np}, e.g. "the length of {about}{np}"), assumption? (noun phrase shown as “Assumes …”), source?, href? (http(s) only), weight? (familiarity, default 1), audience?, system?, locales?, min? / max? (sensible count range), fractions? (false forbids “half a …”), exact? (a definition, not an estimate), article? }.
resultResult | nullRead-only: the first figure's result, the same object express() returns: { ok, value, unit, family, currency, original, locale, system, pick, alternatives, options }. pick and each alternative: { id, origin, n (exact count), rounded, count, approx, deviation, np, text, gloss, per, math[], assumption, source, href, visual, score }.
resultsArray<{ element } & Result>Read-only: one result per figure (scan mode), with the scanned element.
stringsPartial<Record<string, string>>Overrides for visible texts, announcements and engine words (keys: exact, pictureAs, math, assumes, source, none, invalid, eachDot, eachSegment, shareKey, tempo, glossHint, lensLabel, announce, announceGroup, announceGroupOne, aboutSr, about, one, rounded, even, arithmetic, every, everyNoun, perUnit, perUnitNoun, inN, inNSubject, events). English defaults.
value / unit / variant / scan / visual / locale / system / audience / prefer / exclude / alternatives / consistent / defaults / subject / labelreflectedMirror the attributes; setting value, unit, audience or system recomputes at once.

Methods

NameDescription
MvToScale.express(value, unit, options)Static and pure (no DOM, SSR-safe; also a named export express). express(2.4, "TB") or express("2.4 TB", null, { locale, system, audience, comparisons, defaults, prefer, exclude, pick, alternatives, subject, strings }). Returns the Result object with the sentence, math, assumption, source and alternatives, ready to render in an email, a PDF or server HTML.
MvToScale.parse(text)Static: "$4.2M" → { value: 4200000, unit: "USD" }; "1.2 million users" → { value: 1200000, unit: "users" }; null when no number is found (also a named export parseQuantity).
MvToScale.register(comparisons)Static: adds comparisons for every instance on the page (same id replaces), then refreshes the live ones.
MvToScale.libraryStatic getter: copies of the built-in comparisons with their family, assumption and source, to document or audit them.
pick(id, target?)Shows comparison id for a figure (scanned element, selector, index; default the first). Emits the cancelable mv-scale-pick with trigger "api". Returns false if vetoed or if the comparison does not fit this value.
open(target?) / close()Opens or closes the popover of an inline or scanned figure.
refresh()Rescans data-scale figures and recomputes (after changing comparisons in place).

Events

NameDescription
mv-scale-pickCancelable, before another yardstick is shown. detail: { element, id, previous, text, n, value, unit, original, family, choice, trigger: "user" | "api" }. preventDefault() keeps the current one; use it to persist a reader's preference.
mv-scale-toggleThe popover opened or closed. detail: { open, element, trigger ("hover" | "click" | "api") when opening }.

Content structure

NameDescription
(content)Standalone: optional text shown as the precise value (“2.4 TB”, also the no-JS fallback); when empty the value is formatted for you. Scan mode: any rich text containing [data-scale] figures.

CSS classes

NameDescription
mv-to-scale-valueThe precise value (standalone), untouched app text when given.
mv-to-scale-glossInline role="button" span after the figure: .mv-to-scale-sign (≈ or =) and .mv-to-scale-gloss-text; aria-expanded while its popover is open.
mv-to-scale-lensThe popover (Popover API, role=dialog), placed right after its gloss in the DOM: .mv-to-scale-exact, then the panel.
mv-to-scale-cardCard variant: .mv-to-scale-head (.mv-to-scale-label, .mv-to-scale-value) and .mv-to-scale-body.
mv-to-scale-say / mv-to-scale-countThe comparison sentence and its highlighted figure.
mv-to-scale-visualPicture: .mv-to-scale-art (aria-hidden) with .mv-to-scale-dot / .mv-to-scale-seg (--_f fill 0..1) or .mv-to-scale-pulse, and the .mv-to-scale-key caption; data-kind count | share | tempo, data-style dots | bar.
mv-to-scale-alts / mv-to-scale-alt“Picture it as” group of toggle buttons (aria-pressed): .mv-to-scale-alt-check, -alt-text, -alt-per (the assumption in short: “3 GB each”).
mv-to-scale-math<details> “How this is calculated”: .mv-to-scale-steps (ordered steps, with the rounding), .mv-to-scale-assume, .mv-to-scale-source.

CSS variables

NameDefaultDescription
--mv-to-scale-accentvar(--mv-accent)Tint of the gloss, dots, bar and selected alternative.
--mv-to-scale-inkaccent mixed with --mv-fgText-safe version of the tint (≈ sign, check marks).

Accessibility

The comparison is always text, never only a picture or a color: the gloss is an inline role="button" (tabindex 0, Enter and Space, a span rather than a <button> so it wraps with the sentence on narrow screens) whose name reads “about 800 hours of HD video” (the ≈ sign is aria-hidden and replaced by “about” for screen readers), described by a hint saying it opens other comparisons and the math, with aria-haspopup="dialog", aria-expanded and aria-controls. The precise value stays in the text next to it and is repeated as “Exact value” in the popover. The popover is a labelled non-modal dialog placed right after its gloss in the DOM, so Tab walks into it naturally; opening it from the keyboard moves focus to the selected alternative, Escape closes it and returns focus to the gloss, and focus leaving it or a click outside closes it. Hover only previews (after a short intent delay, mouse only) and never steals focus; touch and click open it pinned. Alternatives are toggle buttons (aria-pressed) in a labelled group, reachable with Tab or the arrow keys, Home and End; choosing one is announced politely (“Now showing about 600,000 photos. Also applied to 1 other figure.”). The math is a native <details> disclosure with ordered steps, the rounding made explicit, the assumption and the source. Dots and bars are aria-hidden and always come with a visible text key (“Each dot is 100 hours of HD video.”), so nothing depends on color or shape. Reduced motion (OS or data-motion="reduce"): the tempo dot stops pulsing and the popover appears without sliding. Forced colors: glosses use button colors, dots and bars are drawn in CanvasText, the selected alternative gets a Highlight border. Known limits: numbers, currencies and units follow locale, but the built-in yardsticks and the math lines are worded in English; for another language, pass your own comparisons (their nouns) and strings. Built-in figures are rounded averages whose assumption and source are shown, not measurements for the reader.

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