0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

npm パッケージのダウンロード推移を比較する SVG チャートをブラウザで実装 — nice-tick アルゴリズムと API 直叩き

0
Posted at

「react と vue と svelte と solid-js、結局誰が勝ってるんだ?」を答えるツール。npm の公式 API (api.npmjs.org/downloads/range) から ブラウザから直接 fetch して、6 パッケージまでの日次ダウンロード推移を 1 つの SVG チャートに重ね書きする。チャートライブラリ無し、~300 行 vanilla JS、スケール計算は純粋関数として 17 件のテスト付き。

🌐 Demo: https://sen.ltd/portfolio/npm-downloads-chart/
📦 GitHub: https://github.com/sen-ltd/npm-downloads-chart

Screenshot

なぜ自前で書くのか

npm 公開ダッシュボードは数あれど、

  • 表示パッケージを自由に組み合わせたい
  • 横軸の期間を即変えたい
  • チャートライブラリの D3/Chart.js なしで、SVG を自分で組み立てる
  • API を直叩きする練習がてら

くらいの要件なら 3 ファイル合計 300 行ちょっと で済む。書いてみると "Chart.js が中で何をしているのか" が腹落ちする。

npm のダウンロード API

公式エンドポイントは公開・無認証で叩ける:

https://api.npmjs.org/downloads/range/<period>/<package>

<period> は last-week / last-month / last-year、または YYYY-MM-DD:YYYY-MM-DD。レスポンスは:

{
  "downloads": [
    { "downloads": 8523912, "day": "2025-05-24" },
    { "downloads": 9213489, "day": "2025-05-25" },
    ...
  ],
  "package": "react",
  "start": "2025-05-24",
  "end": "2026-05-23"
}

CORS が有効なのでブラウザから直接 fetch できる。サーバ側プロキシ不要。

const BASE = "https://api.npmjs.org/downloads/range";

export async function fetchDownloads(packageName, period = "last-month") {
  const url = `${BASE}/${encodeURIComponent(period)}/${encodeURIComponent(packageName)}`;
  const res = await fetch(url);
  if (!res.ok) {
    if (res.status === 404) throw new Error(`package not found: ${packageName}`);
    throw new Error(`npm API ${res.status}: ${packageName}`);
  }
  const json = await res.json();
  return {
    name: packageName,
    points: json.downloads,
    start: json.start,
    end: json.end,
  };
}

複数パッケージは Promise.allSettled で並列 + 部分失敗許容:

export async function fetchMany(packageNames, period = "last-month") {
  const results = await Promise.allSettled(
    packageNames.map((n) => fetchDownloads(n, period))
  );
  return results.map((r, i) =>
    r.status === "fulfilled"
      ? { ok: true, series: r.value }
      : { ok: false, name: packageNames[i], error: r.reason.message }
  );
}

1 パッケージが 404 でもチャート全体は描画される。エラーは UI でリスト表示する。

「綺麗な」 y 軸の目盛り (nice-tick)

最大値 8723 のとき、 y 軸の目盛りを 0, 1500, 3000, 4500, 6000, 7500, 8723 にしたくはない。0, 2000, 4000, 6000, 8000, 10000 のほうが圧倒的に読みやすい。nice-tick algorithm:

export function niceTicks(min, max, targetCount = 5) {
  const range = max - min;
  const roughStep = range / targetCount;
  // ステップ幅の桁数を取る
  const exponent = Math.floor(Math.log10(roughStep));
  const fraction = roughStep / Math.pow(10, exponent);
  // {1, 2, 2.5, 5, 10} のどれかにスナップ
  let nice;
  if (fraction <= 1) nice = 1;
  else if (fraction <= 2) nice = 2;
  else if (fraction <= 2.5) nice = 2.5;
  else if (fraction <= 5) nice = 5;
  else nice = 10;
  const step = nice * Math.pow(10, exponent);
  // 0 から step ずつ並べる
  const ticks = [];
  const start = Math.floor(min / step) * step;
  const end = Math.ceil(max / step) * step;
  for (let v = start; v <= end + step / 2; v += step) {
    ticks.push(Math.round(v / step) * step);
  }
  return ticks;
}

ポイント:

  1. 桁を Math.log10() で取る — 8723 なら exponent = 3 (10^3 = 1000 オーダ)
  2. 目標ステップを 10^exponent で割る — 8723 / 5 = 1744.6、これを 10^3 = 1000 で割って 1.7
  3. {1, 2, 2.5, 5, 10} の最小値にスナップ — 1.7 → 2
  4. 戻して step を作る — 2 × 1000 = 2000
  5. 0 から step ずつ並べる — [0, 2000, 4000, 6000, 8000, 10000]

