Reactで、数字の並びから小さな折れ線グラフを描く方法
数字の並びを小さな折れ線グラフ(スパークライン)にするには、最小値と最大値から各点の縦の位置を決め、SVG の <polyline> の points に並べるだけで足ります。グラフのライブラリは要りません。気を付けるのは、全部同じ値や1件だけのときの0での割り算と、最後の点に付ける丸が SVG の端で切れることです。
ここでは、values に数字の配列を渡すと幅 120px・高さ 32px の折れ線を描く Sparkline というコンポーネントを作りました。目盛りや軸は付けず、最後の点だけをピンクの丸で示します。表に出てくる数字は架空のものです。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、@types/react 19.3.0、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、StrictMode で包んだ開発用のビルドを Playwright で Chromium に表示して確かめました。Firefox と Safari、実機のスマートフォン、スクリーンリーダーでの実際の読み上げは試していません。日付が飛び飛びのデータのように、横の間隔をそろえないグラフも扱っていません。

数字を、SVG の座標に直す
計算は spark.ts にまとめました。toPoints() は、数字の並びを、幅 width・高さ height の箱の中の点に直します。横は、最初の点を左の端、最後の点を右の端に置き、その間を等しく分けます。縦は、最大値が上の端、最小値が下の端に来るようにします。MDN の SVG の座標の説明にあるとおり、SVG は下へ行くほど y が大きくなるので、max - v を使って向きを逆にしています。
// 数字の並びを、スパークライン用の座標に直す関数
export type Point = { x: number; y: number };
// いちばん小さい値と大きい値(Math.min(...values) は件数が多いと例外になるので、1つずつ見る)
export function extent(values: readonly number[]): { min: number; max: number } {
let min = Infinity;
let max = -Infinity;
for (const v of values) {
if (v < min) min = v;
if (v > max) max = v;
}
return { min, max };
}
// width × height の箱の中に、上下左右 pad だけ余白を空けて並べる
export function toPoints(values: readonly number[], width: number, height: number, pad: number): Point[] {
const n = values.length;
if (n === 0) return [];
const { min, max } = extent(values);
const range = max - min;
const innerW = width - pad * 2;
const innerH = height - pad * 2;
return values.map((v, i) => ({
// 1件だけのときは真ん中に置く(n - 1 が 0 で割り算にならないように)
x: n === 1 ? width / 2 : pad + (innerW * i) / (n - 1),
// SVG は下へ行くほど y が大きいので、最大値が上の端(pad)に来るようにする
// 全部同じ値のときは range が 0 になるので、高さの真ん中に置く
y: range === 0 ? height / 2 : pad + ((max - v) / range) * innerH,
}));
}
// "12, 15 11、18" のような文字列から数字だけを取り出す
export function parseNumbers(text: string): number[] {
return text
.normalize('NFKC') // 全角の数字やカンマを半角にする
.split(/[\s,、]+/)
.filter((s) => s !== '')
.map(Number)
.filter(Number.isFinite);
}
toPoints([10, 20, 15, 30], 120, 32, 3.5) を Node.js で実行すると、点は次のようになりました。最大の 30 が y = 3.5(上の端から余白の分だけ下)、最小の 10 が y = 28.5(下の端から余白の分だけ上)です。
toPoints([10,20,15,30], 120, 32, 3.5): [{"x":3.5,"y":28.5},{"x":41.166666666666664,"y":16},{"x":78.83333333333333,"y":22.25},{"x":116.5,"y":3.5}]
マイナスの値が混じっていても、計算は変わりません。[-3, 0, 3] では、-3 が下の端、3 が上の端、0 がちょうど真ん中(y = 16)に来ました。
全部同じ値と、1件だけのとき
全部同じ値だと、最大値と最小値の差が 0 になります。(max - v) / (max - min) をそのまま計算すると、0 を 0 で割ることになり、[20, 20, 20] では y がすべて NaN になりました。
割り算だけで y を出すと(全部同じ値): [ NaN, NaN, NaN ]
そこで toPoints() では、差が 0 のときは高さの真ん中に置いています。上の画像の「在庫(変化なし)」の行が、そうして描いた横一直線です。
1件だけのときは、横の位置を求める i / (n - 1) が 0 / 0 になります。こちらは箱の横の真ん中に置きました。1点だけの <polyline> は、Chromium で測ると大きさが幅 0・高さ 0 でした。最後の点の丸は描かれるので、「1件だけ」の行の画面には丸だけが出ています。0件のときは SVG を描かず、「データなし」と出します。
最小値と最大値は、ループで求める
最小値と最大値は Math.min(...values) でも求められます。ただ、MDN の Math.max() の説明には、スプレッド構文で配列を引数に展開する書き方は、要素が多すぎると失敗するか、誤った結果になると書かれています。検証環境の Node.js 22 で試したところ、10万件では動き、20万件では例外になりました。Chromium 153 のページの中でも同じでした。
n=100000 Math.min(...values) -> -0.9999999999848337 / extent().min -> -0.9999999999848337
n=200000 Math.min(...values) -> RangeError: Maximum call stack size exceeded / extent().min -> -0.9999999999848337
extent() のように1つずつ比べるループにしておけば、件数を気にせずに済みます。MDN の同じページでも、reduce() を使う書き方ならこの問題は起きないとしています。
Sparkline コンポーネント
座標の計算を使って SVG を描く、Sparkline.tsx のコードです。
import { extent, toPoints } from './spark';
type Props = {
values: readonly number[];
label: string; // 何の数字か(読み上げ用)
width?: number;
height?: number;
};
const DOT_R = 2.5;
const round = (n: number) => Math.round(n * 100) / 100;
export function Sparkline({ values, label, width = 120, height = 32 }: Props) {
// 最後の点の丸が切れないように、余白は丸の半径より大きくする
const pad = DOT_R + 1;
const points = toPoints(values, width, height, pad);
if (points.length === 0) {
return <span className="sparkline-empty">データなし</span>;
}
const { min, max } = extent(values);
const last = points[points.length - 1];
const summary = `${label}:${values.length}件、最小 ${min}、最大 ${max}、最後 ${values[values.length - 1]}`;
return (
<svg
className="sparkline"
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-label={summary}
>
<polyline
points={points.map((p) => `${round(p.x)},${round(p.y)}`).join(' ')}
fill="none"
stroke="#00c8e8"
strokeWidth={1.5}
strokeLinejoin="round"
strokeLinecap="round"
/>
<circle cx={round(last.x)} cy={round(last.y)} r={DOT_R} fill="#d85878" />
</svg>
);
}
viewBox を width・height と同じ数字にしているので、SVG の座標の 1 が画面の 1px に当たります(MDN の SVG の座標の説明には、viewBox で表示する範囲を変えて拡大する例があります)。<polyline> の points には "x,y x,y …" の形で点を並べます。座標は、属性の文字列が長くならないように、小数第3位を四捨五入しました。
丸が切れないように、余白を空ける
最後の値がいちばん大きいと、その点は箱の上の端に来ます。余白を 0 にすると、点の座標が (120, 0) になり、半径 2.5 の丸は SVG の外にはみ出した部分が描かれません。下の画像は、[10, 20, 15, 30] を余白 0 と余白 3.5 で描いて、3倍に拡大したものです。

