BLK-96Blocks

アイソメトリック3D棒グラフ(月ごとの推移を立体で比べる)

月ごとの推移を斜め上から見た立体の棒で並べる、アイソメトリックの3D棒グラフ。手前の列が今年度、奥の列が前年度で、奥行きの軸をそのまま前年との比較に使う。問い合わせ・受注・売上の3項目を本物のラジオボタンで切り替えると、棒が今の高さから新しい高さへ月ごとに少しずつ遅れて伸び縮みし、冒頭の合計・前年比・最も多い月と、右の月別の表(今年度・前年度・前年比・合計)も同じデータで替わる。棒に触れると値の札が出て、押すか表の月ボタン(aria-pressed)を選ぶとその月が強調され、図の下に今年度と前年度の値・前年比を出す。図の上では矢印キーで月を移り、Home/End で端へ、Escape で選択を外すので、キーボードだけで操作が完結する。前年度の列はチェックボックスで消せる。軸の最大値は4等分してきりのいい数を選ぶため、目盛り線は動かさず数字と棒の高さだけが替わる。正射影カメラを原点に向けて置き、カメラ空間の座標を1つの射影関数で出すので、3D(three)と2Dの代替図(SVG)は同じ角度・同じ枠で描かれる。3Dライブラリは画面に入った実寸表示のときだけ動的importで読み込み、一覧の縮小カード・WebGL非対応・コンテキスト喪失では、画家のアルゴリズムで描いた同じ射影のSVGで同じ棒・選択・値の札を出す。描画はオンデマンドで、伸縮が終わると描画ループを止める。prefers-reduced-motion では補間せず最終状態を1枚だけ描く。英字の小見出し・末尾の矢印リンク・角丸・スクロール出現は使わず、hairline 罫線と余白で組んでいる。数値はサンプル。

追加日:
2026-10-12
依存:
three
tags
#3d #bar-chart #isometric #data-visualization #chart #webgl #orthographic-camera #interactive #keyboard #table #no-image #dynamic-import

プレビュー

1年の伸びを、高さでひと目に比べる

月ごとの推移を斜め上から見た立体の棒で並べます。手前が今年度、奥が前年度です。項目を切り替えると棒が伸び縮みし、棒に触れると値が出ます。同じ数字は右の表でも読めます。

今年度の合計
741件
前年比
+19.5%
最も多い月
3月
項目

棒に触れると値・押すと月を選択(矢印キーで月を移動・Escで解除)

月を選ぶと、その月の今年度と前年度の値、前年比をここに出します。棒を押すか、表の月を選んでください。

月別の問い合わせ(件)
月今年度前年度前年比
4238+10.5%
4841+17.1%
5547+17.0%
5150+2.0%
4639+17.9%
6352+21.2%
7158+22.4%
7861+27.9%
6657+15.8%
5849+18.4%
7460+23.3%
8968+30.9%
合計741620+19.5%

月を押すと図の棒も選ばれます。もう一度押すと解除します

数値は見せ方を示すための見本です。項目ごとに月別の今年度と前年度の値を並べれば、軸の目盛り・棒の高さ・表は同じデータから作られます。

"use client";

import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import { Montserrat } from "next/font/google";
// 型だけの import。tsc が消すので 3Dライブラリのコードは1バイトも入らない(実体は下の動的 import)。
import type * as THREE from "three";

const montserrat = Montserrat({ weight: ["500", "600", "700"], subsets: ["latin"], display: "swap" });

/**
 * アイソメトリック3D棒グラフ(月ごとの推移を立体で比べる)
 *
 * 差別化: 既存の3D(product-configurator-3d・isometric-floor-map・section-cut-viewer)は物や空間を見せる表現で、
 * effort-flow-diagram・company-numbers-grid は平面の図と数字だった。本ブロックは時系列の数値を比べるための3Dで、
 * 奥行きの軸を「前年度の列」に使い、項目を切り替えると棒が伸び縮みし、同じデータの表と選択が同期する。
 * 英字の小見出し・見出しの「。」止め・末尾の矢印リンク・角丸・スクロール出現は置かない。
 *
 * 汎用技法メモ:
 * 1. **2Dと3Dで同じ射影を1つの関数から作る**。正射影カメラを原点に向けて置くと、カメラ空間の座標は
 *    「右ベクトル・上ベクトルとの内積」そのものになる。SVG の代替図も同じ関数で頂点を出せば、角度も枠の取り方も一致する。
 * 2. 軸の最大値は「4等分してきりのいい数」になるよう選ぶ(刻み候補 1・1.2・1.5・2・2.5・3・4・5・6・8 ×10^k)。
 *    目盛り線の高さが常に同じ4本で済むので、項目を替えても線は動かさず、数字と棒の高さだけを替えればよい。
 * 3. 棒の伸縮は rAF の時刻で補間し、月ごとに少しずつ遅らせて始める。開始時刻は最初のフレームで決めるので、
 *    止まっている間に値が変わっても補間が飛ばない。prefers-reduced-motion・画面外では最終状態を1枚だけ描く。
 * 4. SVG の代替は画家のアルゴリズムで描く。カメラが右手前の上にいるので「奥の列→手前の列」「左→右」の順に、
 *    各角柱の見える3面(上・手前・右)だけを塗れば前後を取り違えない。
 * 5. 入力の正は本物のラジオボタン(項目)・チェックボックス(前年度の列)・表の月ボタン(aria-pressed)。
 *    図の上では矢印キーで月を移り、Escape で選択を外す。3D のクリックとホバーは近道。
 *
 * 数値はすべて First CH のオリジナルの見本(見せ方を示すためのサンプル)。
 */

type ThreeModule = typeof import("three");

/* ------------------------------------------------------------------ データ */

const MONTHS = ["4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月", "1月", "2月", "3月"];