これで niceTicks(0, 47, 5) → [0, 10, 20, 30, 40, 50]、niceTicks(0, 8723, 5) → [0, 2000, 4000, 6000, 8000, 10000]、niceTicks(0, 12345, 6) → [0, 2500, 5000, 7500, 10000, 12500] と全て綺麗な数字に揃う。Chart.js / D3 / Recharts などが内部でやっているのと同じアルゴリズム。

カウントの省略表記

y 軸ラベルが 12500000 だと読めない。12.5M にしたい:

export function formatCount(n) {
  if (n < 1000) return String(n);
  if (n < 1_000_000) {
    const v = n / 1000;
    return v >= 100 ? `${Math.round(v)}k` : `${v.toFixed(1)}k`;
  }
  const v = n / 1_000_000;
  return v >= 100 ? `${Math.round(v)}M` : `${v.toFixed(1)}M`;
}
  • 1000 未満: そのまま (999)
  • 1000〜99999: 1.2k、45.0k
  • 10万〜999999: 整数で 123k、234k
  • 100万〜: 1.2M、235M

10 倍域ごとに有効桁数を 1 つずつ落とす。235.5M だと逆に間延びするので、桁が大きいほど小数を消すのが視覚的に綺麗。

SVG ライン

サイズが decade スケール (e.g. react = 22M/day, svelte = 137M/year) なので、線一本ずつ Y スケールを別々に持つ、というのも考えたが、本ツールは 比較が主目的なので 1 つの Y スケールで 描画する。svelte と solid-js は react のラインの下で薄く動くのが正解 — それが「圧倒的に react のシェアが大きい」という事実そのもの。

const xScale = linearScale(0, dayCount - 1, 0, innerW);
const yScale = linearScale(0, yMaxActual, innerH, 0); // y軸は反転(SVG は下に向かって正)

const seriesLines = seriesList.map((series, i) => {
  const color = COLORS[i % COLORS.length];
  const coords = series.points.map((p, idx) => ({
    x: xScale.apply(idx),
    y: yScale.apply(p.downloads),
  }));
  return `<polyline fill="none" stroke="${color}" stroke-width="2"
    points="${polylinePoints(coords)}" />`;
}).join("");

<polyline> 1 個で points="x1,y1 x2,y2 ..." を渡すだけ。曲線補間 (Bézier) も <path d="M ... C ..."> で可能だが、デイリー値は折れ線のままが正直で見やすい。

アーキテクチャ

scale.js     ← linearScale + niceTicks + formatCount 等の純粋関数(17 tests)
chart.js     ← SVG レンダラ(scale.js だけに依存)
npm-api.js   ← api.npmjs.org クライアント(CORS 直 fetch)
app.js       ← UI グルー(input → fetch → render)

依存方向:

app.js → npm-api.js(fetch)
app.js → chart.js → scale.js

scale.js は DOM も fetch も使わない。linearScale(0, 100, 0, 500).apply(50) === 250 のような関数群だけなので、Node の組み込みテストランナーで 17 件全て pass:

test("nice-tick algorithm rounds awkward maxima up", () => {
  const ticks = niceTicks(0, 8723, 5);
  assert.equal(ticks[0], 0);
  assert.ok(ticks[ticks.length - 1] >= 8723);
  // ステップが {1, 2, 2.5, 5} × 10^k のどれか
  const step = ticks[1] - ticks[0];
  const exponent = Math.floor(Math.log10(step));
  const frac = step / Math.pow(10, exponent);
  assert.ok([1, 2, 2.5, 5].some((n) => Math.abs(frac - n) < 1e-9));
});

「テスト可能なスケール計算 + 薄い SVG 出力」に分けるだけで、niceTicks の境界バグ(max === min、負数、小数桁ずれ)を Node 側で完全に潰せる。チャートライブラリを使うと "外から見て動いていれば良い" になりがちだが、自前なら数行で正しさを保証できる。

まとめ

  • npm の 公開 API はブラウザから直接叩ける(CORS 対応、認証不要)
  • 複数 fetch は Promise.allSettled で 部分失敗を許容、bad package が他をブロックしない
  • y 軸の "綺麗な目盛り" は {1, 2, 2.5, 5} × 10^k スナップ + log10 で桁を取る 古典的アルゴリズムで実装可能
  • カウント表記は decade ごとに小数桁を落として 1.2k / 235M / 3472M
  • スケール計算 (pure) と SVG レンダラ (impure) を分離 すると Node テストで境界バグを完全に潰せる

リポジトリ: https://github.com/sen-ltd/npm-downloads-chart

このツールは弊社の OSS ポートフォリオ #242 として作成しました。SEN 合同会社(東京)では小さくて切れ味のあるツール群を継続的に公開しています: https://sen.ltd/portfolio/

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?