「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
なぜ自前で書くのか
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;
}
ポイント:
-
桁を
Math.log10()で取る — 8723 なら exponent = 3 (10^3 = 1000 オーダ) - 目標ステップを 10^exponent で割る — 8723 / 5 = 1744.6、これを 10^3 = 1000 で割って 1.7
- {1, 2, 2.5, 5, 10} の最小値にスナップ — 1.7 → 2
- 戻して step を作る — 2 × 1000 = 2000
- 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/