type SeriesId = "inquiry" | "orders" | "sales";
type Series = { id: SeriesId; label: string; unit: string; current: number[]; previous: number[] };

const SERIES: Series[] = [
  {
    id: "inquiry",
    label: "問い合わせ",
    unit: "件",
    current: [42, 48, 55, 51, 46, 63, 71, 78, 66, 58, 74, 89],
    previous: [38, 41, 47, 50, 39, 52, 58, 61, 57, 49, 60, 68],
  },
  {
    id: "orders",
    label: "受注",
    unit: "件",
    current: [9, 11, 13, 12, 10, 15, 17, 19, 16, 13, 18, 22],
    previous: [8, 9, 11, 12, 9, 12, 13, 15, 13, 11, 14, 16],
  },
  {
    id: "sales",
    label: "売上",
    unit: "万円",
    current: [310, 365, 420, 398, 342, 510, 560, 640, 545, 450, 600, 720],
    previous: [280, 300, 350, 380, 290, 410, 440, 500, 450, 380, 470, 550],
  },
];
const SERIES_BY_ID = Object.fromEntries(SERIES.map((s) => [s.id, s])) as Record<SeriesId, Series>;

const STEPS = [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10];
/** 4等分してきりのいい最大値 */
function axisMaxOf(s: Series) {
  const max = Math.max(...s.current, ...s.previous);
  const raw = max / 4;
  const p = Math.pow(10, Math.floor(Math.log10(raw)));
  const step = (STEPS.find((k) => k * p >= raw) ?? 10) * p;
  return step * 4;
}

const fmt = (v: number) => v.toLocaleString("ja-JP");
const sum = (a: number[]) => a.reduce((t, v) => t + v, 0);
function yoy(a: number, b: number) {
  const r = ((a - b) / b) * 100;
  return `${r >= 0 ? "+" : "−"}${Math.abs(r).toFixed(1)}%`;
}

/* ------------------------------------------------------------------ 形と射影(単位は棒の間隔=1) */

const N = MONTHS.length;
const BAR_H = 4; // 軸の最大値の高さ
const ROW_CUR = 1.1; // 今年度の列(手前)
const ROW_PREV = 0; // 前年度の列(奥)
const W_CUR = 0.62;
const W_PREV = 0.46;
const X0 = -0.6;
const X1 = N - 1 + 0.6;
const Z0 = -0.55;
const Z1 = ROW_CUR + 0.55;
const TICKS = [0, 0.25, 0.5, 0.75, 1];

const YAW = (30 * Math.PI) / 180;
const ELEV = (31 * Math.PI) / 180;
const CY = Math.cos(YAW);
const SY = Math.sin(YAW);
const CE = Math.cos(ELEV);
const SE = Math.sin(ELEV);

type P2 = [number, number];
/** カメラ空間の [右, 上]。カメラは原点を向いているので、右ベクトル・上ベクトルとの内積がそのまま座標になる */
const proj = (x: number, y: number, z: number): P2 => [x * CY - z * SY, -x * SY * SE + y * CE - z * CY * SE];

/** 見せる範囲(目盛りの数字・月名・値の札のぶん余白を足す) */
const FRAME = (() => {
  let minX = Infinity;
  let maxX = -Infinity;
  let minY = Infinity;
  let maxY = -Infinity;
  for (const x of [X0, X1]) for (const y of [0, BAR_H]) for (const z of [Z0, Z1]) {
    const [a, b] = proj(x, y, z);
    minX = Math.min(minX, a);
    maxX = Math.max(maxX, a);
    minY = Math.min(minY, b);
    maxY = Math.max(maxY, b);
  }
  return { minX: minX - 0.95, maxX: maxX + 0.2, minY: minY - 0.75, maxY: maxY + 0.7 };
})();

const xOf = (i: number) => i;
const heightOf = (v: number, axisMax: number) => (v / axisMax) * BAR_H;

type Focus = { i: number; row: 0 | 1 } | null; // row 0 = 今年度, 1 = 前年度

/* ------------------------------------------------------------------ 3D */

type ViewerApi = {
  setData: (seriesId: SeriesId) => void;
  setShowPrev: (on: boolean) => void;
  setHighlight: (selected: number | null, focus: Focus) => void;
  setRunning: (running: boolean) => void;
  dispose: () => void;
};

type ViewerOptions = {
  canvas: HTMLCanvasElement;
  host: HTMLElement;
  monthLabels: (HTMLElement | null)[];
  tickLabels: (HTMLElement | null)[];
  tag: HTMLElement | null;
  seriesId: SeriesId;
  showPrev: boolean;
  onHover: (f: Focus) => void;
  onPick: (i: number) => void;
  onFirstFrame: () => void;
  onFail: () => void;
};

const GROW = 0.62; // 秒
const STAGGER = 0.035; // 秒

