製造品出荷額 30 年の Bar Chart Race を Remotion で出す|Claude Code で動画生成

ClaudeCode
e-Stat
製造品出荷額
Remotion
動画

「47 都道府県の製造品出荷額が、1990 年から 2020 年までの間にどう入れ替わったか」——これを 1 本の動画 で見せられたら、SNS でも社内資料でも一気に説得力が上がります。本記事では、e-Stat の工業統計を Claude Code で取得し、Remotion で Bar Chart Race(ランキング遷移アニメ動画)を MP4 出力する までを 1 本で通します。連載 Part 8、所要時間 2 時間程度。

Bar Chart Race は、棒グラフが年ごとに順位を入れ替えながらスライドする、あの YouTube で流行ったやつです。Excel で再現するのは無理ゲーですが、Remotion を使えば React のコードで完全に制御できる ので、Claude Code との相性が抜群に良い。Part 7 までで取得・整形してきた e-Stat データを「最終出力=動画」まで持っていくのが今回のゴールです。

なぜ Bar Chart Race を Remotion で作るのか

D3.js で Bar Chart Race を作るチュートリアルは検索すれば山ほど出てきます。が、Web に埋め込む前提のものばかり で、MP4 として出力するには別途キャプチャツール(OBS など)で画面録画する必要があります。これだとフレーム落ちが避けられず、Reels や Shorts に貼った瞬間に「カクカク動画」になります。

Remotion は React で動画を組み立てて、フレーム単位で完全にレンダリング してから MP4 にエンコードするフレームワークです。1 秒 60 フレームのうち 1 フレームでもズレない、SNS 投稿用としては理想的な作りです。Anthropic のドキュメント動画や、最近の SaaS のローンチ動画にも採用例が増えています。

観点画面録画 (OBS)Remotion
フレーム精度OS 負荷で揺れるフレーム単位で完全制御
解像度画面に依存任意指定(1080x1920 等)
文字の鮮明さアンチエイリアスがブレるベクター描画でクッキリ
再現性環境依存コードで一意に決まる
差分修正全部撮り直し該当箇所だけ再render
CI 連携ほぼ無理GitHub Actions で自動化可

stats47 でも apps/remotion ワークスペースに Remotion を組み込んでいて、Bar Chart Race・Choropleth Map・縦型棒グラフなど SNS 用の動画素材を一括生成しています。本記事はそのコアパターンを最小構成で再現するチュートリアルです。

Claude Code を組み合わせると、「e-Stat からデータ取って Remotion の Composition 用に整形しといて」 と一文頼むだけで、Step 2-3 がほぼ自動で終わります。残るは Composition の React コードを書くだけ。これも Claude に「interpolate でランクの遷移をスムーズにして」と頼めば 8 割書いてくれます。

使うデータ: 工業統計の都道府県別製造品出荷額 30 年分

今回扱うのは経済産業省「工業統計調査」の 都道府県別 製造品出荷額等 です。e-Stat 上では 0003348423 などの統計表 ID で公開されており、1990 年代から 2020 年(2021 年以降は「経済構造実態調査 製造業事業所調査」に統合)まで連続して取得できます。

「製造品出荷額等」は、工場・事業所が 1 年間に出荷した製品の金額合計(億円)で、産業立地の集中度を一発で示す指標です。愛知・大阪・神奈川・静岡・兵庫といった工業県が常時上位ですが、30 年単位で見ると順位の入れ替わりが意外と激しい のがミソ。たとえば北関東(茨城・栃木・群馬)の躍進や、繊維系の凋落で順位を落とした県が見て取れます。

Bar Chart Race 向けには、最低でも 20 年・できれば 30 年スパンが欲しい。1 年あたり 2-3 秒の表示で 30 年なら 1 分前後の動画になり、SNS の最適尺(Reels: 90 秒以内、Shorts: 60 秒以内、TikTok: 60 秒推奨)にぴったりハマります。

データの注意点: 工業統計調査は 2020 年で終了し、以降は「経済構造実態調査」に統合されています。系列の連続性を維持したい場合は、両方の統計表を結合する必要があり、Claude Code に「2020 年以前は 0003348423、2021 年以降は経済構造実態調査の都道府県別データを取って結合して」と頼むのが楽です。

Step 1: Remotion セットアップ(npm init video)

Remotion は単独のプロジェクトとしても、モノレポの 1 ワークスペースとしても作れます。今回はチュートリアルなので、別ディレクトリで素のプロジェクトとして立ち上げます。

