Pythonデータ集計ダッシュボード

Python標準のstatisticsでランダム売上データを集計し、合計・平均・中央値・標準偏差をカード表示しつつバーチャート化。データ分析UIの雛形に使える。

外部ライブラリ: https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.js

#pyodide#data#statistics#chart

ライブデモ

使用例(お題: SaaS FlowDesk)

この技法を「SaaS FlowDesk」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- FlowDesk:月次レポート。Python statisticsでチーム別工数を集計 -->
<section class="fd-report" aria-label="FlowDesk 月次レポート">
  <header class="fd-report__head">
    <div>
      <span class="fd-report__brand"><b>Flow</b>Desk</span>
      <h1 class="fd-report__title">月次アクティビティ・レポート</h1>
      <p class="fd-report__sub">チーム別の完了タスク数を集計</p>
    </div>
    <button id="reroll" class="fd-report__btn" disabled>別の月を表示</button>
  </header>

  <div class="fd-report__grid">
    <!-- 左:チーム別バー -->
    <div class="fd-panel">
      <h2 class="fd-panel__h">チーム別 完了タスク</h2>
      <ul class="fd-bars" id="bars" aria-label="チーム別バーチャート"></ul>
    </div>

    <!-- 右:統計KPI -->
    <div class="fd-panel fd-panel--kpi">
      <h2 class="fd-panel__h">サマリー</h2>
      <dl class="fd-stats" id="stats">
        <div class="fd-stats__row"><dt>合計</dt><dd id="s-sum">—</dd></div>
        <div class="fd-stats__row"><dt>平均/チーム</dt><dd id="s-mean">—</dd></div>
        <div class="fd-stats__row"><dt>中央値</dt><dd id="s-median">—</dd></div>
        <div class="fd-stats__row"><dt>ばらつき(σ)</dt><dd id="s-std">—</dd></div>
        <div class="fd-stats__row fd-stats__row--top"><dt>MVPチーム</dt><dd id="s-top">—</dd></div>
      </dl>
    </div>
  </div>

  <footer class="fd-report__foot" id="foot" data-state="boot">集計エンジン起動中…</footer>
</section>
CSS
/* FlowDesk:月次アクティビティ・レポート */
:root {
  --navy: #0f1b34;
  --navy2: #16264a;
  --blue: #4f7cff;
  --ink: #2c3650;
  --mut: #8390ad;
  --line: #e6eaf3;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  height: 400px;
  display: grid;
  place-items: center;
  background: #eef2fa;
  font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
  color: var(--ink);
  overflow: hidden;
}

.fd-report {
  width: min(600px, 95vw);
  padding: 18px 22px;
  border-radius: 16px;
  background: #fff;
  box-shadow: 0 18px 44px rgba(15, 27, 52, 0.12);
}