function createViewer(T: ThreeModule, opts: ViewerOptions): ViewerApi {
  const { canvas, host, monthLabels, tickLabels, onHover, onPick, onFirstFrame, onFail } = opts;

  const renderer = new T.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: "low-power" });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
  renderer.toneMapping = T.NeutralToneMapping;

  const scene = new T.Scene();
  const camera = new T.OrthographicCamera(-1, 1, 1, -1, 0.1, 100);
  camera.position.set(SY * CE, SE, CY * CE).multiplyScalar(40);
  camera.lookAt(0, 0, 0);

  const geometries: THREE.BufferGeometry[] = [];
  const materials: THREE.Material[] = [];
  const keepG = <G extends THREE.BufferGeometry>(g: G) => (geometries.push(g), g);
  const keepM = <M extends THREE.Material>(m: M) => (materials.push(m), m);

  // 上面が最も明るく、右面・手前の面の順に暗くなる向きから当てる
  scene.add(new T.HemisphereLight(0xffffff, 0xd6d3d1, 1.5));
  const key = new T.DirectionalLight(0xffffff, 1.6);
  key.position.set(6, 12, 2.5);
  scene.add(key);

  const unitBox = keepG(new T.BoxGeometry(1, 1, 1));
  unitBox.translate(0, 0.5, 0); // 底面を原点に置き、高さは scale.y だけで伸ばす
  const unitEdges = keepG(new T.EdgesGeometry(unitBox));

  const matCur = keepM(new T.MeshLambertMaterial({ color: 0xd97706 }));
  const matCurDim = keepM(new T.MeshLambertMaterial({ color: 0xfae6c6, emissive: 0xd9a25a, emissiveIntensity: 0.4 }));
  const matPrev = keepM(new T.MeshLambertMaterial({ color: 0xd6d3d1 }));
  const matPrevOn = keepM(new T.MeshLambertMaterial({ color: 0x8f8a85 }));
  const edgeMat = keepM(new T.LineBasicMaterial({ color: 0x78716c, transparent: true, opacity: 0.35 }));
  const edgeOn = keepM(new T.LineBasicMaterial({ color: 0x78350f, toneMapped: false }));
  const gridMat = keepM(new T.LineBasicMaterial({ color: 0xa8a29e, transparent: true, opacity: 0.55 }));

  // 床
  const floor = new T.Mesh(keepG(new T.PlaneGeometry(X1 - X0, Z1 - Z0)), keepM(new T.MeshBasicMaterial({ color: 0xf5f3ef, toneMapped: false })));
  floor.rotation.x = -Math.PI / 2;
  floor.position.set((X0 + X1) / 2, -0.002, (Z0 + Z1) / 2);
  scene.add(floor);

  // 目盛り線(奥の壁と左の壁)。最大値は常に4等分なので線は動かさない
  const pts: number[] = [];
  for (const t of TICKS) {
    const y = t * BAR_H;
    pts.push(X0, y, Z0, X1, y, Z0, X0, y, Z0, X0, y, Z1);
  }
  pts.push(X0, 0, Z0, X0, BAR_H, Z0, X1, 0, Z0, X1, BAR_H, Z0, X0, 0, Z1, X0, BAR_H * 0.06, Z1, X1, 0, Z0, X1, 0, Z1, X0, 0, Z1, X1, 0, Z1);
  const gridGeo = keepG(new T.BufferGeometry());
  gridGeo.setAttribute("position", new T.Float32BufferAttribute(pts, 3));
  scene.add(new T.LineSegments(gridGeo, gridMat));

  type Bar = { mesh: THREE.Mesh; edges: THREE.LineSegments; i: number; row: 0 | 1 };
  const bars: Bar[] = [];
  for (let i = 0; i < N; i++) {
    for (const row of [0, 1] as const) {
      const w = row === 0 ? W_CUR : W_PREV;
      const mesh = new T.Mesh(unitBox, row === 0 ? matCur : matPrev);
      mesh.position.set(xOf(i), 0, row === 0 ? ROW_CUR : ROW_PREV);
      mesh.scale.set(w, 0.001, w);
      mesh.userData = { i, row };
      const edges = new T.LineSegments(unitEdges, edgeMat);
      mesh.add(edges);
      scene.add(mesh);
      bars.push({ mesh, edges, i, row });
    }
  }

  /* --- 状態 --- */
  const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  let motionOK = !motionQuery.matches;
  let running = false;
  let raf = 0;
  let firstFrame = true;
  let seriesId = opts.seriesId;
  let showPrev = opts.showPrev;
  let selected: number | null = null;
  let focus: Focus = null;
  let width = 1;
  let height = 1;

  const targetsOf = (id: SeriesId) => {
    const s = SERIES_BY_ID[id];
    const max = axisMaxOf(s);
    return bars.map((b) => heightOf(b.row === 0 ? s.current[b.i] : s.previous[b.i], max));
  };
  // 初回は高さ0から伸ばす(動かせない状況なら kick が最終状態に揃える)
  let from = bars.map(() => 0);
  let to = targetsOf(seriesId);
  let now = bars.map(() => 0);
  let startAt: number | null = null;

  const applyHighlight = () => {
    for (const b of bars) {
      const on = b.i === selected || (focus !== null && focus.i === b.i && focus.row === b.row);
      const dim = selected !== null && b.i !== selected && !on;
      if (b.row === 0) b.mesh.material = dim ? matCurDim : matCur;
      else b.mesh.material = on && (focus?.row === 1 || b.i === selected) ? matPrevOn : matPrev;
      b.edges.material = on ? edgeOn : edgeMat;
      b.mesh.visible = b.row === 0 || showPrev;
    }
  };

  const placeCamera = () => {
    let { minX, maxX, minY, maxY } = FRAME;
    const fw = maxX - minX;
    const fh = maxY - minY;
    const aspect = width / height;
    if (fw / fh > aspect) {
      const add = (fw / aspect - fh) / 2;
      minY -= add;
      maxY += add;
    } else {
      const add = (fh * aspect - fw) / 2;
      minX -= add;
      maxX += add;
    }
    camera.left = minX;
    camera.right = maxX;
    camera.top = maxY;
    camera.bottom = minY;
    camera.updateProjectionMatrix();
  };

  const anchor = new T.Vector3();
  const toScreen = (x: number, y: number, z: number): P2 => {
    anchor.set(x, y, z).project(camera);
    return [((anchor.x + 1) / 2) * width, ((1 - anchor.y) / 2) * height];
  };
  const placeLabels = () => {
    monthLabels.forEach((el, i) => {
      if (!el) return;
      const [x, y] = toScreen(xOf(i), 0, Z1 + 0.12);
      el.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) translate(-50%, 0)`;
    });
    tickLabels.forEach((el, k) => {
      if (!el) return;
      const [x, y] = toScreen(X0, TICKS[k] * BAR_H, Z1);
      el.style.transform = `translate(${(x - 6).toFixed(1)}px, ${y.toFixed(1)}px) translate(-100%, -50%)`;
    });
    const tag = opts.tag;
    if (tag) {
      const f = focus ?? (selected !== null ? { i: selected, row: 0 as const } : null);
      if (!f || (f.row === 1 && !showPrev)) {
        tag.style.opacity = "0";
      } else {
        const idx = f.i * 2 + f.row;
        const [x, y] = toScreen(xOf(f.i), now[idx] + 0.08, f.row === 0 ? ROW_CUR : ROW_PREV);
        tag.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) translate(-50%, -100%)`;
        tag.style.opacity = "1";
      }
    }
  };

  const renderOnce = () => {
    bars.forEach((b, k) => b.mesh.scale.setY(Math.max(now[k], 0.001)));
    placeCamera();
    renderer.render(scene, camera);
    placeLabels();
    if (firstFrame) {
      firstFrame = false;
      onFirstFrame();
    }
  };

  const resize = () => {
    const rect = host.getBoundingClientRect();
    width = Math.max(1, Math.round(rect.width));
    height = Math.max(1, Math.round(rect.height));
    renderer.setSize(width, height, false);
  };

  const settle = () => {
    now = [...to];
    from = [...to];
    startAt = null;
    renderOnce();
  };

  function stop() {
    if (!raf) return;
    cancelAnimationFrame(raf);
    raf = 0;
  }

  const ease = (t: number) => 1 - Math.pow(1 - t, 3);
  const frameLoop = (ms: number) => {
    raf = requestAnimationFrame(frameLoop);
    const t = ms / 1000;
    // 開始時刻は最初のフレームで決める(止まっている間に値が変わっても補間が飛ばない)
    if (startAt === null) startAt = t;
    let done = true;
    for (let k = 0; k < bars.length; k++) {
      const p = Math.min(Math.max((t - startAt - bars[k].i * STAGGER) / GROW, 0), 1);
      if (p < 1) done = false;
      now[k] = from[k] + (to[k] - from[k]) * ease(p);
    }
    if (done) {
      stop();
      settle();
      return;
    }
    renderOnce();
  };

  /** 値が変わったときに呼ぶ。動かせない状況なら最終状態を1枚だけ描く */
  const kick = () => {
    if (!running || !motionOK) {
      stop();
      settle();
      return;
    }
    if (raf) return;
    raf = requestAnimationFrame(frameLoop);
  };

  const onMotionChange = () => {
    motionOK = !motionQuery.matches;
    kick();
  };
  motionQuery.addEventListener("change", onMotionChange);

  /* --- 操作: ホバーで値・クリックで月を選ぶ --- */
  const raycaster = new T.Raycaster();
  const ndc = new T.Vector2();
  const pickAt = (clientX: number, clientY: number): Focus => {
    const rect = canvas.getBoundingClientRect();
    ndc.set(((clientX - rect.left) / rect.width) * 2 - 1, -((clientY - rect.top) / rect.height) * 2 + 1);
    raycaster.setFromCamera(ndc, camera);
    const list = bars.filter((b) => b.mesh.visible).map((b) => b.mesh);
    const hit = raycaster.intersectObjects(list, false)[0];
    if (!hit) return null;
    const d = hit.object.userData as { i: number; row: 0 | 1 };
    return { i: d.i, row: d.row };
  };
  let lastHover = "";
  const onPointerMove = (e: PointerEvent) => {
    if (e.pointerType !== "mouse") return;
    const f = pickAt(e.clientX, e.clientY);
    canvas.style.cursor = f ? "pointer" : "default";
    const keyStr = f ? `${f.i}-${f.row}` : "";
    if (keyStr === lastHover) return;
    lastHover = keyStr;
    onHover(f);
  };
  const onPointerLeave = () => {
    if (!lastHover) return;
    lastHover = "";
    onHover(null);
  };
  let downX = 0;
  let downY = 0;
  const onPointerDown = (e: PointerEvent) => {
    downX = e.clientX;
    downY = e.clientY;
  };
  const onPointerUp = (e: PointerEvent) => {
    if (e.button !== 0 || Math.hypot(e.clientX - downX, e.clientY - downY) > 6) return;
    const f = pickAt(e.clientX, e.clientY);
    if (f) onPick(f.i);
  };
  canvas.addEventListener("pointermove", onPointerMove);
  canvas.addEventListener("pointerleave", onPointerLeave);
  canvas.addEventListener("pointerdown", onPointerDown);
  canvas.addEventListener("pointerup", onPointerUp);

  const onContextLost = (e: Event) => {
    e.preventDefault();
    stop();
    onFail();
  };
  canvas.addEventListener("webglcontextlost", onContextLost);

  // canvas の寸法を書き換えると中身が消えるので、止まっていても必ず1枚描き直す
  const ro = new ResizeObserver(() => {
    resize();
    renderOnce();
  });
  ro.observe(host);
  applyHighlight();
  resize();
  renderOnce();

  return {
    setData: (id) => {
      if (id === seriesId) return;
      seriesId = id;
      from = [...now];
      to = targetsOf(id);
      startAt = null;
      stop();
      kick();
    },
    setShowPrev: (on) => {
      showPrev = on;
      applyHighlight();
      if (!raf) renderOnce();
    },
    setHighlight: (sel, f) => {
      selected = sel;
      focus = f;
      applyHighlight();
      if (!raf) renderOnce();
    },
    setRunning: (next) => {
      running = next;
      if (!next) stop();
      else if (startAt !== null || now.some((v, k) => v !== to[k])) kick();
    },
    dispose: () => {
      stop();
      ro.disconnect();
      motionQuery.removeEventListener("change", onMotionChange);
      canvas.removeEventListener("pointermove", onPointerMove);
      canvas.removeEventListener("pointerleave", onPointerLeave);
      canvas.removeEventListener("pointerdown", onPointerDown);
      canvas.removeEventListener("pointerup", onPointerUp);
      canvas.removeEventListener("webglcontextlost", onContextLost);
      for (const g of geometries) g.dispose();
      for (const m of materials) m.dispose();
      renderer.dispose();
      renderer.forceContextLoss();
    },
  };
}

