エクスクルーシブ

Mirror Rank <mv-mirror-rank>

順位の理由を正直に説明し、上がるために何が必要かを伝えるランキングです。リーダーボード(売上、コントリビューター)、販売者や検索のランキング、信用、健康、SEO、セキュリティのスコアなど。

文化的リファレンス

白雪姫(Schneewittchen)、グリム兄弟(1812年、童話)。世界で一番美しいのは誰かと問われると、魔法の鏡は常に正直に答え、誰が一番かを告げることで、問いかけた者がもう一番ではない理由を暗に示します。UI では、ランキングがどのエントリーについても同じように正直に答えます。誰がどれだけ先を行っているか、その差が何で構成されているか、どの部分が決して変えられないか、そして差を埋める最小限の具体的な変更は何かを示します。

仕組み

自らを正直に説明し、順位を上げるには何が必要かを示すランキングです。リーダーボード(売上、コントリビューター)、販売者や検索のランキング、信用、健康、SEO、セキュリティのスコアに使えます。要素(重み、範囲、刻み、方向、単位、任意のコスト)と、要素の値を持つエントリーを渡すか、要素ごとの内訳を返す独自の score(values) 関数を渡します。ランキングは、エントリーごとに積み上げ式の寄与バーが付いた順序付きリストとして描画され、選択したエントリー(デフォルトでは閲覧者自身)にはミラーパネルが表示されます。パネルには、要素ごとのスコア内訳が本物の表(値、最大値に対する得点、目標位置を示す目盛り、符号付きの差)で示され、次の順位との差(「2.4 points behind Amara Okafor (#4)」)と、その差を埋める最小限の具体的な変更(「Close 3 more deals」「Bring in $60k more revenue」「Respond 3 h faster」)が表示されます。変更は宣言された各範囲内で要素ごとの変化量を試して算出され、労力の小さい順に並びます。単独の変更では足りない場合は 2 要素の組み合わせや貪欲法による組み合わせが示され、範囲内のどの変更でも足りない場合は率直に「out of reach」と判定されます。提案にホバーまたはフォーカスすると、すべてのバーと差のメーターへの効果がプレビューされ、押すと固定されます。首位の場合は、代わりに 1 位を失うことになる最小の後退が表示されます。短いテキストが「why is #1 first?」に答え(2 位に対する主な優位点、2 位が上回っている点、その差)、honest モードでは誰にも変えられない要素(在籍期間、アカウントの経過年数)に斜線とラベルを付け、差やリードのうちどれだけがそれらに由来するかを示します。目標は次の順位か 1 位から選べ、長いリストは上位 N 件と選択したエントリーの周辺に絞れます。選択はキャンセル可能な mv-select-entry で、算出された各プランは mv-what-it-takes で追跡できます。

カテゴリーデータ表示
タイプWeb Component(<mv-mirror-rank>)
ステータス安定版
キット根拠を自ら語る数値
Keywordsexclusive, culture, leaderboard, ranking, score, score-breakdown, explainable, what-if, gap, next-rank, gamification, seller-rank, search-ranking, credit-score, health-score, transparency, table, list

When to use

  • A leaderboard or seller ranking should tell each person why they are where they are and the smallest step that moves them up
  • A composite score (credit, account health, SEO, security posture) must show its breakdown and the cheapest ways to raise it
  • A marketplace or search ranking needs a transparent “why is my listing here?” view for sellers, including factors they cannot change
  • Gamified progress should motivate with concrete, reachable targets instead of an opaque number

Avoid when

  • People just need to sort and filter many rows by any column, with no scoring model behind the order → use Data table instead
  • Progress is measured against your own previous attempt rather than against other people → use Ghost Run instead
  • The ranking model is secret or adversarial (fraud, spam scores): explaining it would teach people to game it

インストール

node scripts/add.mjs mirror-rank --out ./src/marvelous

Marvelous UI MCP サーバーを使う AI エージェント:install_components({ slugs: ["mirror-rank"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。

コピーされるファイル(依存関係を含む):tokens/tokens.css、core/base.css、core/dom.js、core/element.js、core/focus.js、core/motion.js、components/mirror-rank/mirror-rank.js、components/mirror-rank/mirror-rank.css。

使い方

クイックスタート、動作する最小限のマークアップ:

<mv-mirror-rank you="aiko" label="Q3 sales leaderboard">
  <script type="application/json">{
    "factors": [{ "key": "deals", "label": "Deals won", "min": 0, "max": 30 }, { "key": "csat", "label": "CSAT", "min": 60, "max": 100, "unit": "%" }],
    "entries": [{ "id": "priya", "name": "Priya Raman", "values": { "deals": 22, "csat": 94 } }, { "id": "aiko", "name": "Aiko Tanaka", "values": { "deals": 20, "csat": 92 } }, { "id": "mei", "name": "Mei Chen", "values": { "deals": 14, "csat": 87 } }]
  }</script>
</mv-mirror-rank>

基本となるマークアップです。ここから属性、data-*、CSS 変数でカスタマイズしてください:

<div id="mr-demo" style="width:min(100%,76rem);margin-inline:auto">
  <style>
    #mr-demo .mr-top { display:flex; align-items:flex-end; justify-content:space-between; gap:.75rem 1.5rem; flex-wrap:wrap; margin:0 0 1rem }
    #mr-demo .mr-top h3 { margin:0; font-size:1.125rem; letter-spacing:-.01em }
    #mr-demo .mr-top p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
    #mr-demo .mr-controls { display:flex; align-items:center; gap:.75rem 1rem; flex-wrap:wrap }
    #mr-demo .mr-controls .mv-choice { gap:.625rem; font-size:.8125rem }
    #mr-demo .mr-foot { display:flex; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap; margin:.875rem 0 0; color:var(--mv-fg-muted); font-size:.75rem }
    #mr-demo .mr-foot code { font-family:var(--mv-font-mono); font-size:.6875rem; color:var(--mv-fg) }
  </style>

  <div class="mr-top">
    <div>
      <h3 id="mr-title">Q3 sales leaderboard</h3>
      <p id="mr-sub">Account executives · updated Sep 24, 2026</p>
    </div>
    <div class="mr-controls">
      <mv-segmented id="mr-scenario" value="sales" aria-label="Scenario">
        <button value="sales">Sales team</button>
        <button value="search">Search ranking</button>
      </mv-segmented>
      <label class="mv-choice" data-control="end">
        <input type="checkbox" role="switch" class="mv-switch" id="mr-honest" checked>
        <span class="mv-choice-text"><span class="mv-choice-title">Honest mode</span></span>
      </label>
      <button class="mv-button" data-variant="outline" data-size="sm" type="button" id="mr-week">Simulate next week</button>
    </div>
  </div>

  <mv-mirror-rank id="mr-rank" you="aiko" honest label="Q3 sales leaderboard">
    <script type="application/json">
      {
        "factors": [
          { "key": "deals", "label": "Deals won", "weight": 30, "min": 0, "max": 30, "step": 1, "unit": ["deal", "deals"], "improve": "Close {n} more {unit}", "slip": "{n} fewer {unit} won" },
          { "key": "revenue", "label": "Revenue closed", "weight": 25, "min": 0, "max": 600, "step": 10, "prefix": "$", "unit": "k", "improve": "Bring in {value} more revenue", "slip": "Revenue {value} lower" },
          { "key": "response", "label": "Lead response", "weight": 20, "better": "lower", "min": 1, "max": 24, "step": 0.5, "unit": "h", "improve": "Respond {value} faster", "slip": "Responses {value} slower" },
          { "key": "csat", "label": "CSAT", "weight": 15, "min": 60, "max": 100, "step": 1, "unit": "%", "improve": "Lift CSAT by {n} points", "slip": "CSAT {n} points lower" },
          { "key": "tenure", "label": "Tenure", "weight": 10, "min": 0, "max": 60, "step": 1, "unit": "mo", "fixed": "Accrues with time on the team" }
        ],
        "entries": [
          { "id": "priya", "name": "Priya Raman", "meta": "EMEA · Enterprise", "values": { "deals": 22, "revenue": 480, "response": 2, "csat": 94, "tenure": 38 } },
          { "id": "marcus", "name": "Marcus Olsen", "meta": "Nordics · Mid-market", "values": { "deals": 24, "revenue": 410, "response": 3.5, "csat": 88, "tenure": 52 } },
          { "id": "amara", "name": "Amara Okafor", "meta": "West Africa · SMB", "values": { "deals": 19, "revenue": 360, "response": 1.5, "csat": 96, "tenure": 20 } },
          { "id": "diego", "name": "Diego Fernández", "meta": "LATAM · Enterprise", "values": { "deals": 17, "revenue": 450, "response": 4, "csat": 90, "tenure": 44 } },
          { "id": "aiko", "name": "Aiko Tanaka", "meta": "APAC · Mid-market", "values": { "deals": 20, "revenue": 400, "response": 4.5, "csat": 92, "tenure": 14 } },
          { "id": "lucas", "name": "Lucas Moreau", "meta": "France · SMB", "values": { "deals": 16, "revenue": 300, "response": 2.5, "csat": 89, "tenure": 30 } },
          { "id": "sofia", "name": "Sofia Rossi", "meta": "Southern Europe · Mid-market", "values": { "deals": 15, "revenue": 340, "response": 5, "csat": 93, "tenure": 26 } },
          { "id": "jonas", "name": "Jonas Weber", "meta": "DACH · Enterprise", "values": { "deals": 13, "revenue": 390, "response": 6.5, "csat": 85, "tenure": 48 } },
          { "id": "mei", "name": "Mei Chen", "meta": "Greater China · SMB", "values": { "deals": 14, "revenue": 260, "response": 2, "csat": 87, "tenure": 9 } },
          { "id": "omar", "name": "Omar Haddad", "meta": "Middle East · Mid-market", "values": { "deals": 11, "revenue": 280, "response": 7.5, "csat": 84, "tenure": 16 } }
        ]
      }
    </script>
  </mv-mirror-rank>

  <div class="mr-foot">
    <span>Pick anyone in the list to see their mirror · hover a suggestion to preview it, press it to pin</span>
    <span id="mr-event" aria-hidden="true"></span>
  </div>

  <script type="module">
    const rank = document.getElementById("mr-rank");
    await customElements.whenDefined("mv-mirror-rank");
    const $ = (id) => document.getElementById(id);
    const clone = (x) => JSON.parse(JSON.stringify(x));

    const sales = { factors: clone(rank.factors), entries: clone(rank.entries) };
    // Next week: Aiko closes three deals and answers leads faster; the others move a little.
    const nextWeek = sales.entries.map((e) => {
      const bump = { aiko: { deals: 3, response: -1.5, revenue: 40 }, amara: { deals: 1 }, diego: { revenue: 20 }, marcus: { deals: 1, csat: 1 }, lucas: { deals: 2 }, mei: { deals: 1 } }[e.id] ?? {};
      const values = { ...e.values };
      for (const [k, d] of Object.entries(bump)) values[k] = +(values[k] + d).toFixed(1);
      return { ...e, values };
    });

    // Search scenario: the marketplace's own (non-linear) relevance score, explained.
    const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
    const search = {
      factors: [
        { key: "match", label: "Query match", min: 0, max: 100, step: 1, unit: "%", maxPoints: 30, improve: "Raise query match by {n} points" },
        { key: "rating", label: "Rating", min: 1, max: 5, step: 0.1, decimals: 1, maxPoints: 25, improve: "Lift the rating by {n}", format: (v) => `${v.toFixed(1)} / 5` },
        { key: "reviews", label: "Reviews", min: 0, max: 5000, step: 10, unit: ["review", "reviews"], maxPoints: 15, improve: "Collect {n} more {unit}" },
        { key: "price", label: "Price vs median", better: "lower", min: -30, max: 30, step: 1, maxPoints: 12, improve: "Cut the price by {n}%", format: (v) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${Math.abs(v)}%` },
        { key: "shipping", label: "Delivery time", better: "lower", min: 1, max: 7, step: 1, unit: ["day", "days"], maxPoints: 10, improve: "Deliver {value} sooner" },
        { key: "history", label: "Seller history", min: 0, max: 48, step: 1, unit: "mo", maxPoints: 8, fixed: "Account age, grows on its own" },
      ],
      score: (v) => {
        const parts = {
          match: 30 * clamp(v.match / 100, 0, 1),
          rating: 25 * clamp((v.rating - 3.5) / 1.5, 0, 1),
          reviews: 15 * Math.min(1, Math.log10(1 + Math.max(0, v.reviews)) / Math.log10(5001)),
          price: 12 * clamp((30 - v.price) / 60, 0, 1),
          shipping: 10 * clamp((7 - v.shipping) / 6, 0, 1),
          history: 8 * Math.min(1, v.history / 48),
        };
        return { total: Object.values(parts).reduce((a, b) => a + b, 0), parts };
      },
      entries: [
        ["harrowfield", "Harrowfield Audio", "Pulse ANC Buds · $119.00", 95, 4.7, 3200, -5, 2, 60],
        ["kestrel", "Kestrel Sound", "Glide Earbuds 2 · $99.00", 90, 4.6, 1800, 0, 1, 40],
        ["quill", "Quill & Co.", "Halo Mini · $129.00", 88, 4.8, 950, 6, 2, 36],
        ["harbor", "Harbor Tech", "WaveFit Pro · $89.00", 92, 4.4, 2600, -10, 3, 52],
        ["aurelia", "Aurelia Goods", "Calm Buds · $94.00", 85, 4.5, 1400, -4, 2, 30],
        ["juniper", "Juniper Direct", "AirLite Pro · $79.99", 80, 4.6, 420, -8, 3, 14],
        ["pine", "Pine Street Electronics", "Tempo X · $104.00", 86, 4.3, 2100, 4, 2, 48],
        ["mistral", "Mistral Supply", "Breeze Buds · $74.00", 78, 4.5, 700, -12, 4, 26],
        ["quokka", "Quokka Gear", "Hop Buds · $97.00", 82, 4.2, 1100, 2, 2, 20],
        ["sable", "Sable Audio Lab", "Studio Pods · $139.00", 75, 4.7, 300, 10, 3, 18],
        ["tidewater", "Tidewater Outlet", "Surf Buds · $64.00", 70, 4.1, 1600, -15, 5, 44],
        ["orbit", "Orbit Electronics", "Nova Lite · $84.00", 72, 4.0, 500, -2, 4, 10],
      ].map(([id, name, meta, match, rating, reviews, price, shipping, history]) => ({ id, name, meta, values: { match, rating, reviews, price, shipping, history } })),
    };

    let week = 0;
    const show = (scenario) => {
      const isSales = scenario === "sales";
      rank.score = isSales ? null : search.score;
      rank.factors = isSales ? sales.factors : search.factors;
      rank.entries = isSales ? (week ? nextWeek : sales.entries) : search.entries;
      rank.you = isSales ? "aiko" : "juniper";
      rank.selected = null;
      rank.target = "next";
      rank.limit = isSales ? 0 : 4;
      rank.label = isSales ? "Q3 sales leaderboard" : "Search results for wireless earbuds";
      $("mr-title").textContent = isSales ? "Q3 sales leaderboard" : "Search ranking · “wireless earbuds”";
      $("mr-sub").textContent = isSales ? "Account executives · updated Sep 24, 2026" : "Seller dashboard · why your listing sits where it does";
      $("mr-week").hidden = !isSales;
    };

    $("mr-scenario").addEventListener("mv-change", (e) => { week = 0; $("mr-week").textContent = "Simulate next week"; show(e.detail.value); });
    $("mr-honest").addEventListener("change", (e) => { rank.honest = e.target.checked; });
    $("mr-week").addEventListener("click", (e) => {
      week = week ? 0 : 1;
      e.currentTarget.textContent = week ? "Back to this week" : "Simulate next week";
      rank.entries = week ? nextWeek : sales.entries;
    });

    const log = (a) => {
      const t = a.target ? ` · target ${a.target.name}` : "";
      $("mr-event").replaceChildren(Object.assign(document.createElement("code"), { textContent: "mv-what-it-takes" }), ` ${a.suggestions.length} suggestion${a.suggestions.length === 1 ? "" : "s"}${t}`);
    };
    rank.addEventListener("mv-what-it-takes", (e) => log(e.detail));
    if (rank.analysis) log(rank.analysis);
  </script>
</div>

API

Attributes

NameタイプDefaultDescription
youstring (entry id)The viewer's entry: tagged “You” in the list and panel, selected by default, always kept visible when limit hides rows. An entry with you: true works too.
selectedstring (entry id)Entry shown in the mirror panel. Defaults to you, then to #1. Reflects user selections.
target"next" | "first" | numbernextRank to aim for: the next rank up, first place, or a given rank (the lowest-scoring entry at or above it). The panel offers a Next rank / First place toggle when the two differ.
honestbooleanNames what can't be changed: fixed factors are hatched in every bar, labeled “Can't change” with their reason in the breakdown, and the panel and the “why #1” text say how many points of a gap or a lead come from them. Fixed factors are never suggested, with or without it.
limitnumber0Show only the top N rows plus the selected entry, its neighbors and the viewer; hidden runs collapse into “Show 12 more” buttons. 0 shows everything.
max-suggestionsnumber3How many suggestions (or slips for the leader) the panel lists.
precisionnumber (0-4)1Decimals of scores and points.
scalenumber100Built-in scoring: the score of an entry that is best on every factor. Weights are shares of it.
labelstringRankingAccessible name of the ranking list.
localeBCP 47 tagNumber and list formatting. Defaults to the closest lang attribute, then en-US.
heading-level1-63aria-level of the panel, suggestions and “why #1” headings.

Properties

NameタイプDescription
factorsFactor[]{ key, label, weight (default 1), better: "higher" | "lower", min, max (normalization bounds, default: the data's), range: [lo, hi] (values reachable in a what-if, default [min, max]), step (smallest meaningful change), maxChange, cost (effort of one unit of change; default 1 / range width, so crossing the whole range costs 1), unit ("h", "%", ["deal", "deals"]; %, °, ×, k, M, B attach without a space), prefix ("$"), decimals, format(value), fixed (true or the reason, e.g. "Accrues with time on the team"), improve / slip (templates with {n}, {unit}, {value}, {label}, {from}, {to}), color, maxPoints (custom score: a factor's maximum points) }. Also read from a <script type="application/json"> child ({ factors, entries }).
entriesEntry[]{ id, name, meta (subtitle), avatar (image URL), you, values: { [factorKey]: number } } (values may also sit directly on the entry). Replace the array (or call refresh()) to update: rows that move slide to their new place.
score(values, entry) => number | { total, parts }Your own scoring instead of the built-in weighted, normalized sum. Return parts ({ [factorKey]: points }) to get the breakdown and “why #1” detail. Suggestions call it with modified values and assume it never drops when a factor gets better (binary search per factor).
stringsPartial<Record<string, string>>Overrides for every visible and announced text (en-US defaults with {placeholders}), e.g. behind, takesTitle, locked, whyLead, improveUp, announceSelect.
ranking{ id, name, rank, score, parts, entry }[]Read-only, best first; ties share a rank.
analysisobjectRead-only: the plan shown in the panel, same shape as the mv-what-it-takes detail.
selected / you / target / honest / limit / maxSuggestions / precision / scale / label / locale / headingLevelreflectedMirror the attributes.

Methods

NameDescription
select(id)Shows an entry in the panel without emitting mv-select-entry. Returns false for an unknown id.
whatItTakes(id?, { target? })Computes the plan for any entry and target without showing it. Returns the mv-what-it-takes detail shape.
explainLeader()Returns the “why is #1 first?” text.
refresh()Recomputes after entries or factors were mutated in place.

Events

NameDescription
mv-select-entryThe user picked an entry (click, Enter or Space). detail: { id, entry, rank, score, previous }. Cancelable: preventDefault() keeps the current selection (e.g. other people's breakdowns are private).
mv-what-it-takesA new plan was computed for the panel (selection, data, target or honest mode changed). detail: { id, entry, rank, score, mode: "climb" | "lead" | "alone", target: { id, name, rank, score } | null, tie, gap, reachable, bestScore, locked: { points, factors } | null, suggestions: [{ kind: "single" | "combo" | "slip", text, score, rank, gain, cost, changes: [{ key, label, from, to, delta, text }] }] }.
mv-previewA suggestion is previewed (hover, keyboard focus) or pinned (press), or the preview ended. detail: { suggestion | null, pinned }.

Content structure

NameDescription
script[type="application/json"]Optional declarative data: { "factors": [...], "entries": [...] }. Properties set from code win.

CSS classes

NameDescription
mv-mirror-rank-board / -list / -row / -entryThe ranking. Rows carry data-rank, data-selected and data-you; the entry button has aria-current="true" when shown in the panel.
mv-mirror-rank-stack / -segStacked contribution bar of a row, one segment per factor (data-fixed when hatched).
mv-mirror-rank-why“Why is #1 first?” block under the list.
mv-mirror-rank-panelThe mirror panel (data-mode climb | lead | alone, data-preview while a suggestion is previewed).
mv-mirror-rank-meterGap meter: score fill, hatched stretch to the target, target mark, preview ghost.
mv-mirror-rank-suggestionA suggestion button (aria-pressed when its preview is pinned, data-previewing while previewed).
mv-mirror-rank-breakdownThe breakdown table; rows carry data-factor, data-fixed and data-changed during a preview.

CSS variables

NameDefaultDescription
--mv-mirror-rank-colorvar(--mv-accent)Tint of the factor shades, selection, meter and toggles.
--mv-mirror-rank-upvar(--mv-success)Previewed gains, positive differences and rank-up badges.
--mv-mirror-rank-downvar(--mv-danger)Previewed losses (leader slips) and negative differences.

Accessibility

The ranking is a real ordered list (role="list" kept despite list-style: none) named by label; each entry is a button whose name reads the rank, name, “You”, subtitle and score in order (“Rank 5 of 10: Aiko Tanaka, You, APAC · Mid-market, 68.0 pts”), with aria-current="true" on the one shown in the panel and aria-controls pointing to it. The list is one tab stop with a roving tabindex: Up/Down, Home and End move, Enter or Space selects (manual activation, so arrowing through never floods announcements). Selecting announces a polite summary: rank, score, gap and the quickest way up (or the closest risk for the leader). The panel is a labelled region with real headings (level set by heading-level); the breakdown is a <table> with a caption, column and row headers, and every bar is decorative (aria-hidden) because each row states its value, points out of the maximum and signed difference as text. Suggestions are a list of buttons (aria-pressed when pinned, described by a hint); the before/after values are written out (“Lead response 4.5 h to 1.5 h”) and the new rank is spoken. Nothing relies on color: fixed factors are hatched and labeled “Can't change” with their reason, differences carry +/− signs, the target is a tick plus “#4” text, gains and losses are striped. The Aim for toggle is a group of aria-pressed buttons; Escape clears a preview; focus is kept on the same control across re-renders. Reduced motion (OS or data-motion="reduce"): rows jump to their new place and bars change instantly. Forced colors: bars and fills switch to CanvasText/Highlight with outlines, the selection and pressed controls get Highlight outlines.

このページは AI で翻訳されています。翻訳の問題を報告