/* ヘッダ */
.fd-report__head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  margin-bottom: 14px;
}
.fd-report__brand { font-size: 12px; color: var(--navy); letter-spacing: 0.02em; }
.fd-report__brand b { color: var(--blue); }
.fd-report__title { margin: 4px 0 2px; font-size: 18px; font-weight: 800; color: var(--navy); }
.fd-report__sub { margin: 0; font-size: 11.5px; color: var(--mut); }
.fd-report__btn {
  font: inherit;
  font-size: 12px;
  font-weight: 700;
  padding: 8px 15px;
  border: 1px solid var(--blue);
  border-radius: 9px;
  cursor: pointer;
  color: var(--blue);
  background: #fff;
  transition: background 0.2s ease, color 0.2s ease;
}
.fd-report__btn:disabled { opacity: 0.5; cursor: default; }
.fd-report__btn:not(:disabled):hover { background: var(--blue); color: #fff; }

.fd-report__grid { display: grid; grid-template-columns: 1.3fr 1fr; gap: 16px; }

.fd-panel {
  padding: 14px 16px;
  border-radius: 12px;
  background: #f6f8fd;
  border: 1px solid var(--line);
}
.fd-panel__h { margin: 0 0 12px; font-size: 12px; font-weight: 700; color: var(--mut); letter-spacing: 0.04em; }

/* バーチャート */
.fd-bars { list-style: none; margin: 0; padding: 0; display: grid; gap: 11px; }
.fd-bar__top { display: flex; justify-content: space-between; margin-bottom: 5px; }
.fd-bar__name { font-size: 12px; font-weight: 600; color: var(--ink); }
.fd-bar__val { font-size: 11.5px; font-weight: 700; color: var(--blue); font-variant-numeric: tabular-nums; }
.fd-bar__track { height: 8px; border-radius: 99px; background: #e3e9f5; overflow: hidden; }
.fd-bar__fill {
  height: 100%;
  width: 0;
  border-radius: 99px;
  background: linear-gradient(90deg, #6f93ff, var(--blue));
  transition: width 0.7s cubic-bezier(0.22, 1, 0.36, 1);
}

/* KPI */
.fd-stats { margin: 0; }
.fd-stats__row {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  padding: 8px 0;
  border-bottom: 1px solid var(--line);
}
.fd-stats__row:last-child { border-bottom: none; }
.fd-stats dt { font-size: 12px; color: var(--mut); }
.fd-stats dd { margin: 0; font-size: 15px; font-weight: 800; color: var(--navy); font-variant-numeric: tabular-nums; }
.fd-stats__row--top dd { font-size: 13px; color: var(--blue); }

/* フッタ */
.fd-report__foot {
  margin-top: 14px;
  font-size: 11px;
  color: var(--mut);
  text-align: right;
}
.fd-report__foot[data-state="ready"]::before { content: "● "; color: #2ecc90; }
.fd-report__foot[data-state="ready"] { color: #4f6088; }
JavaScript
// 要素取得(null安全)
const $ = (id) => document.getElementById(id);
const barsEl = $("bars"), foot = $("foot"), reroll = $("reroll");
const out = {
  sum: $("s-sum"), mean: $("s-mean"), median: $("s-median"),
  std: $("s-std"), top: $("s-top")
};

if (barsEl && foot && reroll && Object.values(out).every(Boolean)) {
  let pyodide = null;

  // Pythonでチーム別の完了タスク数を生成し statistics で集計
  function aggregate() {
    return pyodide.runPython(`
import random, statistics, json
teams = ["開発", "デザイン", "営業", "CS", "経営企画"]
data = {t: sum(random.randint(2, 9) for _ in range(random.randint(6, 16))) for t in teams}
values = list(data.values())
top = max(data, key=data.get)
result = {
    "rows": [{"name": k, "val": v} for k, v in sorted(data.items(), key=lambda kv: -kv[1])],
    "sum": sum(values),
    "mean": round(statistics.mean(values), 1),
    "median": round(statistics.median(values), 1),
    "std": round(statistics.pstdev(values), 1),
    "top": top,
}
json.dumps(result)
`);
  }

  // 結果をDOMへ反映
  function update() {
    const res = JSON.parse(aggregate());
    const max = Math.max(...res.rows.map((r) => r.val), 1);

    barsEl.replaceChildren();
    res.rows.forEach((r, i) => {
      const li = document.createElement("li");
      li.className = "fd-bar";
      li.innerHTML = `
        <div class="fd-bar__top">
          <span class="fd-bar__name">${r.name}</span>
          <span class="fd-bar__val">${r.val} 件</span>
        </div>
        <div class="fd-bar__track"><div class="fd-bar__fill"></div></div>`;
      barsEl.append(li);
      const fill = li.querySelector(".fd-bar__fill");
      // 次フレームで幅を入れてトランジション
      requestAnimationFrame(() => {
        setTimeout(() => { fill.style.width = (r.val / max * 100) + "%"; }, i * 70);
      });
    });

    out.sum.textContent    = res.sum + " 件";
    out.mean.textContent   = res.mean.toLocaleString();
    out.median.textContent = res.median.toLocaleString();
    out.std.textContent    = res.std.toLocaleString();
    out.top.textContent    = res.top;
  }

  reroll.addEventListener("click", update);

  // Pyodide起動
  (async () => {
    try {
      const mod = await import("https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.mjs");
      pyodide = await mod.loadPyodide();
      foot.dataset.state = "ready";
      foot.textContent = "集計: Python statistics / 更新 2026-06";
      reroll.disabled = false;
      update();
    } catch (e) {
      foot.textContent = "レポートを読み込めませんでした";
    }
  })();
}

コード

HTML
<!-- Pythonの標準ライブラリでデータ集計し表+バーで表示するデモ -->
<main class="board" aria-label="Pythonデータ集計">
  <header class="board__head">
    <div>
      <h1 class="board__title">売上集計ダッシュボード</h1>
      <p class="board__desc">CSV風データを Python (statistics) で集計</p>
    </div>
    <button id="reroll" class="board__btn" disabled>データ再生成</button>
  </header>

  <section class="board__grid">
    <!-- カテゴリ別バーチャート -->
    <div class="card">
      <h2 class="card__h">カテゴリ別 売上合計</h2>
      <ul class="bars" id="bars" aria-label="バーチャート"></ul>
    </div>

    <!-- 統計サマリー -->
    <div class="card card--stats">
      <h2 class="card__h">統計サマリー</h2>
      <dl class="stats" id="stats">
        <div class="stats__row"><dt>合計</dt><dd id="s-sum">—</dd></div>
        <div class="stats__row"><dt>平均</dt><dd id="s-mean">—</dd></div>
        <div class="stats__row"><dt>中央値</dt><dd id="s-median">—</dd></div>
        <div class="stats__row"><dt>標準偏差</dt><dd id="s-std">—</dd></div>
        <div class="stats__row"><dt>最大カテゴリ</dt><dd id="s-top">—</dd></div>
      </dl>
    </div>
  </section>

  <footer class="board__foot" id="foot" data-state="boot">Pyodide 起動中…</footer>
</main>
CSS
:root {
  --bg: #0f1117;
  --card: #181b25;
  --card-2: #1d2130;
  --ink: #eef1f8;
  --muted: #9aa3b8;
  --accent: #6ee7b7;
  --accent-2: #60a5fa;
  --line: #262b3a;
  --sans: system-ui, "Segoe UI", "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 14px;
  font-family: var(--sans);
  color: var(--ink);
  background:
    radial-gradient(700px 300px at 100% 0%, #15233a 0%, transparent 60%),
    var(--bg);
}

.board {
  width: min(100%, 660px);
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 16px;
  padding: 18px;
  box-shadow: 0 26px 60px -26px rgba(0,0,0,.75);
}

.board__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
.board__title { margin: 0; font-size: 17px; font-weight: 800; letter-spacing: .01em; }
.board__desc  { margin: 4px 0 0; font-size: 11px; color: var(--muted); }

.board__btn {
  flex: none;
  font: inherit; font-size: 12px; font-weight: 600;
  color: #07120c;
  background: linear-gradient(180deg, #8df0c4, var(--accent));
  border: none; padding: 8px 16px; border-radius: 10px; cursor: pointer;
  transition: transform .12s ease, box-shadow .12s ease;
}
.board__btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 8px 18px -6px rgba(110,231,183,.5); }
.board__btn:disabled { opacity: .5; cursor: not-allowed; }

.board__grid {
  display: grid;
  grid-template-columns: 1.4fr 1fr;
  gap: 14px;
}
@media (max-width: 540px) { .board__grid { grid-template-columns: 1fr; } }

.card {
  background: var(--card-2);
  border: 1px solid var(--line);
  border-radius: 12px;
  padding: 14px;
}
.card__h { margin: 0 0 12px; font-size: 12px; color: var(--muted); font-weight: 600; letter-spacing: .04em; text-transform: uppercase; }

/* バーチャート */
.bars { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.bar { font-size: 12px; }
.bar__top { display: flex; justify-content: space-between; margin-bottom: 4px; }
.bar__name { color: var(--ink); }
.bar__val  { color: var(--accent); font-variant-numeric: tabular-nums; }
.bar__track { height: 8px; background: #11141d; border-radius: 6px; overflow: hidden; }
.bar__fill {
  height: 100%;
  width: 0;
  border-radius: 6px;
  background: linear-gradient(90deg, var(--accent-2), var(--accent));
  transition: width .7s cubic-bezier(.22,1,.36,1);
}

/* 統計リスト */
.stats { margin: 0; }
.stats__row { display: flex; justify-content: space-between; align-items: baseline; padding: 9px 0; border-bottom: 1px dashed var(--line); }
.stats__row:last-child { border-bottom: none; }
.stats dt { font-size: 12px; color: var(--muted); }
.stats dd { margin: 0; font-size: 15px; font-weight: 700; color: var(--ink); font-variant-numeric: tabular-nums; }
.card--stats dd#s-top { font-size: 13px; color: var(--accent); }

.board__foot {
  margin-top: 14px;
  font-size: 10px;
  text-align: center;
  padding: 6px;
  border-radius: 8px;
}
.board__foot[data-state="boot"]  { color: #ffd166; background: rgba(255,209,102,.08); }
.board__foot[data-state="ready"] { color: var(--accent); background: rgba(110,231,183,.08); }

@media (prefers-reduced-motion: reduce) {
  .bar__fill { transition: none; }
  .board__btn { transition: none; }
}
JavaScript
// 要素取得(null安全)
const $ = (id) => document.getElementById(id);
const barsEl = $("bars"), foot = $("foot"), reroll = $("reroll");
const out = {
  sum: $("s-sum"), mean: $("s-mean"), median: $("s-median"),
  std: $("s-std"), top: $("s-top")
};

if (barsEl && foot && reroll && Object.values(out).every(Boolean)) {
  let pyodide = null;

  // Pythonでランダムな売上データを生成し集計(statisticsモジュール使用)
  function aggregate() {
    return pyodide.runPython(`
import random, statistics, json
cats = ["家電", "書籍", "食品", "衣料", "玩具"]
data = {c: sum(random.randint(20, 240) for _ in range(random.randint(8, 20))) for c in cats}
values = list(data.values())
top = max(data, key=data.get)
result = {
    "rows": [{"name": k, "val": v} for k, v in sorted(data.items(), key=lambda kv: -kv[1])],
    "sum": sum(values),
    "mean": round(statistics.mean(values), 1),
    "median": round(statistics.median(values), 1),
    "std": round(statistics.pstdev(values), 1),
    "top": top,
}
json.dumps(result)
`);
  }

  // 結果をDOMへ反映
  function update() {
    const res = JSON.parse(aggregate());
    const max = Math.max(...res.rows.map((r) => r.val), 1);

    // バー再構築
    barsEl.replaceChildren();
    res.rows.forEach((r, i) => {
      const li = document.createElement("li");
      li.className = "bar";
      li.innerHTML = `
        <div class="bar__top">
          <span class="bar__name">${r.name}</span>
          <span class="bar__val">¥${r.val.toLocaleString()}</span>
        </div>
        <div class="bar__track"><div class="bar__fill"></div></div>`;
      barsEl.append(li);
      const fill = li.querySelector(".bar__fill");
      // 次フレームで幅を入れてトランジションを効かせる
      requestAnimationFrame(() => {
        setTimeout(() => { fill.style.width = (r.val / max * 100) + "%"; }, i * 70);
      });
    });

    out.sum.textContent    = "¥" + res.sum.toLocaleString();
    out.mean.textContent   = res.mean.toLocaleString();
    out.median.textContent = res.median.toLocaleString();
    out.std.textContent    = res.std.toLocaleString();
    out.top.textContent    = res.top;
  }

  reroll.addEventListener("click", update);

  // Pyodide起動
  (async () => {
    try {
      const mod = await import("https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.mjs");
      pyodide = await mod.loadPyodide();
      foot.dataset.state = "ready";
      foot.textContent = "集計エンジン: Python statistics";
      reroll.disabled = false;
      update();
    } catch (e) {
      foot.textContent = "読込失敗: " + e.message;
    }
  })();
}

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「Pythonデータ集計ダッシュボード」の効果を追加してください。

# 追加してほしい効果
Pythonデータ集計ダッシュボード(Python (Pyodideブラウザ実行))
Python標準のstatisticsでランダム売上データを集計し、合計・平均・中央値・標準偏差をカード表示しつつバーチャート化。データ分析UIの雛形に使える。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- Pythonの標準ライブラリでデータ集計し表+バーで表示するデモ -->
<main class="board" aria-label="Pythonデータ集計">
  <header class="board__head">
    <div>
      <h1 class="board__title">売上集計ダッシュボード</h1>
      <p class="board__desc">CSV風データを Python (statistics) で集計</p>
    </div>
    <button id="reroll" class="board__btn" disabled>データ再生成</button>
  </header>

  <section class="board__grid">
    <!-- カテゴリ別バーチャート -->
    <div class="card">
      <h2 class="card__h">カテゴリ別 売上合計</h2>
      <ul class="bars" id="bars" aria-label="バーチャート"></ul>
    </div>

    <!-- 統計サマリー -->
    <div class="card card--stats">
      <h2 class="card__h">統計サマリー</h2>
      <dl class="stats" id="stats">
        <div class="stats__row"><dt>合計</dt><dd id="s-sum">—</dd></div>
        <div class="stats__row"><dt>平均</dt><dd id="s-mean">—</dd></div>
        <div class="stats__row"><dt>中央値</dt><dd id="s-median">—</dd></div>
        <div class="stats__row"><dt>標準偏差</dt><dd id="s-std">—</dd></div>
        <div class="stats__row"><dt>最大カテゴリ</dt><dd id="s-top">—</dd></div>
      </dl>
    </div>
  </section>

  <footer class="board__foot" id="foot" data-state="boot">Pyodide 起動中…</footer>
</main>

【CSS】
:root {
  --bg: #0f1117;
  --card: #181b25;
  --card-2: #1d2130;
  --ink: #eef1f8;
  --muted: #9aa3b8;
  --accent: #6ee7b7;
  --accent-2: #60a5fa;
  --line: #262b3a;
  --sans: system-ui, "Segoe UI", "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 14px;
  font-family: var(--sans);
  color: var(--ink);
  background:
    radial-gradient(700px 300px at 100% 0%, #15233a 0%, transparent 60%),
    var(--bg);
}

.board {
  width: min(100%, 660px);
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 16px;
  padding: 18px;
  box-shadow: 0 26px 60px -26px rgba(0,0,0,.75);
}

.board__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
.board__title { margin: 0; font-size: 17px; font-weight: 800; letter-spacing: .01em; }
.board__desc  { margin: 4px 0 0; font-size: 11px; color: var(--muted); }

.board__btn {
  flex: none;
  font: inherit; font-size: 12px; font-weight: 600;
  color: #07120c;
  background: linear-gradient(180deg, #8df0c4, var(--accent));
  border: none; padding: 8px 16px; border-radius: 10px; cursor: pointer;
  transition: transform .12s ease, box-shadow .12s ease;
}
.board__btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 8px 18px -6px rgba(110,231,183,.5); }
.board__btn:disabled { opacity: .5; cursor: not-allowed; }

.board__grid {
  display: grid;
  grid-template-columns: 1.4fr 1fr;
  gap: 14px;
}
@media (max-width: 540px) { .board__grid { grid-template-columns: 1fr; } }

.card {
  background: var(--card-2);
  border: 1px solid var(--line);
  border-radius: 12px;
  padding: 14px;
}
.card__h { margin: 0 0 12px; font-size: 12px; color: var(--muted); font-weight: 600; letter-spacing: .04em; text-transform: uppercase; }

/* バーチャート */
.bars { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.bar { font-size: 12px; }
.bar__top { display: flex; justify-content: space-between; margin-bottom: 4px; }
.bar__name { color: var(--ink); }
.bar__val  { color: var(--accent); font-variant-numeric: tabular-nums; }
.bar__track { height: 8px; background: #11141d; border-radius: 6px; overflow: hidden; }
.bar__fill {
  height: 100%;
  width: 0;
  border-radius: 6px;
  background: linear-gradient(90deg, var(--accent-2), var(--accent));
  transition: width .7s cubic-bezier(.22,1,.36,1);
}

/* 統計リスト */
.stats { margin: 0; }
.stats__row { display: flex; justify-content: space-between; align-items: baseline; padding: 9px 0; border-bottom: 1px dashed var(--line); }
.stats__row:last-child { border-bottom: none; }
.stats dt { font-size: 12px; color: var(--muted); }
.stats dd { margin: 0; font-size: 15px; font-weight: 700; color: var(--ink); font-variant-numeric: tabular-nums; }
.card--stats dd#s-top { font-size: 13px; color: var(--accent); }

.board__foot {
  margin-top: 14px;
  font-size: 10px;
  text-align: center;
  padding: 6px;
  border-radius: 8px;
}
.board__foot[data-state="boot"]  { color: #ffd166; background: rgba(255,209,102,.08); }
.board__foot[data-state="ready"] { color: var(--accent); background: rgba(110,231,183,.08); }

@media (prefers-reduced-motion: reduce) {
  .bar__fill { transition: none; }
  .board__btn { transition: none; }
}

【JavaScript】
// 要素取得(null安全)
const $ = (id) => document.getElementById(id);
const barsEl = $("bars"), foot = $("foot"), reroll = $("reroll");
const out = {
  sum: $("s-sum"), mean: $("s-mean"), median: $("s-median"),
  std: $("s-std"), top: $("s-top")
};

if (barsEl && foot && reroll && Object.values(out).every(Boolean)) {
  let pyodide = null;

  // Pythonでランダムな売上データを生成し集計(statisticsモジュール使用)
  function aggregate() {
    return pyodide.runPython(`
import random, statistics, json
cats = ["家電", "書籍", "食品", "衣料", "玩具"]
data = {c: sum(random.randint(20, 240) for _ in range(random.randint(8, 20))) for c in cats}
values = list(data.values())
top = max(data, key=data.get)
result = {
    "rows": [{"name": k, "val": v} for k, v in sorted(data.items(), key=lambda kv: -kv[1])],
    "sum": sum(values),
    "mean": round(statistics.mean(values), 1),
    "median": round(statistics.median(values), 1),
    "std": round(statistics.pstdev(values), 1),
    "top": top,
}
json.dumps(result)
`);
  }

  // 結果をDOMへ反映
  function update() {
    const res = JSON.parse(aggregate());
    const max = Math.max(...res.rows.map((r) => r.val), 1);

    // バー再構築
    barsEl.replaceChildren();
    res.rows.forEach((r, i) => {
      const li = document.createElement("li");
      li.className = "bar";
      li.innerHTML = `
        <div class="bar__top">
          <span class="bar__name">${r.name}</span>
          <span class="bar__val">¥${r.val.toLocaleString()}</span>
        </div>
        <div class="bar__track"><div class="bar__fill"></div></div>`;
      barsEl.append(li);
      const fill = li.querySelector(".bar__fill");
      // 次フレームで幅を入れてトランジションを効かせる
      requestAnimationFrame(() => {
        setTimeout(() => { fill.style.width = (r.val / max * 100) + "%"; }, i * 70);
      });
    });

    out.sum.textContent    = "¥" + res.sum.toLocaleString();
    out.mean.textContent   = res.mean.toLocaleString();
    out.median.textContent = res.median.toLocaleString();
    out.std.textContent    = res.std.toLocaleString();
    out.top.textContent    = res.top;
  }

  reroll.addEventListener("click", update);

  // Pyodide起動
  (async () => {
    try {
      const mod = await import("https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.mjs");
      pyodide = await mod.loadPyodide();
      foot.dataset.state = "ready";
      foot.textContent = "集計エンジン: Python statistics";
      reroll.disabled = false;
      update();
    } catch (e) {
      foot.textContent = "読込失敗: " + e.message;
    }
  })();
}

# 外部ライブラリ
https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.js

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。