/* ------------------------------------------------------------------ 2D(同じ射影の SVG) */

const S = 40; // 1単位あたりの SVG 単位
const sv = (x: number, y: number, z: number): P2 => {
  const [a, b] = proj(x, y, z);
  return [a * S, -b * S];
};
const poly = (p: P2[]) => p.map(([a, b]) => `${a.toFixed(1)},${b.toFixed(1)}`).join(" ");
const VIEWBOX = `${(FRAME.minX * S).toFixed(1)} ${(-FRAME.maxY * S).toFixed(1)} ${((FRAME.maxX - FRAME.minX) * S).toFixed(1)} ${((FRAME.maxY - FRAME.minY) * S).toFixed(1)}`;

const FACES = {
  cur: ["#eaa543", "#d97706", "#b45309"],
  curDim: ["#fbe6c4", "#f3cf98", "#e9bb7b"],
  prev: ["#efeeec", "#d6d3d1", "#bfbab5"],
  prevOn: ["#b5b0ab", "#8f8a85", "#77726d"],
} as const;

type IsoBarsProps = {
  series: Series;
  showPrev: boolean;
  selected: number | null;
  focus: Focus;
  onHover: (f: Focus) => void;
  onPick: (i: number) => void;
};

function IsoBars({ series, showPrev, selected, focus, onHover, onPick }: IsoBarsProps) {
  const max = axisMaxOf(series);
  const tagFocus = focus ?? (selected !== null ? { i: selected, row: 0 as const } : null);
  const rows = (showPrev ? [1, 0] : [0]) as (0 | 1)[]; // 奥の列から描く
  const prism = (i: number, row: 0 | 1) => {
    const v = row === 0 ? series.current[i] : series.previous[i];
    const h = Math.max(heightOf(v, max), 0.001);
    const w = row === 0 ? W_CUR : W_PREV;
    const cx = xOf(i);
    const cz = row === 0 ? ROW_CUR : ROW_PREV;
    const ax = cx - w / 2;
    const bx = cx + w / 2;
    const az = cz - w / 2;
    const bz = cz + w / 2;
    const on = i === selected || (focus !== null && focus.i === i && focus.row === row);
    const dim = selected !== null && i !== selected && !on;
    const [top, side, front] = row === 0 ? (dim ? FACES.curDim : FACES.cur) : on ? FACES.prevOn : FACES.prev;
    const stroke = on ? "#78350f" : "rgba(120,113,108,0.35)";
    return (
      <g
        key={`${row}-${i}`}
        className="cursor-pointer"
        onPointerEnter={(e) => e.pointerType === "mouse" && onHover({ i, row })}
        onPointerLeave={(e) => e.pointerType === "mouse" && onHover(null)}
        onClick={() => onPick(i)}
      >
        <polygon points={poly([sv(ax, 0, bz), sv(bx, 0, bz), sv(bx, h, bz), sv(ax, h, bz)])} fill={front} stroke={stroke} strokeWidth={0.6} />
        <polygon points={poly([sv(bx, 0, az), sv(bx, 0, bz), sv(bx, h, bz), sv(bx, h, az)])} fill={side} stroke={stroke} strokeWidth={0.6} />
        <polygon points={poly([sv(ax, h, az), sv(bx, h, az), sv(bx, h, bz), sv(ax, h, bz)])} fill={top} stroke={stroke} strokeWidth={0.6} />
      </g>
    );
  };

  let tag: { x: number; y: number; text: string } | null = null;
  if (tagFocus && (tagFocus.row === 0 || showPrev)) {
    const v = tagFocus.row === 0 ? series.current[tagFocus.i] : series.previous[tagFocus.i];
    const [x, y] = sv(xOf(tagFocus.i), heightOf(v, max) + 0.08, tagFocus.row === 0 ? ROW_CUR : ROW_PREV);
    tag = { x, y, text: `${MONTHS[tagFocus.i]} ${tagFocus.row === 0 ? "今年度" : "前年度"} ${fmt(v)}${series.unit}` };
  }

  return (
    <svg viewBox={VIEWBOX} preserveAspectRatio="xMidYMid meet" className="block size-full" aria-hidden="true">
      <polygon points={poly([sv(X0, 0, Z0), sv(X1, 0, Z0), sv(X1, 0, Z1), sv(X0, 0, Z1)])} fill="#f5f3ef" stroke="#a8a29e" strokeOpacity={0.55} strokeWidth={0.7} />
      <g stroke="#a8a29e" strokeOpacity={0.55} strokeWidth={0.7} fill="none">
        {TICKS.map((t) => (
          <polyline key={t} points={poly([sv(X1, t * BAR_H, Z0), sv(X0, t * BAR_H, Z0), sv(X0, t * BAR_H, Z1)])} />
        ))}
        <polyline points={poly([sv(X0, 0, Z0), sv(X0, BAR_H, Z0)])} />
        <polyline points={poly([sv(X1, 0, Z0), sv(X1, BAR_H, Z0)])} />
      </g>
      {rows.map((row) => Array.from({ length: N }, (_, i) => prism(i, row)))}
      <g className={montserrat.className} fontSize={11} fill="#57534e">
        {TICKS.map((t) => {
          const [x, y] = sv(X0, t * BAR_H, Z1);
          return (
            <text key={t} x={x - 6} y={y + 4} textAnchor="end" fontWeight={500}>
              {fmt(t * max)}
            </text>
          );
        })}
        {MONTHS.map((m, i) => {
          const [x, y] = sv(xOf(i), 0, Z1 + 0.12);
          return (
            <text key={m} x={x} y={y + 12} textAnchor="middle" fontWeight={i === selected ? 700 : 500} fill={i === selected ? "#b45309" : "#57534e"}>
              {m}
            </text>
          );
        })}
      </g>
      {tag && (
        <g>
          <rect x={tag.x - tag.text.length * 4.6} y={tag.y - 22} width={tag.text.length * 9.2} height={18} fill="#1c1917" />
          <text x={tag.x} y={tag.y - 9} textAnchor="middle" fontSize={11} fontWeight={700} fill="#ffffff">
            {tag.text}
          </text>
        </g>
      )}
    </svg>
  );
}