# 任意の場所で
npm init video@latest bar-chart-race
cd bar-chart-race

npm init video を叩くとテンプレート選択肢が出てきます。Hello World (v3 系の TypeScript テンプレート)を選んでください。Blank でも良いですが、まずは動くサンプルから差分で組み立てた方が事故が少ないです。

# 開発サーバ起動(プレビュー UI が localhost:3000 で立ち上がる)
npm run dev

npm run dev を叩くと Remotion Studio という Web UI が開き、左ペインに Composition 一覧、右ペインにタイムラインとプレビュー、というエディタ風の画面が出ます。ここでアニメーションを React のホットリロードで作っていく のが Remotion の体験のキモです。

ディレクトリ用途
src/Root.tsxComposition の登録(id・解像度・FPS・duration を宣言)
src/Composition.tsx動画本体の React コンポーネント
public/静的アセット(画像・フォントなど)
out/レンダリング後の MP4 出力先(デフォルト)

Composition とは、Remotion における「1 本の動画」の単位です。1 プロジェクトに複数 Composition を登録できるので、後述の縦型 (Reels) と横型 (YouTube) の 2 本を同時に出すなどの構成も可能です。

Step 2: Claude Code に「製造品出荷額 30 年取って」と頼む

ここで Claude Code の出番です。e-Stat API キーは Part 1 で取得済みの前提で、プロジェクトルートに .env を作っておきます。

echo "ESTAT_APP_ID=あなたのキー" >> .env

Claude Code を起動し、こう頼みます。

claude
e-Stat API(ESTAT_APP_ID は .env にある)から、
工業統計調査「都道府県別 製造品出荷額等」を 1990 年から 2020 年まで取って、
src/data/manufacturing.json に
{ "years": [1990, 1991, ...], "prefectures": [{"code": "01", "name": "北海道", "values": [12345, 12500, ...]}, ...] }
の形で保存して。
2020 年以降は経済構造実態調査の都道府県別データで補完して 2023 年まで欲しい。
単位は億円で揃えること。

Claude Code は e-Stat の getStatsList で該当統計表 ID を探し(または Part 2 で作った検索スキルを呼び)、getStatsData を 30 年分ループで叩いて、JSON に整形して保存してくれます。途中で「2018 年のデータが取れない」「@cat01 の意味コードが揺れている」など細かい例外が出ますが、エラーログを Claude に貼れば即修正案 が返ってきます。

規約に注意: e-Stat API は cdTimeFrom / cdTimeTo / cdArea を使うと R2 キャッシュ分断が起きるため、stats47 では 全年度・全地域を一括取得してメモリ上でフィルタ するルールになっています。チュートリアル用途では細かく刻んでも構いませんが、本番アプリに組み込むときは全件取得→フィルタで統一するのが安全です。

生成された JSON はこんな感じになります。

{
  "years": [1990, 1991, 1992, "...", 2022, 2023],
  "prefectures": [
    { "code": "01", "name": "北海道", "values": [54321, 53890, "...", 61200, 62100] },
    { "code": "02", "name": "青森県", "values": [12450, 12300, "...", 14200, 14500] },
    "...",
    { "code": "23", "name": "愛知県", "values": [368000, 370200, "...", 478000, 481000] },
    "...",
    { "code": "47", "name": "沖縄県", "values": [4200, 4350, "...", 5100, 5200] }
  ]
}

Step 3: 各年で 47 県をソートした配列に整形

Bar Chart Race のアニメは「ある時点での順位」を毎フレーム決める必要があります。Step 2 の JSON のままだと「県ごとの時系列」になっているので、これを「年ごとの順位スナップショット」に変換します。

Claude Code に頼んでも良いですが、ロジックがシンプルなので一度自分で書いて理解する価値があります。

// src/data/buildSnapshots.ts
import raw from "./manufacturing.json";

export type Snapshot = {
  year: number;
  ranks: Array<{
    code: string;
    name: string;
    value: number;
    rank: number; // 1 が最上位
  }>;
};

export function buildSnapshots(): Snapshot[] {
  return raw.years.map((year, yearIdx) => {
    const ranks = raw.prefectures
      .map((pref) => ({
        code: pref.code,
        name: pref.name,
        value: pref.values[yearIdx] ?? 0,
      }))
      .sort((a, b) => b.value - a.value)
      .map((row, idx) => ({ ...row, rank: idx + 1 }));
    return { year, ranks };
  });
}