Sparkline では、余白を丸の半径より 1 大きい 3.5 にしています。Playwright で、表の5行と入力欄の折れ線の6つについて、丸の外枠が SVG の外枠の内側に収まっていることを確かめました。
読み上げ用のラベルを付ける
スパークラインは目で形をつかむためのもので、点の一つひとつを読み上げても伝わりにくくなります。そこで SVG 全体に role="img" を付け、aria-label に「来店数:10件、最小 31、最大 50、最後 48」のような要約を入れました。MDN の img ロールの説明には、ほとんどのスクリーンリーダーは role="img" を付けた要素を中身の見えないひとかたまりとして扱うので、全体の説明を aria-label などで付けるようにと書かれています。
Playwright で getByRole('img') を数えると、6つ見つかりました。getByRole('img', { name: /^返品数:/ }) で探すと、「返品数:10件、最小 2、最大 9、最後 2」のラベルが付いた SVG が見つかります。
入力した文字から数字を取り出す
画像の上の入力欄では、parseNumbers() で文字列から数字を取り出しています。カンマ、読点、空白、改行のどれで区切っても読めるようにし、数字として読めないものは捨てます。区切る前に normalize('NFKC') を通しているので、全角の数字やカンマも半角として読めます(NFKC については MDN の String.prototype.normalize() の説明にあります)。
parse "12, 15 11、18\n-3.5 abc 1e2 ,,": [12,15,11,18,-3.5,100]
parse "12,3.5 4"(全角): [12,3.5,4]
Number() で変換しているため、1e2 も 100 として読まれます。入力欄に「abc」とだけ入れると、数字が0件になり「データなし」が出ました。「5,3,8」と改行、「10」を入れると、「入力した数字:4件、最小 3、最大 10、最後 10」のラベルで描かれました。
最後に、入力欄と表を並べた App.tsx です。main.tsx では、これを StrictMode で包んで表示しています。このとき、ブラウザのコンソールにエラーや警告は出ませんでした。
import { useState } from 'react';
import { Sparkline } from './Sparkline';
import { parseNumbers } from './spark';
// 架空の数字
const rows = [
{ name: '来店数', values: [32, 35, 31, 38, 42, 39, 45, 44, 50, 48] },
{ name: '返品数', values: [9, 7, 8, 6, 6, 5, 4, 5, 3, 2] },
{ name: '気温の差', values: [-3, -1, 2, 0, -4, 1, 3] },
{ name: '在庫(変化なし)', values: [20, 20, 20, 20, 20] },
{ name: '1件だけ', values: [7] },
];
export function App() {
const [text, setText] = useState('12, 15, 11, 18, 22, 19, 25, 24, 30, 28');
const values = parseNumbers(text);
return (
<main>
<label htmlFor="nums">数字(カンマ、空白、改行で区切る)</label>
<textarea id="nums" rows={2} value={text} onChange={(e) => setText(e.target.value)} />
<p className="preview">
<Sparkline values={values} label="入力した数字" width={240} height={48} />
<span className="count">{values.length}件</span>
</p>
<table>
<tbody>
{rows.map((r) => (
<tr key={r.name}>
<th scope="row">{r.name}</th>
<td>
<Sparkline values={r.values} label={r.name} />
</td>
<td className="num">{r.values[r.values.length - 1]}</td>
</tr>
))}
</tbody>
</table>
</main>
);
}