/* ------------------------------------------------------------------ 本体 */

export default function IsometricBarChart() {
  const uid = useId().replace(/:/g, "");
  const headingId = `bars-heading-${uid}`;
  const hintId = `bars-hint-${uid}`;
  const tableId = `bars-table-${uid}`;
  const radioName = `bars-series-${uid}`;

  const stageRef = useRef<HTMLDivElement>(null);
  const plotRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const monthRefs = useRef<(HTMLElement | null)[]>([]);
  const tickRefs = useRef<(HTMLElement | null)[]>([]);
  const tagRef = useRef<HTMLSpanElement>(null);
  const apiRef = useRef<ViewerApi | null>(null);

  const [live, setLive] = useState(false);
  const [inView, setInView] = useState(false);
  const [pageVisible, setPageVisible] = useState(true);
  const [ready, setReady] = useState(false);
  const [failed, setFailed] = useState(false);
  const [seriesId, setSeriesId] = useState<SeriesId>("inquiry");
  const [showPrev, setShowPrev] = useState(true);
  const [selected, setSelected] = useState<number | null>(null);
  const [hover, setHover] = useState<Focus>(null);
  const [announce, setAnnounce] = useState("");

  const initialRef = useRef({ seriesId, showPrev });
  useEffect(() => {
    initialRef.current = { seriesId, showPrev };
  }, [seriesId, showPrev]);

  // 起動条件: 画面内 かつ 実寸表示。一覧のカードは親に scale が掛かっているので
  // rect.width(変形後)÷ offsetWidth(レイアウト幅)が 1 未満になり、3Dライブラリを読み込まない。
  useEffect(() => {
    const el = stageRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const fullSize = el.offsetWidth > 0 ? rect.width / el.offsetWidth > 0.75 : true;
    const io = new IntersectionObserver(
      ([entry]) => {
        setInView(entry.isIntersecting);
        if (entry.isIntersecting && fullSize) setLive(true);
      },
      { rootMargin: "160px" },
    );
    io.observe(el);
    const onVisibility = () => setPageVisible(document.visibilityState === "visible");
    document.addEventListener("visibilitychange", onVisibility);
    return () => {
      io.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, []);

  useEffect(() => {
    if (!live) return;
    const canvas = canvasRef.current;
    const host = plotRef.current;
    if (!canvas || !host) return;
    let cancelled = false;
    const boot = async () => {
      // 非対応なら重いライブラリを取りに行かず、2D のまま確定させる
      const probe = document.createElement("canvas");
      const gl = probe.getContext("webgl2") ?? (probe.getContext("webgl") as WebGLRenderingContext | null);
      if (!gl) {
        setFailed(true);
        return;
      }
      gl.getExtension("WEBGL_lose_context")?.loseContext();
      const T = await import("three");
      if (cancelled) return;
      apiRef.current = createViewer(T, {
        canvas,
        host,
        monthLabels: monthRefs.current,
        tickLabels: tickRefs.current,
        tag: tagRef.current,
        seriesId: initialRef.current.seriesId,
        showPrev: initialRef.current.showPrev,
        onHover: (f) => setHover(f),
        onPick: (i) => setSelected((cur) => (cur === i ? null : i)),
        onFirstFrame: () => setReady(true),
        onFail: () => setFailed(true),
      });
    };
    boot().catch(() => {
      if (!cancelled) setFailed(true);
    });
    return () => {
      cancelled = true;
      apiRef.current?.dispose();
      apiRef.current = null;
    };
  }, [live]);

  useEffect(() => {
    apiRef.current?.setRunning(inView && pageVisible && !failed);
  }, [inView, pageVisible, failed, ready]);

  useEffect(() => {
    apiRef.current?.setData(seriesId);
  }, [seriesId, ready]);

  useEffect(() => {
    apiRef.current?.setShowPrev(showPrev);
  }, [showPrev, ready]);

  useEffect(() => {
    apiRef.current?.setHighlight(selected, hover);
  }, [selected, hover, ready, seriesId]);

  const series = SERIES_BY_ID[seriesId];
  const axisMax = axisMaxOf(series);

  // 選択と項目が落ち着いてから読み上げる
  useEffect(() => {
    const t = window.setTimeout(() => {
      const s = SERIES_BY_ID[seriesId];
      if (selected === null) {
        setAnnounce(`${s.label}を表示中。今年度の合計 ${fmt(sum(s.current))}${s.unit}、前年比 ${yoy(sum(s.current), sum(s.previous))}`);
        return;
      }
      const a = s.current[selected];
      const b = s.previous[selected];
      setAnnounce(`${MONTHS[selected]}の${s.label}。今年度 ${fmt(a)}${s.unit}、前年度 ${fmt(b)}${s.unit}、前年比 ${yoy(a, b)}`);
    }, 300);
    return () => window.clearTimeout(t);
  }, [seriesId, selected]);

  const onStageKey = (e: KeyboardEvent<HTMLDivElement>) => {
    let next: number | null | undefined;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = selected === null ? 0 : Math.min(N - 1, selected + 1);
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = selected === null ? N - 1 : Math.max(0, selected - 1);
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = N - 1;
    else if (e.key === "Escape") next = null;
    if (next === undefined) return;
    e.preventDefault();
    setSelected(next);
  };

  const show3d = ready && !failed;
  const total = sum(series.current);
  const totalPrev = sum(series.previous);
  const peak = series.current.indexOf(Math.max(...series.current));
  const tagFocus = hover ?? (selected !== null ? { i: selected, row: 0 as const } : null);
  const tagText =
    tagFocus && (tagFocus.row === 0 || showPrev)
      ? `${MONTHS[tagFocus.i]} ${tagFocus.row === 0 ? "今年度" : "前年度"} ${fmt(tagFocus.row === 0 ? series.current[tagFocus.i] : series.previous[tagFocus.i])}${series.unit}`
      : "";
  const label = "text-[12px] font-bold tracking-wide text-gray-500";

  return (
    <section aria-labelledby={headingId} className="@container w-full max-w-5xl bg-white text-gray-900">
      {/* 冒頭: 英字の小見出しは置かず、見出しと年度の要約で開く */}
      <div className="grid gap-6 @2xl:grid-cols-[minmax(0,1fr)_auto] @2xl:items-end">
        <div>
          <h2 id={headingId} className="text-[clamp(1.35rem,3.4vw,2.05rem)] leading-[1.35] font-bold tracking-tight">
            1年の伸びを、
            <span className="whitespace-nowrap">高さでひと目に比べる</span>
          </h2>
          <p className="mt-4 max-w-xl text-[14px] leading-[1.9] font-medium text-gray-600">月ごとの推移を斜め上から見た立体の棒で並べます。手前が今年度、奥が前年度です。項目を切り替えると棒が伸び縮みし、棒に触れると値が出ます。同じ数字は右の表でも読めます。</p>
        </div>
        <dl className="grid grid-cols-3 border-y border-gray-200 @2xl:w-[21rem]">
          {[
            { term: "今年度の合計", value: fmt(total), unit: series.unit },
            { term: "前年比", value: yoy(total, totalPrev), unit: "" },
            { term: "最も多い月", value: MONTHS[peak].replace("月", ""), unit: "月" },
          ].map((s, i) => (
            <div key={s.term} className={`py-3 ${i > 0 ? "border-l border-gray-200 pl-3" : ""}`}>
              <dt className="text-[11px] font-bold tracking-wide whitespace-nowrap text-gray-500">{s.term}</dt>
              <dd className="mt-1 flex items-baseline gap-1 whitespace-nowrap">
                <span className={`${montserrat.className} text-[1.3rem] leading-none font-semibold tabular-nums`}>{s.value}</span>
                <span className="text-[11px] text-gray-500">{s.unit}</span>
              </dd>
            </div>
          ))}
        </dl>
      </div>

      <div className="mt-8 grid gap-8 border-t border-gray-200 pt-8 @3xl:grid-cols-[minmax(0,1fr)_17.5rem] @3xl:gap-9">
        <div className="min-w-0">
          {/* 操作: 項目(ラジオ)と前年度の列(チェックボックス) */}
          <div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-4">
            <fieldset className="min-w-0">
              <legend className={label}>項目</legend>
              <div className="mt-2 flex border border-gray-300">
                {SERIES.map((s, i) => (
                  <label
                    key={s.id}
                    className={`relative cursor-pointer px-3.5 py-1.5 text-[13px] font-bold whitespace-nowrap text-gray-600 transition-colors hover:text-amber-700 has-checked:bg-amber-600 has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-amber-600 motion-reduce:transition-none ${
                      i > 0 ? "border-l border-gray-300" : ""
                    }`}
                  >
                    <input type="radio" name={radioName} value={s.id} checked={seriesId === s.id} onChange={() => setSeriesId(s.id)} className="sr-only" />
                    {s.label}
                    <span className="ml-1 text-[11px] font-medium opacity-80">({s.unit})</span>
                  </label>
                ))}
              </div>
            </fieldset>
            <label className="flex cursor-pointer items-center gap-2 text-[13px] font-bold text-gray-700">
              <input type="checkbox" checked={showPrev} onChange={(e) => setShowPrev(e.target.checked)} className="size-4 cursor-pointer accent-amber-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600" />
              前年度の列を出す
            </label>
          </div>

          <div
            ref={stageRef}
            tabIndex={0}
            role="group"
            aria-roledescription="3D棒グラフ"
            aria-label={`${series.label}の月別推移。${selected !== null ? `${MONTHS[selected]}を選択中` : "月は未選択"}`}
            aria-describedby={hintId}
            aria-controls={tableId}
            onKeyDown={onStageKey}
            className="relative mt-5 aspect-[4/3] w-full overflow-hidden border border-gray-200 bg-[linear-gradient(180deg,#ffffff_0%,#faf8f5_100%)] @md:aspect-[16/10] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
          >
            {/* 図の領域。下端の余白は操作の案内に空けておく */}
            <div ref={plotRef} className="absolute inset-x-2 top-2 bottom-6">
              {/* 同じ射影の2D: 3Dが立ち上がるまで・縮小プレビュー・WebGL非対応のときの表示。選択と値の札も同じ */}
              <div className={`absolute inset-0 transition-opacity duration-700 motion-reduce:transition-none ${show3d ? "pointer-events-none opacity-0" : "opacity-100"}`}>
                <IsoBars series={series} showPrev={showPrev} selected={selected} focus={hover} onHover={setHover} onPick={(i) => setSelected((cur) => (cur === i ? null : i))} />
              </div>

              <canvas
                ref={canvasRef}
                aria-hidden="true"
                className={`absolute inset-0 block size-full transition-opacity duration-700 motion-reduce:transition-none ${show3d ? "opacity-100" : "pointer-events-none opacity-0"}`}
              />

              {/* 月名・目盛り・値の札(位置は3D側が transform で書き換える) */}
              <div aria-hidden="true" className={`pointer-events-none absolute inset-0 transition-opacity duration-500 motion-reduce:transition-none ${show3d ? "opacity-100" : "opacity-0"}`}>
                {MONTHS.map((m, i) => (
                  <span
                    key={m}
                    ref={(el) => {
                      monthRefs.current[i] = el;
                    }}
                    className={`absolute top-0 left-0 text-[10.5px] leading-none whitespace-nowrap tabular-nums ${i === selected ? "font-bold text-amber-700" : "font-medium text-gray-600"}`}
                  >
                    {m.replace("月", "")}
                    <span className="hidden @md:inline">月</span>
                  </span>
                ))}
                {TICKS.map((t, k) => (
                  <span
                    key={t}
                    ref={(el) => {
                      tickRefs.current[k] = el;
                    }}
                    className={`${montserrat.className} absolute top-0 left-0 text-[10.5px] leading-none font-medium whitespace-nowrap text-gray-600 tabular-nums`}
                  >
                    {fmt(t * axisMax)}
                  </span>
                ))}
                <span ref={tagRef} className="absolute top-0 left-0 bg-stone-900 px-1.5 py-1 text-[11px] leading-none font-bold whitespace-nowrap text-white opacity-0">
                  {tagText}
                </span>
              </div>
            </div>

            <p id={hintId} className="pointer-events-none absolute bottom-1.5 left-3 text-[10.5px] leading-[1.6] text-gray-500">
              {failed ? "3Dが使えない環境のため、同じ角度の図で表示中" : "棒に触れると値・押すと月を選択"}
              <span className="sr-only @md:not-sr-only">(矢印キーで月を移動・Escで解除)</span>
            </p>
          </div>

          {/* 選んだ月の読み取り */}
          <div className="mt-4 border-t border-gray-200 pt-4">
            {selected !== null ? (
              <p className="text-[13px] leading-[1.85] text-gray-700">
                <span className="font-bold text-gray-900">{MONTHS[selected]}の{series.label}</span>
                <span className={`${montserrat.className} mx-2 text-[1.15rem] font-semibold text-amber-700 tabular-nums`}>{fmt(series.current[selected])}</span>
                {series.unit}
                <span className="ml-2 text-gray-500">
                  前年度 <span className={`${montserrat.className} tabular-nums`}>{fmt(series.previous[selected])}</span>
                  {series.unit}・前年比 <span className={`${montserrat.className} tabular-nums`}>{yoy(series.current[selected], series.previous[selected])}</span>
                </span>
              </p>
            ) : (
              <p className="text-[13px] leading-[1.85] text-gray-600">月を選ぶと、その月の今年度と前年度の値、前年比をここに出します。棒を押すか、表の月を選んでください。</p>
            )}
          </div>
        </div>

        {/* 同じデータの表(月ボタンが入力の正) */}
        <div className="min-w-0 @3xl:border-l @3xl:border-gray-200 @3xl:pl-9">
          <table id={tableId} className="w-full table-fixed border-collapse text-[12.5px]">
            <caption className={`${label} pb-3 text-left`}>
              月別の{series.label}({series.unit})
            </caption>
            <thead>
              <tr className="border-y border-gray-200 text-[11px] text-gray-500">
                <th scope="col" className="w-[3.6rem] py-2 pl-1 text-left font-bold">
                  月
                </th>
                <th scope="col" className="py-2 text-right font-bold">
                  今年度
                </th>
                <th scope="col" className="py-2 text-right font-bold">
                  前年度
                </th>
                <th scope="col" className="py-2 pr-1 text-right font-bold">
                  前年比
                </th>
              </tr>
            </thead>
            <tbody className={`${montserrat.className} tabular-nums`}>
              {MONTHS.map((m, i) => {
                const on = i === selected;
                return (
                  <tr key={m} className={`border-b border-gray-200 transition-colors motion-reduce:transition-none ${on ? "bg-amber-50/80" : ""}`}>
                    <th scope="row" className="p-0 text-left font-normal">
                      <button
                        type="button"
                        aria-pressed={on}
                        onClick={() => setSelected(on ? null : i)}
                        className={`w-full cursor-pointer py-[0.42rem] pl-1 text-left font-sans text-[12.5px] transition-colors hover:text-amber-700 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none ${
                          on ? "font-bold text-amber-800" : "font-medium text-gray-800"
                        }`}
                      >
                        {m}
                      </button>
                    </th>
                    <td className={`text-right ${on ? "font-semibold text-gray-900" : "font-medium text-gray-800"}`}>{fmt(series.current[i])}</td>
                    <td className="text-right font-medium text-gray-500">{fmt(series.previous[i])}</td>
                    <td className="pr-1 text-right font-medium text-gray-700">{yoy(series.current[i], series.previous[i])}</td>
                  </tr>
                );
              })}
            </tbody>
            <tfoot className={`${montserrat.className} tabular-nums`}>
              <tr className="border-b border-gray-300">
                <th scope="row" className="py-2 pl-1 text-left font-sans text-[12px] font-bold text-gray-600">
                  合計
                </th>
                <td className="text-right font-semibold">{fmt(total)}</td>
                <td className="text-right font-medium text-gray-500">{fmt(totalPrev)}</td>
                <td className="pr-1 text-right font-semibold">{yoy(total, totalPrev)}</td>
              </tr>
            </tfoot>
          </table>
          <p className="mt-2 text-[11px] leading-[1.7] text-gray-500">月を押すと図の棒も選ばれます。もう一度押すと解除します</p>

          <p aria-live="polite" className="sr-only">
            {announce}
          </p>
        </div>
      </div>

      <p className="mt-8 border-t border-gray-200 pt-6 text-[12px] leading-[1.9] text-gray-500">数値は見せ方を示すための見本です。項目ごとに月別の今年度と前年度の値を並べれば、軸の目盛り・棒の高さ・表は同じデータから作られます。</p>
    </section>
  );
}

shadcn CLI でプロジェクトに追加

npx shadcn@latest add https://designs.first-ch.com/r/isometric-bar-chart.json