Isometric 3D Bar Chart (compare monthly trends in depth)
An isometric 3D bar chart that lays out monthly figures as solid bars seen from above at an angle. The front row is this year and the back row is last year, so the depth axis is used directly for the year-over-year comparison. Switching between three measures (inquiries, orders, revenue) with real radio buttons makes the bars grow or shrink from their current height to the new one, each month starting a little after the previous, while the totals at the top (annual total, change vs last year, peak month) and the monthly table on the right (this year, last year, change, total) update from the same data. Hovering a bar shows a value tag; clicking it or pressing a month button in the table (aria-pressed) highlights that month and shows both years and the change below the chart. On the chart, the arrow keys move between months, Home/End jump to the ends and Escape clears the selection, so the whole flow works by keyboard. A checkbox hides the last-year row. The axis maximum is always a round number split into four, so the grid lines stay put and only the labels and bar heights change. The orthographic camera points at the origin and a single projection function gives camera-space coordinates, so the 3D view (three) and the 2D fallback (SVG) share the same angle and framing. three is dynamically imported only when the block is on screen at full size; the scaled gallery card, browsers without WebGL and lost contexts get an SVG drawn with the painter's algorithm in the same projection, with the same bars, selection and value tag. Rendering is on demand and the loop stops once the bars settle. With prefers-reduced-motion nothing is tweened and the final state is drawn once. No small English kickers, trailing arrow links, rounded corners or scroll-in effects, just hairline rules and whitespace. Figures are sample data.
- Added:
- 2026-10-12
- Dependencies:
- three
- tags
- #3d #bar-chart #isometric #data-visualization #chart #webgl #orthographic-camera #interactive #keyboard #table #no-image #dynamic-import
Preview
1年の伸びを、高さでひと目に比べる
月ごとの推移を斜め上から見た立体の棒で並べます。手前が今年度、奥が前年度です。項目を切り替えると棒が伸び縮みし、棒に触れると値が出ます。同じ数字は右の表でも読めます。
- 今年度の合計
- 741件
- 前年比
- +19.5%
- 最も多い月
- 3月
棒に触れると値・押すと月を選択(矢印キーで月を移動・Escで解除)
月を選ぶと、その月の今年度と前年度の値、前年比をここに出します。棒を押すか、表の月を選んでください。
| 月 | 今年度 | 前年度 | 前年比 |
|---|---|---|---|
| 42 | 38 | +10.5% | |
| 48 | 41 | +17.1% | |
| 55 | 47 | +17.0% | |
| 51 | 50 | +2.0% | |
| 46 | 39 | +17.9% | |
| 63 | 52 | +21.2% | |
| 71 | 58 | +22.4% | |
| 78 | 61 | +27.9% | |
| 66 | 57 | +15.8% | |
| 58 | 49 | +18.4% | |
| 74 | 60 | +23.3% | |
| 89 | 68 | +30.9% | |
| 合計 | 741 | 620 | +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>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/isometric-bar-chart.json