ストリームグラフライブラリ比較
4つのライブラリで同じ時系列(ジャンル別の月間再生数)をストリームグラフで描き、通常の積み上げ面グラフと切り替えて比較
このページでは、ApexCharts・Mantine Charts・Nivo・D3.js を使ったストリームグラフ(Streamgraph)の実装パターンを、音楽配信サービスのジャンル別の月間再生数(6ジャンル・24か月)という同じデータのインタラクティブなデモとコードで確認できます。ApexCharts は chart.type: 'streamgraph'、Mantine は AreaChart の type="stream"、Nivo は @nivo/stream、D3 は d3-shape の stackOffsetWiggle で描き、どのデモもボタンで通常の積み上げ面グラフに切り替えられます。
積み上げ面グラフが基線を 0 に置いて全体量の推移を見せるのに対し、ストリームグラフは基線を中央に動かして、系列ごとの帯の太さの変化(どの系列が、いつ膨らみ、いつしぼんだか)を見せる図です。縦の位置に意味が無くなるので、合計の推移は読みにくくなります。エリアチャート(Area Chart)の実装比較 →
1. ApexCharts (react-apexcharts)
ApexCharts 7.1 で加わったチャート種別。chart.type: 'streamgraph' で系列ごとの値を渡すと、ApexCharts が積み上げて基線を中央に動かす。plotOptions.streamgraph.offset を 'zero' にすると通常の積み上げ面になる。v8 からは既定のバンドルに入っていないので、import 'apexcharts/features/streamgraph' が要る。
チャートを読み込み中...
操作:ボタンでストリーム/積み上げ面を切り替え/帯に hover でその月の値を表示/凡例のクリックで帯を消す
offset は 'wiggle'(既定。帯の傾きの合計が小さくなるよう基線を動かす、ストリームグラフの元の形)・'silhouette'(中央の線に対して上下対称)・'zero'(通常の積み上げ面)・'expand'(各月の合計を 100% にする)から選べる。帯の並び(order)は既定の 'inside-out' で、山の早い系列ほど中に置き、遅い系列を薄い側へ交互に足していく。中ほどは基線が動いても揺れにくいので、先に目で追う帯が落ち着く。帯の名前は、それぞれの帯がいちばん太いところに太さに合わせた大きさで描かれ(細い帯には描かない)、hover すると、ほかの帯が薄くなる。ツールチップはその月の全部の帯と合計を並べるが、合計の行の見出しは「Total」と英語で固定されている。帯の境目の線は既定で無いので、背景と同じ色の 1px の線を足した。import 'apexcharts/features/streamgraph' を忘れると、コンソールに警告が出て、グラフは何も描かれない。react-apexcharts の型の type にはまだ streamgraph が無いので、値は渡したうえで型だけ外している(コード例を参照)。 CSS変数(--apx-*)を読む仕組みはありますが、再読み込みの呼び出しが別途必要なため、サイトのダークモードには追従しません(ライト表示で固定しています)。'use client';
// ApexStreamgraph.tsx(apexcharts 8.7 / react-apexcharts 2.2。ページ側では next/dynamic の ssr: false で読み込む)
import { useState } from 'react';
import Chart from 'react-apexcharts';
import type { ApexOptions } from 'apexcharts';
// v8 からストリームグラフは既定のバンドルに入っていない。この import が無いと、警告が出て何も描かれない
import 'apexcharts/features/streamgraph';
// サンプルデータ:音楽配信サービスのジャンル別の月間再生数(万回)、2024年1月〜2025年12月。並びは上に積まれる順
const MONTHS = Array.from({ length: 24 }, (_, i) => `${2024 + Math.floor(i / 12)}/${String((i % 12) + 1).padStart(2, '0')}`);
const GENRES = [
{ name: 'J-POP', color: '#3987e5', data: [260, 263, 266, 269, 272, 275, 278, 281, 284, 287, 296, 338, 302, 299, 302, 305, 308, 311, 314, 317, 320, 323, 335, 399] },
{ name: 'ヒップホップ', color: '#d95926', data: [50, 53, 56, 59, 62, 65, 68, 71, 74, 77, 80, 83, 86, 91, 106, 143, 199, 231, 205, 155, 124, 115, 116, 119] },
{ name: 'クリスマスソング', color: '#199e70', data: [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 13, 215, 13, 5, 5, 5, 5, 5, 5, 5, 5, 5, 12, 195] },
{ name: 'ロック', color: '#c98500', data: [100, 100, 100, 100, 100, 111, 198, 219, 120, 100, 100, 100, 100, 100, 100, 100, 100, 109, 184, 202, 117, 100, 100, 100] },
{ name: 'アニメソング', color: '#d55181', data: [139, 134, 139, 253, 322, 229, 163, 134, 115, 139, 132, 115, 139, 132, 115, 139, 132, 115, 139, 132, 115, 139, 132, 111] },
{ name: 'K-POP', color: '#008300', data: [60, 64, 67, 71, 74, 78, 81, 85, 88, 92, 95, 99, 102, 106, 109, 113, 116, 120, 126, 144, 190, 245, 248, 200] },
];
// 目盛りは半年ごと(1月と7月)だけ。ツールチップの見出しは「2025年2月」
const tick = (m: string) => (m.endsWith('/01') ? `${m.slice(0, 4)}年1月` : m.endsWith('/07') ? '7月' : '');
const monthLabel = (m: string) => `${m.slice(0, 4)}年${Number(m.slice(5))}月`;
export default function ApexStreamgraph() {
const [mode, setMode] = useState<'stream' | 'stacked'>('stream');
const options: ApexOptions = {
chart: { type: 'streamgraph', toolbar: { show: false } },
// offset:'wiggle' がストリーム、'zero' が通常の積み上げ面。帯の並び(order)は既定の 'inside-out'
plotOptions: { streamgraph: { offset: mode === 'stream' ? 'wiggle' : 'zero' } },
colors: GENRES.map((g) => g.color),
// 帯の境目に、背景と同じ色の細い線を引く(ストリームの既定は線なし)
stroke: { width: 1, colors: ['#ffffff'] },
// 左端の目盛りのラベルが切れないよう、左右に余白を取る
grid: { padding: { left: 28, right: 16 } },
xaxis: {
categories: MONTHS,
labels: { formatter: (v: string) => tick(v), rotate: 0, hideOverlappingLabels: false },
tooltip: { enabled: false },
},
// ストリームの縦の位置は基線からのずれで、値として読めないので、y 軸は積み上げ面のときだけ出す
yaxis: { show: mode === 'stacked', title: { text: '再生数(万回)' }, labels: { formatter: (v: number) => `${Math.round(v)}` } },
legend: { position: 'top' },
tooltip: {
x: { formatter: (_: number, o?: { dataPointIndex: number }) => (o ? monthLabel(MONTHS[o.dataPointIndex]) : '') },
y: { formatter: (v: number) => `${v} 万回` },
},
};
return (
<div>
{(['stream', 'stacked'] as const).map((m) => (
<button key={m} type="button" onClick={() => setMode(m)} style={{ marginRight: 4, fontWeight: mode === m ? 700 : 400 }}>{m === 'stream' ? 'ストリーム' : '積み上げ面'}</button>
))}
{/* react-apexcharts の型の type に streamgraph が無いので、値は渡したうえで型だけ外す */}
<Chart
type={'streamgraph' as never}
series={GENRES.map(({ name, data }) => ({ name, data }))}
options={options}
height={340}
/>
</div>
);
}AIプロンプトテンプレート
React + TypeScript で、ApexCharts(apexcharts 8.7・react-apexcharts 2.2)を使ったストリームグラフを実装してください。
- データ:音楽配信サービスのジャンル別の月間再生数(万回)。J-POP・ヒップホップ・クリスマスソング・ロック・アニメソング・K-POP の6ジャンル、2024年1月〜2025年12月の24か月。色はジャンルごとに固定すること
- 読み込み: v8 ではストリームグラフが既定のバンドルに入っていないため、import 'apexcharts/features/streamgraph' を足すこと。コンポーネントは next/dynamic の ssr: false で読み込むこと
- 型: react-apexcharts の type に streamgraph が無いため、type={'streamgraph' as never} として値だけ渡すこと
- 切り替え: ボタンで plotOptions.streamgraph.offset を 'wiggle'(ストリーム)と 'zero'(通常の積み上げ面)に切り替えること。帯の並び(order)は既定の 'inside-out' のままにすること
- 見た目: stroke を幅 1・背景と同じ色にして帯の境目を区切ること。x 軸は半年ごと(1月と7月)だけラベルを出し、左端のラベルが切れないよう grid.padding を取ること
- 軸: ストリームの縦の位置は値として読めないので、yaxis.show は積み上げ面のときだけ true にすること
- ツールチップ: tooltip.x.formatter で見出しを「2025年2月」に、tooltip.y.formatter で「◯ 万回」にすることこのプロンプトはあくまでたたき台です。AIの回答はモデルやバージョン、会話の文脈によって毎回異なります。意図通りに動かない場合は、条件を追記・修正してお使いください。
2. @mantine/charts (AreaChart type="stream")
Mantine 9.6 で AreaChart に加わった type="stream"。中身は Recharts の AreaChart で、stackOffset="wiggle" で積み上げる。type="stacked" にすると通常の積み上げ面になり、y 軸は type="stream" のとき既定で消える。
チャートを読み込み中...
操作:ボタンでストリーム/積み上げ面を切り替え/グラフに hover でその月の値を表示
series の順に積む。ほかの3つと同じ並びにするため、series を上に積む順に並べ、Recharts の reverseStackOrder(areaChartProps で渡す)で先頭を上に積ませている。series を逆順に渡しても同じ形になるが、凡例とツールチップまで下の帯からの順になる。凡例は既定で名前順に並ぶ(Recharts 3 の Legend の itemSorter の既定が 'value')ので、legendProps の itemSorter: null で series の順にした。凡例の幅も既定でグラフの幅から左右の margin を引いたぶんしかなく、狭い画面では何段にも折り返すので、wrapperStyle で全幅に広げている。既定の塗りは半透明のグラデーションなので、withGradient={false}・fillOpacity={1} で不透明にしている。帯の線は series の色で描かれ、凡例とツールチップの色見本もこの線の色から取られる。境目を背景色の線にしようと areaProps で stroke を変えると、色見本まで背景色になるので、線は変えていない。valueFormatter は y 軸の目盛りにも効くので、目盛りは yAxisProps で数字だけにした。ツールチップの行は狭い画面で折り返すので、Styles API(tooltipItemName・tooltipItemData)で折り返しを止めている。 配色はMantine独自のカラースキーム(MantineProvider)で決まり、サイトのダークモード切替とは連動しないため、追従しません(ライト表示で固定しています)。'use client';
// MantineStreamgraph.tsx(@mantine/core・@mantine/charts 9.7 / recharts 3.10)
import { useState } from 'react';
import { MantineProvider } from '@mantine/core';
import { AreaChart } from '@mantine/charts';
import '@mantine/core/styles.css';
import '@mantine/charts/styles.css';
// サンプルデータ:音楽配信サービスのジャンル別の月間再生数(万回)、2024年1月〜2025年12月。並びは上に積まれる順
const MONTHS = Array.from({ length: 24 }, (_, i) => `${2024 + Math.floor(i / 12)}/${String((i % 12) + 1).padStart(2, '0')}`);
const GENRES = [
{ name: 'J-POP', color: '#3987e5', data: [260, 263, 266, 269, 272, 275, 278, 281, 284, 287, 296, 338, 302, 299, 302, 305, 308, 311, 314, 317, 320, 323, 335, 399] },
{ name: 'ヒップホップ', color: '#d95926', data: [50, 53, 56, 59, 62, 65, 68, 71, 74, 77, 80, 83, 86, 91, 106, 143, 199, 231, 205, 155, 124, 115, 116, 119] },
{ name: 'クリスマスソング', color: '#199e70', data: [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 13, 215, 13, 5, 5, 5, 5, 5, 5, 5, 5, 5, 12, 195] },
{ name: 'ロック', color: '#c98500', data: [100, 100, 100, 100, 100, 111, 198, 219, 120, 100, 100, 100, 100, 100, 100, 100, 100, 109, 184, 202, 117, 100, 100, 100] },
{ name: 'アニメソング', color: '#d55181', data: [139, 134, 139, 253, 322, 229, 163, 134, 115, 139, 132, 115, 139, 132, 115, 139, 132, 115, 139, 132, 115, 139, 132, 111] },
{ name: 'K-POP', color: '#008300', data: [60, 64, 67, 71, 74, 78, 81, 85, 88, 92, 95, 99, 102, 106, 109, 113, 116, 120, 126, 144, 190, 245, 248, 200] },
];
// 目盛りは半年ごと(1月と7月)だけ。ツールチップの見出しは「2025年2月」
const tick = (m: string) => (m.endsWith('/01') ? `${m.slice(0, 4)}年1月` : m.endsWith('/07') ? '7月' : '');
const monthLabel = (m: string) => `${m.slice(0, 4)}年${Number(m.slice(5))}月`;
// AreaChart の data は、月ごとの行({ month: '2024/01', 'J-POP': 260, ... })
const ROWS = MONTHS.map((month, i) => ({ month, ...Object.fromEntries(GENRES.map((g) => [g.name, g.data[i]])) }));
export default function MantineStreamgraph() {
const [mode, setMode] = useState<'stream' | 'stacked'>('stream');
return (
<MantineProvider>
{(['stream', 'stacked'] as const).map((m) => (
<button key={m} type="button" onClick={() => setMode(m)} style={{ marginRight: 4, fontWeight: mode === m ? 700 : 400 }}>{m === 'stream' ? 'ストリーム' : '積み上げ面'}</button>
))}
<AreaChart
h={340}
data={ROWS}
dataKey="month"
// type="stream" で Recharts の stackOffset="wiggle" になる。"stacked" は通常の積み上げ面
type={mode}
series={GENRES.map(({ name, color }) => ({ name, color }))}
curveType="monotone"
// 塗りを不透明にする(既定は半透明のグラデーション)。線の色は変えない(凡例とツールチップの色見本が線の色になる)
withGradient={false}
fillOpacity={1}
strokeWidth={1}
withDots={false}
withLegend
// 凡例は既定で名前順(Recharts の itemSorter)なので null で series の順に。幅は既定でグラフの幅から左右の margin を引いたぶんなので全幅に広げる
legendProps={{ verticalAlign: 'top', itemSorter: null, wrapperStyle: { left: 0, width: '100%' } }}
valueFormatter={(v) => `${v} 万回`}
// y 軸は type="stream" のとき既定で消える。積み上げ面では出し、目盛りは数字だけにする
yAxisLabel={mode === 'stacked' ? '再生数(万回)' : undefined}
yAxisProps={{ tickFormatter: (v: number) => String(v) }}
gridAxis={mode === 'stacked' ? 'x' : 'none'}
// 狭い画面では目盛りを等間隔に間引く(積み上げ面は y 軸のぶん狭くなり、ラベルがくっつく)
xAxisProps={{ ticks: MONTHS.filter((m) => tick(m)), interval: 'equidistantPreserveStart', tickFormatter: tick }}
// 帯の並び順の設定(inside-out など)が無く、series の順に積む。series は上に積む順なので、
// reverseStackOrder で先頭を上に積ませる(margin は左端の目盛りのラベルが切れないように)
areaChartProps={{ reverseStackOrder: true, margin: { left: 24, right: 16 } }}
tooltipProps={{ labelFormatter: (l) => (MONTHS.includes(String(l)) ? monthLabel(String(l)) : l) }}
styles={{ tooltipItemName: { whiteSpace: 'nowrap' }, tooltipItemData: { whiteSpace: 'nowrap' } }}
/>
</MantineProvider>
);
}AIプロンプトテンプレート
React + TypeScript で、@mantine/charts(9.7)の AreaChart を使ったストリームグラフを実装してください。
- データ:音楽配信サービスのジャンル別の月間再生数(万回)。J-POP・ヒップホップ・クリスマスソング・ロック・アニメソング・K-POP の6ジャンル、2024年1月〜2025年12月の24か月。色はジャンルごとに固定すること
- データ: 月ごとの行({ month: '2024/01', 'J-POP': 260, ... })にして data に渡し、dataKey="month" にすること
- 切り替え: ボタンで type を "stream"(Recharts の stackOffset="wiggle")と "stacked"(通常の積み上げ面)に切り替えること
- 並び順: Mantine には帯の並び順の設定が無いため、series を上に積む順(J-POP・ヒップホップ・クリスマスソング・ロック・アニメソング・K-POP)に並べ、areaChartProps の reverseStackOrder: true で先頭を上に積ませること
- 見た目: withGradient={false}・fillOpacity={1} で塗りを不透明にし、withDots={false} にすること。areaProps で stroke を変えないこと(凡例とツールチップの色見本が線の色になるため)
- 凡例: legendProps の itemSorter: null で、名前順ではなく series の順に並べること。凡例の幅は既定でグラフの幅から左右の margin を引いたぶんしかないので、wrapperStyle: { left: 0, width: '100%' } で全幅に広げること
- 軸: y 軸は積み上げ面のときだけ yAxisLabel を付け、目盛りは yAxisProps の tickFormatter で数字だけにすること。ストリームでは gridAxis="none" にすること。x 軸は半年ごとだけ目盛りを出し、xAxisProps の interval: 'equidistantPreserveStart' で狭い画面では間引くこと
- ツールチップ: valueFormatter で「◯ 万回」、tooltipProps の labelFormatter で見出しを「2025年2月」にすること。狭い画面で行が折り返さないよう、styles の tooltipItemName・tooltipItemData を whiteSpace: 'nowrap' にすることこのプロンプトはあくまでたたき台です。AIの回答はモデルやバージョン、会話の文脈によって毎回異なります。意図通りに動かない場合は、条件を追記・修正してお使いください。
3. Nivo (@nivo/stream)
@nivo/stream の Stream(ResponsiveStream)。data に月ごとの行、keys に系列名を渡すと、d3-shape の stack() で積み上げる。基線は offsetType(既定 'wiggle')、並び順は order(既定 'none'=keys の順)で決め、offsetType を 'none' にすると通常の積み上げ面になる。
チャートを読み込み中...
操作:ボタンでストリーム/積み上げ面を切り替え/グラフに hover でその月の値を表示
offsetType は 'wiggle'・'silhouette'・'expand'・'diverging'・'none'、order は 'insideOut'・'ascending'・'descending'・'reverse'・'none' から選べる(中身は d3-shape の offset と order)。月ごとに hover すると全部の帯の値を表で出す(stackTooltip)が、既定の表には月の見出しが無く、表はポインターの左右に出るだけなので、狭い画面では画面の外にはみ出した。このデモでは layers から既定の 'slices' を外し、@nivo/tooltip の useTooltip で、置き場所に収まる位置へずらして表を出す自前のレイヤーを重ねている。また、形を切り替えると margin が変わり(積み上げ面では y 軸のぶん広げる)、x 軸の目盛りの位置がアニメーションする途中で transform に壊れた値が入って、狭い画面ではコンソールにエラーが出たため、animate={false} にしている。 軸の文字(currentColor)・格子・ツールチップ・帯の境目の線を CSS で解決できる値で渡しているので、サイトのダークモードに追従する。'use client';
// NivoStreamgraph.tsx(@nivo/stream・@nivo/tooltip 0.99)
import { useMemo, useState } from 'react';
import { ResponsiveStream, type StreamCustomLayerProps, type StreamSliceData } from '@nivo/stream';
import { useTooltip } from '@nivo/tooltip';
// サンプルデータ:音楽配信サービスのジャンル別の月間再生数(万回)、2024年1月〜2025年12月。並びは上に積まれる順
const MONTHS = Array.from({ length: 24 }, (_, i) => `${2024 + Math.floor(i / 12)}/${String((i % 12) + 1).padStart(2, '0')}`);
const GENRES = [
{ name: 'J-POP', color: '#3987e5', data: [260, 263, 266, 269, 272, 275, 278, 281, 284, 287, 296, 338, 302, 299, 302, 305, 308, 311, 314, 317, 320, 323, 335, 399] },
{ name: 'ヒップホップ', color: '#d95926', data: [50, 53, 56, 59, 62, 65, 68, 71, 74, 77, 80, 83, 86, 91, 106, 143, 199, 231, 205, 155, 124, 115, 116, 119] },
{ name: 'クリスマスソング', color: '#199e70', data: [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 13, 215, 13, 5, 5, 5, 5, 5, 5, 5, 5, 5, 12, 195] },
{ name: 'ロック', color: '#c98500', data: [100, 100, 100, 100, 100, 111, 198, 219, 120, 100, 100, 100, 100, 100, 100, 100, 100, 109, 184, 202, 117, 100, 100, 100] },
{ name: 'アニメソング', color: '#d55181', data: [139, 134, 139, 253, 322, 229, 163, 134, 115, 139, 132, 115, 139, 132, 115, 139, 132, 115, 139, 132, 115, 139, 132, 111] },
{ name: 'K-POP', color: '#008300', data: [60, 64, 67, 71, 74, 78, 81, 85, 88, 92, 95, 99, 102, 106, 109, 113, 116, 120, 126, 144, 190, 245, 248, 200] },
];
// 目盛りは半年ごと(1月と7月)だけ。ツールチップの見出しは「2025年2月」
const tick = (m: string) => (m.endsWith('/01') ? `${m.slice(0, 4)}年1月` : m.endsWith('/07') ? '7月' : '');
const monthLabel = (m: string) => `${m.slice(0, 4)}年${Number(m.slice(5))}月`;
const ROWS = MONTHS.map((_, i) => Object.fromEntries(GENRES.map((g) => [g.name, g.data[i]])));
const TIP_W = 200; // ツールチップの幅(px)。収まる位置を計算するので固定する
// hover で出す表(月の見出しつき。並びは画面の上の帯から)
const Table = ({ s }: { s: StreamSliceData }) => (
<div style={{ width: TIP_W, background: '#fff', border: '1px solid #e5e7eb', padding: '6px 10px', fontSize: 12 }}>
<strong>{monthLabel(MONTHS[s.index])}</strong>
{s.stack.map((d) => (
<div key={d.layerId} style={{ display: 'flex', gap: 6 }}><span style={{ color: d.color }}>■</span>{d.layerLabel}<span style={{ marginLeft: 'auto' }}>{d.formattedValue}</span></div>
))}
</div>
);
export default function NivoStreamgraph() {
const [mode, setMode] = useState<'stream' | 'stacked'>('stream');
const margin = useMemo(() => ({ top: 8, right: 20, bottom: 28, left: mode === 'stacked' ? 60 : 28 }), [mode]);
// 既定の slices レイヤーの代わり。既定は表をポインターの左右に出すだけで、狭い画面でははみ出すので、収まる位置へずらす
const HoverLayer = useMemo(() => function HoverLayer({ slices, xScale, yScale }: StreamCustomLayerProps) {
const { showTooltipAt, hideTooltip } = useTooltip();
const [innerW, innerH, step] = [xScale.range()[1], yScale.range()[0], xScale.step()];
const outerW = margin.left + innerW + margin.right;
const show = (s: StreamSliceData) => {
const cx = margin.left + s.x;
const left = cx + 14 + TIP_W <= outerW ? cx + 14 : cx - 14 - TIP_W >= 0 ? cx - 14 - TIP_W : cx < outerW / 2 ? outerW - TIP_W : 0;
// anchor 'right' は、渡した位置の 14px 右に、縦の中央を合わせて出す
showTooltipAt(<Table s={s} />, [left - 14, margin.top + innerH / 2], 'right');
};
return <g>{slices.map((s) => (
<rect key={s.index} x={s.x - step / 2} width={step} height={innerH} fill="transparent"
onMouseEnter={() => show(s)} onMouseMove={() => show(s)} onMouseLeave={hideTooltip} />
))}</g>;
}, [margin]);
return (
<div>
{(['stream', 'stacked'] as const).map((m) => (
<button key={m} type="button" onClick={() => setMode(m)} style={{ marginRight: 4, fontWeight: mode === m ? 700 : 400 }}>{m === 'stream' ? 'ストリーム' : '積み上げ面'}</button>
))}
<div style={{ height: 340 }}>
<ResponsiveStream
data={ROWS}
keys={GENRES.map((g) => g.name)}
// offsetType:'wiggle' がストリーム、'none' が通常の積み上げ面。order は山の早いジャンルほど中に置く
offsetType={mode === 'stream' ? 'wiggle' : 'none'}
order="insideOut"
curve="monotoneX"
colors={({ id }) => GENRES.find((g) => g.name === id)!.color}
borderWidth={1}
borderColor="#ffffff"
margin={margin}
animate={false} // margin が変わると、目盛りのアニメーションで transform が壊れてエラーが出るため切る
// ストリームの縦の位置は値として読めないので、y 軸は積み上げ面のときだけ出す
axisLeft={mode === 'stacked' ? { legend: '再生数(万回)', legendOffset: -50, legendPosition: 'middle' } : null}
axisBottom={{ tickValues: MONTHS.flatMap((m, i) => (tick(m) ? [i] : [])), format: (i) => tick(MONTHS[Number(i)]) }}
enableGridY={mode === 'stacked'}
valueFormat={(v) => `${v} 万回`}
layers={['grid', 'axes', 'layers', HoverLayer]} // 既定の 'slices' の代わりに HoverLayer を重ねる
/>
</div>
</div>
);
}AIプロンプトテンプレート
React + TypeScript で、Nivo(@nivo/stream・@nivo/tooltip 0.99)を使ったストリームグラフを実装してください。
- データ:音楽配信サービスのジャンル別の月間再生数(万回)。J-POP・ヒップホップ・クリスマスソング・ロック・アニメソング・K-POP の6ジャンル、2024年1月〜2025年12月の24か月。色はジャンルごとに固定すること
- データ: 月ごとの行({ 'J-POP': 260, ... })にして data に、ジャンル名の配列を keys に渡すこと
- 切り替え: ボタンで offsetType を 'wiggle'(ストリーム)と 'none'(通常の積み上げ面)に切り替え、order は 'insideOut'、curve は 'monotoneX' にすること
- 見た目: colors は関数でジャンルの色を返し、borderWidth={1}・borderColor を背景色にして帯の境目を区切ること。animate={false} にすること(margin が変わると目盛りのアニメーションで transform が壊れ、エラーが出るため)
- 軸: axisLeft は積み上げ面のときだけ出し、axisBottom は tickValues で半年ごとの位置(0, 6, 12, 18)を指定し、format で月の文字列に直すこと
- ツールチップ: layers から既定の 'slices' を外し、月ごとの透明な rect で hover を受ける自前のレイヤーを重ねること。@nivo/tooltip の useTooltip の showTooltipAt で、幅を固定した表(月の見出し+全ジャンルの値)を、置き場所の幅に収まる位置へずらして出すこと
- ダークモード: theme の text を fill: currentColor、grid の線を CSS 変数にして、サイトのダークモードに追従させることこのプロンプトはあくまでたたき台です。AIの回答はモデルやバージョン、会話の文脈によって毎回異なります。意図通りに動かない場合は、条件を追記・修正してお使いください。
4. D3.js(d3-shape の stackOffsetWiggle)
d3-shape の stack() で帯の下端・上端を計算し、area() で SVG のパスにする。offset に stackOffsetWiggle を渡すとストリームグラフ、stackOffsetNone で通常の積み上げ面になる。軸・hover・ツールチップは自分で描く。
チャートを読み込み中...
操作:ボタンでストリーム/積み上げ面を切り替え/帯に hover でその帯を強調し、その月の値を表示
stackOffsetWiggle は、ストリームグラフを提案した Byron と Wattenberg(2008)の基線の決め方で、帯の傾きの合計が小さくなるように、月ごとに積み上げ全体を上下にずらす。stackOrderInsideOut は山の早い系列ほど中に置く並び順で、wiggle と組み合わせることが勧められている。ApexCharts の 'wiggle'・'inside-out' も同じ計算をしていて(Recharts と Nivo は d3-shape をそのまま使う)、4つのデモの帯の形は同じになる。stackOffsetSilhouette(中央揃え)や stackOffsetExpand(100%)に差し替えることもできる。hover では、ポインターに近い月と、その月でポインターの下にある帯を、スケールの invert で逆算して探している。 文字は fill="currentColor"、格子と帯の境目の線は CSS 変数で描いているので、サイトのダークモードに追従する。'use client';
// D3Streamgraph.tsx(d3-shape 3.2 / d3-scale 4.0)
import { useMemo, useState } from 'react';
import { area, curveMonotoneX, stack, stackOffsetNone, stackOffsetWiggle, stackOrderInsideOut, type SeriesPoint } from 'd3-shape';
import { scaleLinear } from 'd3-scale';
// サンプルデータ:音楽配信サービスのジャンル別の月間再生数(万回)、2024年1月〜2025年12月。並びは上に積まれる順
const MONTHS = Array.from({ length: 24 }, (_, i) => `${2024 + Math.floor(i / 12)}/${String((i % 12) + 1).padStart(2, '0')}`);
const GENRES = [
{ name: 'J-POP', color: '#3987e5', data: [260, 263, 266, 269, 272, 275, 278, 281, 284, 287, 296, 338, 302, 299, 302, 305, 308, 311, 314, 317, 320, 323, 335, 399] },
{ name: 'ヒップホップ', color: '#d95926', data: [50, 53, 56, 59, 62, 65, 68, 71, 74, 77, 80, 83, 86, 91, 106, 143, 199, 231, 205, 155, 124, 115, 116, 119] },
{ name: 'クリスマスソング', color: '#199e70', data: [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 13, 215, 13, 5, 5, 5, 5, 5, 5, 5, 5, 5, 12, 195] },
{ name: 'ロック', color: '#c98500', data: [100, 100, 100, 100, 100, 111, 198, 219, 120, 100, 100, 100, 100, 100, 100, 100, 100, 109, 184, 202, 117, 100, 100, 100] },
{ name: 'アニメソング', color: '#d55181', data: [139, 134, 139, 253, 322, 229, 163, 134, 115, 139, 132, 115, 139, 132, 115, 139, 132, 115, 139, 132, 115, 139, 132, 111] },
{ name: 'K-POP', color: '#008300', data: [60, 64, 67, 71, 74, 78, 81, 85, 88, 92, 95, 99, 102, 106, 109, 113, 116, 120, 126, 144, 190, 245, 248, 200] },
];
// 目盛りは半年ごと(1月と7月)だけ。ツールチップの見出しは「2025年2月」
const tick = (m: string) => (m.endsWith('/01') ? `${m.slice(0, 4)}年1月` : m.endsWith('/07') ? '7月' : '');
const monthLabel = (m: string) => `${m.slice(0, 4)}年${Number(m.slice(5))}月`;
const ROWS = MONTHS.map((_, i) => Object.fromEntries(GENRES.map((g) => [g.name, g.data[i]])));
const [W, H] = [640, 320]; // viewBox の大きさ。表示の幅に合わせて拡大・縮小される
const M = { top: 8, right: 20, bottom: 28, left: 48 };
export default function D3Streamgraph() {
const [mode, setMode] = useState<'stream' | 'stacked'>('stream');
const [hover, setHover] = useState<{ i: number; name: string | null } | null>(null);
// stack() で帯の下端・上端を計算する。offset を差し替えるだけで、積み上げ面とストリームが切り替わる
const series = useMemo(() => stack<Record<string, number>>()
.keys(GENRES.map((g) => g.name))
.order(stackOrderInsideOut) // 山の早いジャンルほど中に置く
.offset(mode === 'stream' ? stackOffsetWiggle : stackOffsetNone)(ROWS), [mode]);
const x = scaleLinear().domain([0, MONTHS.length - 1]).range([M.left, W - M.right]);
const y = scaleLinear()
.domain([Math.min(...series.flat().map((d) => d[0])), Math.max(...series.flat().map((d) => d[1]))])
.range([H - M.bottom, M.top])
.nice();
const path = area<SeriesPoint<Record<string, number>>>().x((_, i) => x(i)).y0((d) => y(d[0])).y1((d) => y(d[1])).curve(curveMonotoneX);
// ポインターに近い月と、その月でポインターの下にある帯を探す(位置は viewBox の単位に直す)
const onMove = (e: React.PointerEvent<SVGSVGElement>) => {
const box = e.currentTarget.getBoundingClientRect();
const i = Math.max(0, Math.min(MONTHS.length - 1, Math.round(x.invert(((e.clientX - box.left) / box.width) * W))));
const v = y.invert(((e.clientY - box.top) / box.height) * H);
setHover({ i, name: series.find((s) => s[i][0] <= v && v <= s[i][1])?.key ?? null });
};
return (
<div style={{ position: 'relative' }}>
{(['stream', 'stacked'] as const).map((m) => (
<button key={m} type="button" onClick={() => setMode(m)} style={{ marginRight: 4, fontWeight: mode === m ? 700 : 400 }}>{m === 'stream' ? 'ストリーム' : '積み上げ面'}</button>
))}
<svg viewBox={`0 0 ${W} ${H}`} width="100%" onPointerMove={onMove} onPointerLeave={() => setHover(null)}>
{/* 積み上げ面のときだけ、y 軸の目盛りと横線を出す(ストリームの縦の位置は値として読めない) */}
{mode === 'stacked' && y.ticks(5).map((t) => (
<g key={t}>
<line x1={M.left} x2={W - M.right} y1={y(t)} y2={y(t)} stroke="#e5e7eb" />
<text x={M.left - 8} y={y(t)} dy="0.32em" textAnchor="end" fontSize={11}>{t}</text>
</g>
))}
{/* 帯の境目は白い線で区切り、hover 中はポインターの下の帯以外を薄くする */}
{series.map((s) => (
<path key={s.key} d={path(s) ?? ''} fill={GENRES.find((g) => g.name === s.key)!.color} stroke="#fff"
opacity={hover?.name && hover.name !== s.key ? 0.35 : 1} />
))}
{MONTHS.map((m, i) => tick(m) && <text key={m} x={x(i)} y={H - 8} textAnchor="middle" fontSize={11}>{tick(m)}</text>)}
{hover && <line x1={x(hover.i)} x2={x(hover.i)} y1={M.top} y2={H - M.bottom} stroke="#000" strokeOpacity={0.4} />}
</svg>
{/* ツールチップは、ポインターと反対側の上の隅に出す(狭い画面でもはみ出さない) */}
{hover && (
<div style={{ position: 'absolute', top: 32, [x(hover.i) > W / 2 ? 'left' : 'right']: 8, background: '#fff', border: '1px solid #e5e7eb', padding: '6px 10px', fontSize: 12, pointerEvents: 'none' }}>
<strong>{monthLabel(MONTHS[hover.i])}</strong>
{GENRES.map((g) => (
<div key={g.name} style={{ display: 'flex', gap: 6, fontWeight: g.name === hover.name ? 700 : 400 }}>
<span style={{ color: g.color }}>■</span>{g.name}<span style={{ marginLeft: 'auto', paddingLeft: 12 }}>{g.data[hover.i]} 万回</span>
</div>
))}
</div>
)}
</div>
);
}AIプロンプトテンプレート
React + TypeScript で、D3(d3-shape 3.2・d3-scale 4.0)を使ったストリームグラフを、SVG を自分で描いて実装してください。 - データ:音楽配信サービスのジャンル別の月間再生数(万回)。J-POP・ヒップホップ・クリスマスソング・ロック・アニメソング・K-POP の6ジャンル、2024年1月〜2025年12月の24か月。色はジャンルごとに固定すること - 計算: d3-shape の stack().keys(ジャンル名).order(stackOrderInsideOut) で積み上げ、offset をボタンで stackOffsetWiggle(ストリーム)と stackOffsetNone(通常の積み上げ面)に切り替えること - 描画: x は scaleLinear で月の番号を横位置に、y は scaleLinear で帯の下端の最小〜上端の最大を縦位置にし、area().curve(curveMonotoneX) でパスを作ること。帯の境目は背景色の線で区切ること - 軸: 積み上げ面のときだけ y 軸の目盛りと横線を出し、x 軸は半年ごと(1月と7月)だけラベルを出すこと - hover: ポインターの位置からスケールの invert で、いちばん近い月と、その月でポインターの下にある帯を探し、その帯以外を薄くすること。全ジャンルの値を並べたツールチップを、はみ出さない位置に出すこと - ダークモード: 文字は fill="currentColor"、線は CSS 変数にして、サイトのダークモードに追従させること
このプロンプトはあくまでたたき台です。AIの回答はモデルやバージョン、会話の文脈によって毎回異なります。意図通りに動かない場合は、条件を追記・修正してお使いください。
ライブラリ比較
| ライブラリ | 書き方 | 基線の選び方 | 帯の並び順 | ダークモード |
|---|---|---|---|---|
| ApexCharts | chart.type: 'streamgraph' | offset:wiggle(既定)・silhouette・zero・expand | order:inside-out(既定)・inverse・none | ライト固定 |
| Mantine | AreaChart type="stream" | type:stream(wiggle)・stacked・percent | 設定なし(series の順) | ライト固定 |
| Nivo | @nivo/stream の Stream | offsetType:wiggle(既定)・silhouette・expand・diverging・none | order:insideOut・ascending・descending・reverse・none(既定) | ✓ 追従 |
| D3.js | d3-shape の stack() と area() | stackOffsetWiggle・Silhouette・Expand・Diverging・None | stackOrderInsideOut・Ascending・Descending・Reverse・None | ✓ 追従 |
選択のポイント
- •ApexCharts: 設定だけで、帯への名前の直書き・hover で他の帯を薄くする表示・合計つきのツールチップまでそろう。ストリームグラフらしい見た目をいちばん手早く作れる。v8 では import を1行足すのを忘れない。
- •Mantine: すでに Mantine を使っていれば、AreaChart の
typeを変えるだけで済む。並び順の設定が無いので、seriesの順を自分で決める。 - •Nivo: 基線と並び順の選択肢が多く、SVG で色を CSS から渡せるので、サイトのダークモードに合わせたい場合に向く。ツールチップの位置や中身を変えるには、
layersで自前のレイヤーを足す。 - •D3.js: 計算(
stack())と描画が分かれていて、仕組みを確かめたい場合や、帯の上に注記を描くなど独自の表現をしたい場合に向く。軸・hover・ツールチップはすべて自分で書く。
典型的な使用例
- 🎵ジャンル・カテゴリ別の再生数や売上の推移:どのカテゴリが、いつ伸びて、いつしぼんだか(流行の移り変わり)を見る
- 🔍検索キーワード・話題の盛り上がり:いくつもの話題の注目度の変化を、1枚の流れとして見せる
- 🎬映画・番組のジャンル別の興行収入や視聴数:ヒット作が出た時期に、そのジャンルの帯が膨らむ様子を見る
- 🛒商品カテゴリ別の販売数の季節変動:季節もの(クリスマス・夏物など)が膨らむ時期と、ほかのカテゴリとの入れ替わりを見る
⚠️ 使用時の注意点
- •縦の位置(基線)は月ごとに動くので、縦軸の値は読めない。系列どうしの正確な比較や合計の推移を見せたいときは、積み上げ面グラフや折れ線グラフにする
- •帯の太さの変化は読めるが、帯が細い所や大きく曲がっている所では太さを見誤りやすい。正確な値はツールチップや表で補う
- •帯の並び順で見え方が大きく変わる。inside-out(山の早い系列ほど中)にすると、中ほどの帯が揺れにくくなる
- •系列が多すぎると帯が細くなって読めない。数が多いときは、小さい系列を「その他」にまとめる
- •隣り合う帯の色が似ていると境目が分からない。並び順で隣になる帯どうしを見分けやすい色にし(このページの色は、色覚の違いでも隣どうしを見分けられることを確かめてある)、背景色の細い線で区切る
ストリームグラフ(Streamgraph)は、複数の系列の時系列を積み上げた面グラフの一種で、基線を 0 に固定せず中央付近に動かし、帯が川の流れのように見える。帯の太さがその時点の値を表し、系列ごとの増減や流行の移り変わりを見せる。
音楽・映画のジャンル別の再生数や興行収入、検索キーワードや話題の注目度、商品カテゴリ別の販売数など、系列が多く、全体量よりも「どの系列が、いつ膨らんだか」の流れを見せたい場面で使われる。
- •ウィグル型(帯の傾きの合計が小さくなるよう基線を動かす。元の形)
- •シルエット型(中央の線に対して上下対称)
- •100%型(各時点の合計を 100% にする)
- •積み上げ面型(基線を 0 に固定。比べるための元の形)
📊 使用しているサンプルデータ(クリックで表示)
ある音楽配信サービスの、ジャンル別の月間再生数(万回)を、2024年1月〜2025年12月の24か月ぶん用意しました。J-POP はゆっくり伸びて年末に膨らみ、アニメソングは2024年5月のヒット曲で、ロックは夏フェスの7〜8月に、ヒップホップは2025年春〜夏に、K-POP は2025年10〜11月に、クリスマスソングは12月だけ膨らみます。山の時期をずらしてあるので、帯の膨らみが流れのように移っていくのが見えます。4つのデモとコード例は同じ値・同じ色です。