これで [{ year: 1990, ranks: [{code, name, value, rank}, ...] }, ...] が手に入ります。Composition 側ではこれを参照して毎フレーム描画する、というだけです。データ欠損年度 (null) は ?? 0 で 0 扱いにしていますが、Bar Chart Race では 「直前年で線形補間」する のが定石です。後述の「つまずきポイント」で詳しく触れます。

Step 4: Remotion で Composition 作成(interpolate でランキング遷移をスムーズに)

Composition の本体を書きます。骨子は「年 N と年 N+1 の間を補間して棒の位置と長さをアニメーションする」。Remotion の interpolate 関数が主役です。

// src/BarChartRace.tsx
import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate } from "remotion";
import { buildSnapshots } from "./data/buildSnapshots";

const SECONDS_PER_YEAR = 1.5; // 1 年あたり 1.5 秒
const TOP_N = 10;             // 上位 10 県だけ表示

export const BarChartRace: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps, width } = useVideoConfig();
  const snapshots = buildSnapshots();

  // 現在「何年と何年の間」にいるかを算出
  const framesPerYear = SECONDS_PER_YEAR * fps;
  const yearIdx = Math.min(
    Math.floor(frame / framesPerYear),
    snapshots.length - 2,
  );
  const progress = (frame % framesPerYear) / framesPerYear; // 0..1

  const cur = snapshots[yearIdx];
  const next = snapshots[yearIdx + 1];

  // 年の小数表示(例: 2003.4 → 2003 にフロアして表示)
  const displayYear = Math.floor(
    interpolate(progress, [0, 1], [cur.year, next.year]),
  );

  // 描画用に「補間後の順位・値」を作る
  const rows = cur.ranks
    .map((curRow) => {
      const nextRow = next.ranks.find((r) => r.code === curRow.code)!;
      const rank = interpolate(progress, [0, 1], [curRow.rank, nextRow.rank]);
      const value = interpolate(progress, [0, 1], [curRow.value, nextRow.value]);
      return { ...curRow, rank, value };
    })
    .sort((a, b) => a.rank - b.rank)
    .slice(0, TOP_N);

  const maxValue = rows[0].value;
  const barAreaWidth = width - 320; // 左に県名、右に値を表示する余白

  return (
    <AbsoluteFill style={{ background: "#0b1220", color: "white", padding: 48 }}>
      <h1 style={{ fontSize: 48, fontWeight: 800 }}>
        都道府県別 製造品出荷額 ({displayYear} 年)
      </h1>
      <div style={{ marginTop: 32 }}>
        {rows.map((row) => {
          const y = (row.rank - 1) * 88;
          const barWidth = (row.value / maxValue) * barAreaWidth;
          return (
            <div
              key={row.code}
              style={{
                position: "absolute",
                top: 120 + y,
                left: 0,
                right: 0,
                height: 72,
                display: "flex",
                alignItems: "center",
              }}
            >
              <div style={{ width: 160, fontSize: 32 }}>{row.name}</div>
              <div
                style={{
                  width: barWidth,
                  height: 56,
                  background: colorOf(row.code),
                  borderRadius: 8,
                }}
              />
              <div style={{ marginLeft: 16, fontSize: 28 }}>
                {Math.round(row.value).toLocaleString()} 億円
              </div>
            </div>
          );
        })}
      </div>
    </AbsoluteFill>
  );
};

// 都道府県コードに応じた色(実際は 47 色のパレットを別ファイルで管理)
function colorOf(code: string): string {
  const palette = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#06b6d4", "#3b82f6", "#8b5cf6", "#ec4899"];
  return palette[Number(code) % palette.length];
}

ポイントは 3 つ。

  1. rank を補間値(float)で持つ: ランクを整数で持つと、5 位→4 位の入れ替わりが「ガクッ」と瞬間切替してしまい、Bar Chart Race の見どころである「棒同士が滑らかにすれ違う動き」が出ません。interpolate で 5.0 → 4.0 と連続値にすることで、入れ替わり中の途中位置 (4.6 位など) を Y 座標として描画でき、ぬるっと動きます。
  2. value も補間する: 値も毎フレーム補間しないと棒の長さがカクつきます。
  3. sort してから slice(TOP_N): 上位 10 県だけ見せるなら、毎フレーム改めてソートして TOP_N を取り直すこと。前フレームの順位を引きずると、ランクインしたばかりの県が表示されません。

Composition の登録は src/Root.tsx で行います。

// src/Root.tsx
import { Composition } from "remotion";
import { BarChartRace } from "./BarChartRace";
import { buildSnapshots } from "./data/buildSnapshots";

const snapshots = buildSnapshots();
const SECONDS_PER_YEAR = 1.5;
const FPS = 30;

export const RemotionRoot: React.FC = () => {
  const totalSeconds = (snapshots.length - 1) * SECONDS_PER_YEAR + 2; // 末尾に余韻 2 秒
  return (
    <>
      <Composition
        id="BarChartRace-Vertical"
        component={BarChartRace}
        durationInFrames={Math.ceil(totalSeconds * FPS)}
        fps={FPS}
        width={1080}
        height={1920}
      />
      <Composition
        id="BarChartRace-Landscape"
        component={BarChartRace}
        durationInFrames={Math.ceil(totalSeconds * FPS)}
        fps={FPS}
        width={1920}
        height={1080}
      />
    </>
  );
};

縦型と横型を 2 本登録しておくと、SNS 用と YouTube 用で id を切り替えるだけで書き出せます。

Step 5: フレームレート / 解像度(縦型 1080x1920 for Reels/Shorts)

SNS 媒体ごとに最適な解像度・アスペクト比・尺は異なります。Remotion は Composition 単位で width height fps を変えられるので、媒体に合わせた書き出し設定を一覧化しておくと迷いません。

プラットフォーム解像度アスペクト比FPS推奨尺
Instagram Reels1080 × 19209:1630〜 90 秒
YouTube Shorts1080 × 19209:1630〜 60 秒
TikTok1080 × 19209:163015-60 秒
X (Twitter)1280 × 72016:930〜 140 秒
YouTube 通常1920 × 108016:930 / 60制限なし
LinkedIn1080 × 10801:130〜 30 秒

製造品出荷額 30 年 × 1.5 秒/年 = 45 秒。これに余韻 2 秒で計 47 秒。Reels / Shorts / TikTok すべてに収まります。

FPS は 30 で十分です。Bar Chart Race の動き量だと 60fps にしてもファイルサイズが倍になるだけで、視覚的な滑らかさは大きく変わりません。逆に 24fps だと棒の入れ替わりが微妙にカクつく ので、30fps を下回らないこと。

設定値1 分動画のおおよそのファイルサイズ (H.264)
1080x1920 / 30fps15-25 MB
1080x1920 / 60fps30-50 MB
1920x1080 / 30fps20-35 MB
1920x1080 / 60fps40-70 MB

Instagram の Reels は アップロード上限 300 MB なので、30fps なら 10 分まで余裕。気にしなくて良いです。

Step 6: MP4 書き出し(npx remotion render)

ローカルでのレンダリングは 1 行です。

# 縦型 (Reels/Shorts/TikTok 用)
npx remotion render BarChartRace-Vertical out/bar-chart-race-vertical.mp4

# 横型 (YouTube/X 用)
npx remotion render BarChartRace-Landscape out/bar-chart-race-landscape.mp4

初回は Chrome Headless がダウンロードされるため数分かかります(約 200 MB)。2 回目以降は 47 秒の動画なら Apple M2 で 20-40 秒くらいで終わります。

オプションも豊富で、よく使うものを並べておきます。

# 解像度を後から上書き(Composition と別解像度で書き出したい時)
npx remotion render BarChartRace-Vertical --width 720 --height 1280 out/preview.mp4

# 並列レンダリング(CPU 全使用)
npx remotion render BarChartRace-Vertical --concurrency=8 out/bar.mp4

# プロキシ用に低品質プレビュー
npx remotion render BarChartRace-Vertical --crf 28 out/preview.mp4

# 静止画 (PNG) として 1 フレーム抜く(サムネ用)
npx remotion still BarChartRace-Vertical out/thumbnail.png --frame=900

CI で自動化するなら GitHub Actions に上記コマンドを書くだけ。Remotion 公式が提供する Docker イメージを使えば Chrome ダウンロードもキャッシュされて、毎日新しい動画を自動生成するパイプラインが組めます。stats47 でも apps/remotion でこの構成にしており、Composition のコードを差し替えれば翌日には新作 Reels が SNS にデプロイされます。

SNS 用 captions(人気拡散のコツ)

Bar Chart Race は動画自体が情報量 100% なので、キャプションは 「視聴者に何を見てほしいか」を 1 文に絞る のが鉄則です。長文の説明を入れても、動画再生中は誰も読みません。

プラットフォームキャプション例
Instagram Reels「30 年で愛知が独走、北関東が躍進。」 + ハッシュタグ 10 個前後
YouTube Shorts「【製造業】47 都道府県ランキング 1990-2023」 + 概要欄に元データ URL
TikTok1 行短文 + #日本地理 #統計 #意外と知らない
X (Twitter)「製造品出荷額 30 年の都道府県順位推移。あなたの県は何位?」+ 動画添付

ハッシュタグは トレンド系 (#日本 #ランキング) と専門系 (#製造業 #工業統計) を 1:1 で混ぜる と発見性が伸びます。stats47 の運用実績では、Reels で「県名を 3 つ以上含む」キャプションがエンゲージメントが約 1.4 倍。視聴者は自分の都道府県をエゴサして反応する習性があるためです。

つまずきポイント

つまずき 1: 同順位のフリッカ

複数の県が 同じ値(または小数点以下で僅差) のとき、フレームごとにソート結果が入れ替わって棒がチカチカします。対策は 2 つ。

// 対策 A: 第二ソートキーに県コードを使って安定ソート化
.sort((a, b) => b.value - a.value || a.code.localeCompare(b.code));

// 対策 B: 同順位を許容してランクを duplicate
const ranked = sorted.map((row, idx, arr) => ({
  ...row,
  rank: arr.findIndex((r) => r.value === row.value) + 1, // 同値は同ランク
}));

stats47 では対策 A を採用しています。同順位 (=) を見せるより、安定した順位入れ替えの方が動画として読みやすいためです。

つまずき 2: データ欠損年度の補間

工業統計でも、5 年ごとの構造調査年と毎年の簡易調査年で県別の有無が違ったり、市町村合併で系列が切れる県があります。values[i]null のとき、何も対策しないと棒が一気に 0 まで縮んで Bar Chart Race が崩壊します。

// 線形補間で null を埋める
function fillNulls(values: (number | null)[]): number[] {
  const out = [...values] as number[];
  for (let i = 0; i < out.length; i++) {
    if (out[i] != null) continue;
    const prev = out.slice(0, i).reverse().find((v) => v != null);
    const next = out.slice(i + 1).find((v) => v != null);
    if (prev != null && next != null) {
      // 線形補間
      const prevIdx = out.lastIndexOf(prev, i);
      const nextIdx = out.indexOf(next, i);
      out[i] = prev + ((next - prev) * (i - prevIdx)) / (nextIdx - prevIdx);
    } else {
      out[i] = prev ?? next ?? 0;
    }
  }
  return out;
}

Claude Code に「manufacturing.jsonvaluesnull がある県を線形補間して埋めて」と頼めば同等のコードを書いてくれます。

つまずき 3: 軸スケール変動

Bar Chart Race は最上位の値(maxValue)で棒の長さを正規化しますが、30 年で最上位の値そのものが伸びる(または縮む)と、過去と未来で「同じ長さの棒が違う額を示す」 という認知バグが起きます。

対策 1: 軸を 全期間の最大値で固定

const globalMax = Math.max(...snapshots.flatMap((s) => s.ranks.map((r) => r.value)));
const barWidth = (row.value / globalMax) * barAreaWidth;

対策 2: 値ラベルを必ず表示(億円単位)。視聴者が長さではなく数字で判断できるようにする。

stats47 では 両方とも採用 しています。動画は数秒で流れていくので、冗長なくらい情報を冗長化した方が伝わります。

次回予告(Part 9: レーダーチャート)

Part 9 では、47 都道府県の「多次元プロフィール」を一発で見せる レーダーチャート を Claude Code + D3.js で作ります。たとえば「東京都の人口密度・所得・大学進学率・出生率を 5 角形で示す」みたいな絵です。Bar Chart Race は時系列でしたが、レーダーは「ある時点での横比較」が得意。両者を使い分けられると、データ可視化の引き出しが一気に広がります。

レーダーチャートも実は実装が地味に難しく、特に「軸の正規化(人口密度と出生率を同じスケールに乗せる方法)」と「3 県以上の重ね描画でラベルが衝突する問題」のトレードオフが面白いポイントです。お楽しみに。

関連ランキング・記事

本記事で扱った製造品出荷額の最新ランキングや、関連する Claude Code × e-Stat 連載は以下から。


ここまでで Bar Chart Race の MP4 が手元に出力できているはずです。Reels や TikTok に投稿すれば、「製造業ってこんなに県によって違うのか」と多くの人に届く動画資産 が手に入ります。Claude Code はデータ取得・整形・コード生成の 3 段階すべてで効くので、Remotion を触ったことがない人でも 1 日かからずに 1 本仕上がります。次回は静止画の表現力を一気に上げるレーダーチャートで会いましょう。