BLK-78Blocks
点群モーフ・数値ボード(形から形へ移る実績値)
1600個の点が、選んだ実績値の数字そのものを形づくる切替ボード。項目を選ぶと点はいまの形をほどき、x座標順に対応づけられた次の字形へイージングで移る。点は z=0 の平面にサンプリングしたうえでヨーとピッチで回し、k=f/(f-z) の透視係数を大きさと濃さに掛けるため、平面の字が立体として立ち上がる。移動中だけ面の外へ持ち上げる弧(sin(π·p))で軌跡がふくらみ、110点に1つの追跡点だけがアンバーの折れ線を残す。移り方は4種類から差し替えでき、選んだイージングはその場でSVGの曲線図になる。動きは transform ではなくCanvasの再描画だが、prefers-reduced-motion では rAF を止めて切替を即時の置き換えに落とす。
- 追加日:
- 2026-09-25
- 依存:
- gsap
- tags
- #block #metrics #canvas #point-cloud #morph #easing #projection #interactive #motion #no-image
プレビュー
数字でみる制作の進み方
点の集まりが、選んだ数字の形に組み直る
右の一覧から項目を選ぶと、1600個の点がいまの形をほどいて次の数字へ移ります。移り方は下の4種類から差し替えられ、アンバーの数点にだけ残る軌跡でその効き方が見えます。
初回の打ち合わせから公開まで24日
5ページ規模の平均。原稿と写真が揃っている場合の実働です。
移り方を選ぶ
項目を選ぶ
数値は直近12か月の社内集計です。条件によって前後しますので、目安としてご覧ください。
"use client";
import { useEffect, useRef, useState } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({
weight: ["500", "600", "700"],
subsets: ["latin"],
display: "swap",
preload: false,
});
/**
* 点群モーフ・数値ボード(BLK-78)
*
* 差別化メモ: 既存の Canvas 粒子(particle-grid-hero / cursor-string-canvas)は「ホーム座標が1つあり、
* ポインタで乱して物理で戻す」連続運動で、粒子自体は情報を持たない背景だった。本ブロックの点群は
* 到達形を複数持ち、選んだ実績値の数字そのものを形づくる=点が情報を担う離散的な切替ボードで、
* 切替の「移り方(イージング)」を利用者が差し替えられる点が本体になっている。飽和している型のうち
* 英字キッカー・末尾の「→」リンク・角丸・スクロール出現アニメの4つを意図的に外した。
*
* 汎用技法メモ(web-design-playbook へ還流する要点)
*
* 1. GSAP は「トゥイーンの実行器」ではなく「イージング関数の供給源」として使える
* const ease = gsap.parseEase("back.out(1.7)"); // (p:0..1) => number を返す
* 600点を個別にトゥイーンさせるとインスタンスが600個できるが、parseEase で関数だけ貰い、
* 進捗は自前の rAF で1本だけ進めれば、点が何個でもオブジェクトは増えない。elastic/back の
* 行き過ぎ(>1・<0)もそのまま数値で返るので、位置の線形補間に掛けるだけで質感が出る。
* 動的 import(await import("gsap"))で読み、到着するまでは内蔵の三次イージングで動かす。
*
* 2. 形から形へのモーフは「対応付け」で決まる。点はx座標順に結ぶ
* 現在位置と目標位置をそれぞれ x 昇順に並べて同じ順位どうしを結ぶと、軌跡の交差がほぼ消える。
* 最適輸送を解かなくても、1次元のソート(O(n log n))だけで「ほどけて組み直る」ように見える。
*
* 3. 平面の点を3Dとして投影すると、同じ点群が一気に立体になる
* 文字は z=0 の平面にサンプリングし、ヨー(Y軸)とわずかなピッチ(X軸)で回してから
* k = f / (f - z) の透視係数を掛けるだけ。点の大きさと濃さにも k を掛けると奥行きが出る。
* 移動中だけ点を面の外へ持ち上げる(lift = sin(π·p) · 個体差)と、平面上の直線移動が弧になる。
*
* 4. イージングは「曲線を描いて見せる」と伝わる
* ease 関数を60点サンプリングして SVG の path にすれば、選択中の移り方がその場で図になる。
* 行き過ぎる系(back/elastic)は 0..1 をはみ出すので、viewBox を上下に広げて切らない。
*
* 5. 軌跡は少数の点にだけ残す
* 全点に残像を引くと画面が濁る。110点に1つだけ「追跡点」にして、平面座標のまま履歴を溜め、
* 毎フレーム同じ投影に掛けて薄いアンバーの折れ線で描く。イージングの効き(溜め・行き過ぎ)が
* 見えるのはこの数本だけで足りる。
*
* 6. 文字のサンプリングは document.fonts.ready の後にもう一度行う
* Web フォントが届く前後で字形が変わるため、初回描画(フォールバック書体)のあとに
* document.fonts.ready を待って採り直す。document.fonts.check は載っていない書体にも true を
* 返すことがあるので在否判定には使わない。
*
* 着想として借りたのは「点群を別の配置へイージングで移す」という仕組みだけで、文言・配色・
* レイアウト・コードはすべて First CH のオリジナルとして書き起こしている(外部コード・素材なし)。
*/
type Metric = {
/** 点群が形づくる文字列。数字と記号のみ(グリフを塗りつぶしでサンプリングするため) */
figure: string;
unit: string;
label: string;
note: string;
};
const METRICS: Metric[] = [
{
figure: "24",
unit: "日",
label: "初回の打ち合わせから公開まで",
note: "5ページ規模の平均。原稿と写真が揃っている場合の実働です。",
},
{
figure: "92",
unit: "%",
label: "公開後も運用を任せていただく割合",
note: "作って終わりにせず、更新と改善まで続けてお受けしています。",
},
{
figure: "2.4",
unit: "倍",
label: "作り直しによる表示速度の改善幅",
note: "画像と読み込み順の整理が中心で、構成はそのままでも変わります。",
},
{
figure: "180",
unit: "件",
label: "これまでに設計・実装したページ",
note: "1ページものから、数十ページの改修まで幅があります。",
},
{
figure: "3",
unit: "日",
label: "ご相談からお見積りをお出しするまで",
note: "要件が固まっていない段階でも、範囲の切り方から一緒に考えます。",
},
];
type EaseOption = {
/** GSAP の ease 文字列。gsap.parseEase() にそのまま渡す */
id: string;
label: string;
/** この移り方のときの本体の長さ(ms)。行き過ぎ系は戻る時間が要る */
duration: number;
};
const EASES: EaseOption[] = [
{ id: "power2.inOut", label: "ためて、収める", duration: 1100 },
{ id: "expo.out", label: "一気に出て、止まる", duration: 1250 },
{ id: "back.out(1.7)", label: "行き過ぎて、戻る", duration: 1250 },
{ id: "elastic.out(1,0.62)", label: "弾んで、落ち着く", duration: 1750 },
];
const COUNT = 1600; // 点の総数(形が変わっても増減しない)
const STAGGER = 280; // 左の点から順に出発する幅(ms)
const FOCAL = 3.4; // 透視投影の焦点距離(平面の1辺=1とする単位)
const TRAIL_EVERY = 110; // この間隔ごとの点にだけ軌跡を残す
const TRAIL_MAX = 26;
/** gsap が届くまで使う内蔵イージング(power2.inOut 相当) */
const fallbackEase = (p: number) => (p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2);
type Point = {
x: number;
y: number;
sx: number;
sy: number;
tx: number;
ty: number;
arc: number;
delay: number;
size: number;
};
type Engine = {
morphTo: (index: number) => void;
setEase: (fn: (p: number) => number, duration: number) => void;
};
export default function PointCloudMetricMorph() {
const [active, setActive] = useState(0);
const [easeIndex, setEaseIndex] = useState(0);
const [curve, setCurve] = useState<string>(() => samplePath(fallbackEase));
const stageRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const engineRef = useRef<Engine | null>(null);
// --- 描画エンジン(マウント時に1度だけ組み立てる) -------------------------------
useEffect(() => {
const stage = stageRef.current;
const canvas = canvasRef.current;
if (!stage || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let w = 1;
let h = 1;
let dpr = 1;
let raf = 0;
let last = 0;
let clock = 0; // rAF で積む内部時計(ms)。performance.now() は使わない
let shapes: Float32Array[] = [];
let shapeIndex = 0;
let ease = fallbackEase;
let duration = EASES[0].duration;
let startAt = -1e9;
let running = false;
let yaw = 0.26;
let pitch = 0.08;
let yawTarget = 0.26;
let pitchTarget = 0.08;
let trailAlpha = 0;
const points: Point[] = [];
const trails: { index: number; pts: number[] }[] = [];
for (let i = 0; i < COUNT; i++) {
points.push({ x: 0, y: 0, sx: 0, sy: 0, tx: 0, ty: 0, arc: 0, delay: 0, size: 1 });
if (i % TRAIL_EVERY === 0) trails.push({ index: i, pts: [] });
}
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
let onScreen = true;
let visible = document.visibilityState !== "hidden";
// --- 文字グリフを点群へ落とす -------------------------------------------------
const sample = (figure: string): Float32Array => {
const SIZE = 220;
const off = document.createElement("canvas");
const octx = off.getContext("2d", { willReadFrequently: true });
const out = new Float32Array(COUNT * 2);
if (!octx) return out;
octx.font = `700 ${SIZE}px ${montserrat.style.fontFamily}, "Helvetica Neue", Arial, sans-serif`;
const width = Math.max(1, Math.ceil(octx.measureText(figure).width) + 16);
off.width = width;
off.height = Math.ceil(SIZE * 1.4);
octx.font = `700 ${SIZE}px ${montserrat.style.fontFamily}, "Helvetica Neue", Arial, sans-serif`;
octx.textBaseline = "middle";
octx.textAlign = "center";
octx.fillStyle = "#000";
octx.fillText(figure, off.width / 2, off.height / 2);
const data = octx.getImageData(0, 0, off.width, off.height).data;
const hits: number[] = [];
const stride = 3;
for (let y = 0; y < off.height; y += stride) {
for (let x = 0; x < off.width; x += stride) {
if (data[(y * off.width + x) * 4 + 3] > 140) {
hits.push(x, y);
}
}
}
const total = hits.length / 2;
if (total === 0) return out;
// 文字の高さを 1 とする平面座標へ正規化(中心が原点)
const scale = 1 / SIZE;
let seed = figure.length * 9973 + 7;
const rand = () => {
seed = (seed * 1664525 + 1013904223) % 4294967296;
return seed / 4294967296;
};
for (let i = 0; i < COUNT; i++) {
// 点数が足りなければ同じ画素を再利用し、微小なゆらぎで重なりを避ける
const pick = Math.floor(rand() * total);
const jx = (rand() - 0.5) * stride * 0.9;
const jy = (rand() - 0.5) * stride * 0.9;
out[i * 2] = (hits[pick * 2] + jx - off.width / 2) * scale;
out[i * 2 + 1] = (hits[pick * 2 + 1] + jy - off.height / 2) * scale;
}
return out;
};
const buildShapes = () => {
shapes = METRICS.map((m) => sample(m.figure));
};
// --- 形の切り替え -------------------------------------------------------------
const assign = (index: number) => {
const target = shapes[index];
if (!target) return;
// 現在位置と目標位置をそれぞれ x 昇順にして同順位で結ぶ(軌跡の交差を減らす)
const order = points.map((p, i) => i).sort((a, b) => points[a].x - points[b].x);
const targetOrder = Array.from({ length: COUNT }, (_, i) => i).sort(
(a, b) => target[a * 2] - target[b * 2],
);
let seed = index * 2654435761 + 11;
const rand = () => {
seed = (seed * 1664525 + 1013904223) % 4294967296;
return seed / 4294967296;
};
for (let k = 0; k < COUNT; k++) {
const p = points[order[k]];
const t = targetOrder[k];
p.sx = p.x;
p.sy = p.y;
p.tx = target[t * 2];
p.ty = target[t * 2 + 1];
p.arc = (rand() - 0.5) * 0.62;
p.delay = (k / COUNT) * STAGGER;
p.size = 0.72 + rand() * 0.6;
}
};
const settle = () => {
for (const p of points) {
p.x = p.tx;
p.y = p.ty;
p.sx = p.tx;
p.sy = p.ty;
}
};
// --- 投影と描画 ---------------------------------------------------------------
const project = (x: number, y: number, z: number) => {
const cy = Math.cos(yaw);
const sy = Math.sin(yaw);
const xr = x * cy + z * sy;
const zr = -x * sy + z * cy;
const cp = Math.cos(pitch);
const sp = Math.sin(pitch);
const yr = y * cp - zr * sp;
const zz = y * sp + zr * cp;
const k = FOCAL / (FOCAL - zz);
return { x: xr * k, y: yr * k, k };
};
const draw = (progressed: boolean) => {
ctx.clearRect(0, 0, w, h);
const unit = Math.min(h * 0.62, w * 0.42);
const cx = w / 2;
const cy = h / 2;
// 平面(文字が載っている面)の枠をヘアラインで示す=投影であることが読める
const corners = [
[-1.05, -0.58],
[1.05, -0.58],
[1.05, 0.58],
[-1.05, 0.58],
];
ctx.strokeStyle = "rgba(0,0,0,0.10)";
ctx.lineWidth = 1;
ctx.beginPath();
corners.forEach(([px, py], i) => {
const q = project(px, py, 0);
const X = cx + q.x * unit;
const Y = cy + q.y * unit;
if (i === 0) ctx.moveTo(X, Y);
else ctx.lineTo(X, Y);
});
ctx.closePath();
ctx.stroke();
// 追跡点の軌跡(イージングの溜め・行き過ぎが見える数本だけ)
if (trailAlpha > 0.01) {
ctx.strokeStyle = `rgba(217,119,6,${0.36 * trailAlpha})`;
ctx.lineWidth = 1;
for (const tr of trails) {
if (tr.pts.length < 6) continue;
ctx.beginPath();
for (let i = 0; i < tr.pts.length; i += 3) {
const q = project(tr.pts[i], tr.pts[i + 1], tr.pts[i + 2]);
const X = cx + q.x * unit;
const Y = cy + q.y * unit;
if (i === 0) ctx.moveTo(X, Y);
else ctx.lineTo(X, Y);
}
ctx.stroke();
}
}
// 点は正方形で描く(角丸を使わない標本モチーフ。塗り替えは濃度の段階ごとにまとめる)
let bucket = -1;
for (let i = 0; i < COUNT; i++) {
const p = points[i];
const lift = progressed ? liftOf(i) : 0;
const q = project(p.x, p.y, lift);
const s = Math.max(1, (1.25 + p.size * 0.7) * q.k * (unit / 170));
const alpha = Math.min(0.95, Math.max(0.3, 0.46 * q.k * q.k));
const b = Math.round(alpha * 6);
if (b !== bucket) {
bucket = b;
ctx.fillStyle = `rgba(23,23,23,${(b / 6).toFixed(3)})`;
}
ctx.fillRect(cx + q.x * unit - s / 2, cy + q.y * unit - s / 2, s, s);
}
// 追跡点だけアンバーで上書きし、どの点を追っているか分かるようにする
ctx.fillStyle = "#d97706";
for (const tr of trails) {
const p = points[tr.index];
const q = project(p.x, p.y, progressed ? liftOf(tr.index) : 0);
const s = Math.max(2, 2.6 * q.k * (unit / 170));
ctx.fillRect(cx + q.x * unit - s / 2, cy + q.y * unit - s / 2, s, s);
}
};
const lifts = new Float32Array(COUNT);
const liftOf = (i: number) => lifts[i];
const step = (time: number) => {
raf = requestAnimationFrame(step);
const dt = last ? Math.min(48, time - last) : 16.7;
last = time;
clock += dt;
// ヨー・ピッチは目標値へ追従(無操作時はゆっくり左右に振れる)
const drift = Math.sin(clock * 0.00028) * 0.22;
const ty = yawTarget + drift;
yaw += (ty - yaw) * 0.045;
pitch += (pitchTarget - pitch) * 0.045;
const elapsed = clock - startAt;
const span = duration + STAGGER;
const moving = elapsed >= 0 && elapsed <= span + 60;
if (moving) {
for (let i = 0; i < COUNT; i++) {
const p = points[i];
const raw = (elapsed - p.delay) / duration;
const lin = raw < 0 ? 0 : raw > 1 ? 1 : raw;
const e = lin >= 1 ? 1 : ease(lin);
p.x = p.sx + (p.tx - p.sx) * e;
p.y = p.sy + (p.ty - p.sy) * e;
lifts[i] = Math.sin(Math.PI * lin) * p.arc;
}
trailAlpha = Math.min(1, trailAlpha + dt / 220);
for (const tr of trails) {
const p = points[tr.index];
tr.pts.push(p.x, p.y, lifts[tr.index]);
if (tr.pts.length > TRAIL_MAX * 3) tr.pts.splice(0, 3);
}
} else if (trailAlpha > 0) {
trailAlpha = Math.max(0, trailAlpha - dt / 700);
if (trailAlpha === 0) for (const tr of trails) { tr.pts.length = 0; }
lifts.fill(0);
}
draw(moving);
};
const start = () => {
if (running || mql.matches || !onScreen || !visible) return;
running = true;
last = 0;
raf = requestAnimationFrame(step);
};
const stop = () => {
running = false;
cancelAnimationFrame(raf);
};
const resize = () => {
const rect = stage.getBoundingClientRect();
// 一覧カードでは scale が掛かるので、描画解像度は contentBox 相当の論理サイズから決める
w = Math.max(1, Math.round(stage.clientWidth));
h = Math.max(1, Math.round(stage.clientHeight));
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (rect.width === 0) return;
draw(false);
};
const onPointer = (event: PointerEvent) => {
if (mql.matches) return;
const rect = stage.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const nx = (event.clientX - rect.left) / rect.width - 0.5;
const ny = (event.clientY - rect.top) / rect.height - 0.5;
yawTarget = 0.26 + nx * 0.5;
pitchTarget = 0.08 + ny * 0.22;
};
const onLeave = () => {
yawTarget = 0.26;
pitchTarget = 0.08;
};
const morphTo = (index: number) => {
shapeIndex = index;
assign(index);
// reduced-motion/画面外/タブ非表示では rAF が回らない=補間が進まないので、
// 目標位置へ置き換えて1回だけ描く(戻ってきたときに前の形のまま固まらない)
if (mql.matches || !onScreen || !visible) {
settle();
lifts.fill(0);
trailAlpha = 0;
draw(false);
return;
}
for (const tr of trails) tr.pts.length = 0;
startAt = clock;
start();
};
engineRef.current = {
morphTo,
setEase: (fn, ms) => {
ease = fn;
duration = ms;
},
};
buildShapes();
resize();
assign(0);
settle();
draw(false);
const ro = new ResizeObserver(resize);
ro.observe(stage);
const io = new IntersectionObserver(
(entries) => {
onScreen = entries.some((entry) => entry.isIntersecting);
if (onScreen) start();
else stop();
},
{ threshold: 0 },
);
io.observe(stage);
const onVisibility = () => {
visible = document.visibilityState !== "hidden";
if (visible) start();
else stop();
};
const onMotionChange = () => {
if (mql.matches) {
stop();
settle();
lifts.fill(0);
trailAlpha = 0;
yaw = 0.26;
pitch = 0.08;
draw(false);
} else {
start();
}
};
document.addEventListener("visibilitychange", onVisibility);
mql.addEventListener("change", onMotionChange);
stage.addEventListener("pointermove", onPointer);
stage.addEventListener("pointerleave", onLeave);
// Web フォントが届いたら字形を採り直す(届く前は代替書体の形で立ち上がる)
let alive = true;
document.fonts.ready.then(() => {
if (!alive) return;
buildShapes();
assign(shapeIndex);
if (!(clock - startAt <= duration + STAGGER)) {
settle();
draw(false);
}
});
start();
return () => {
alive = false;
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
mql.removeEventListener("change", onMotionChange);
stage.removeEventListener("pointermove", onPointer);
stage.removeEventListener("pointerleave", onLeave);
engineRef.current = null;
};
}, []);
// --- 選んだ移り方を GSAP の parseEase で受け取る -------------------------------
useEffect(() => {
let alive = true;
const option = EASES[easeIndex];
import("gsap")
.then(({ gsap }) => {
if (!alive) return;
const fn = gsap.parseEase(option.id) ?? fallbackEase;
engineRef.current?.setEase(fn, option.duration);
setCurve(samplePath(fn));
})
.catch(() => {
if (!alive) return;
engineRef.current?.setEase(fallbackEase, option.duration);
setCurve(samplePath(fallbackEase));
});
engineRef.current?.setEase(fallbackEase, option.duration);
return () => {
alive = false;
};
}, [easeIndex]);
// --- 選んだ項目へ形を移す -------------------------------------------------------
useEffect(() => {
engineRef.current?.morphTo(active);
}, [active]);
const current = METRICS[active];
return (
<section
className={`mx-auto w-full max-w-[900px] border border-gray-200 bg-white text-gray-900`}
>
<div className="border-b border-gray-200 px-6 py-6 sm:px-8 sm:py-7">
<p className="flex items-center gap-2 text-[11px] font-medium text-gray-500">
<span aria-hidden="true" className="inline-block h-3 w-px bg-amber-600" />
数字でみる制作の進み方
</p>
<h2 className="mt-3 text-[22px] leading-[1.5] font-bold tracking-tight sm:text-[26px]">
点の集まりが、選んだ数字の形に組み直る
</h2>
<p className="mt-3 max-w-[54ch] text-[13px] leading-[1.9] text-gray-600">
右の一覧から項目を選ぶと、1600個の点がいまの形をほどいて次の数字へ移ります。移り方は下の4種類から差し替えられ、アンバーの数点にだけ残る軌跡でその効き方が見えます。
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_282px]">
{/* 盤面 */}
<div className="border-b border-gray-200 lg:border-r lg:border-b-0">
<div
ref={stageRef}
className="relative h-[272px] w-full overflow-hidden bg-[radial-gradient(circle_at_center,#f8fafc_0%,#ffffff_72%)] sm:h-[332px] lg:h-[372px]"
>
<canvas ref={canvasRef} aria-hidden="true" className="block h-full w-full" />
<span
aria-hidden="true"
className={`pointer-events-none absolute top-3 left-4 ${montserrat.className} text-[10px] font-semibold text-gray-400 tabular-nums`}
>
{String(active + 1).padStart(2, "0")} / {String(METRICS.length).padStart(2, "0")}
</span>
</div>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-t border-gray-200 px-5 py-4 sm:px-6">
<p role="status" className="text-[13px] font-bold">
{current.label}
<span className={`ml-2 ${montserrat.className} text-[15px] text-amber-600 tabular-nums`}>
{current.figure}
{current.unit}
</span>
</p>
<p className="text-[12px] leading-[1.8] text-gray-600">{current.note}</p>
</div>
{/* 移り方の切り替えとその曲線 */}
<div className="flex flex-col gap-4 border-t border-gray-200 px-5 py-4 sm:flex-row sm:items-center sm:px-6">
<div className="min-w-0 flex-1">
<p className="text-[11px] font-medium text-gray-500">移り方を選ぶ</p>
<div className="mt-2 flex flex-wrap gap-2">
{EASES.map((option, index) => {
const on = index === easeIndex;
return (
<button
key={option.id}
type="button"
onClick={() => {
setEaseIndex(index);
engineRef.current?.morphTo(active);
}}
aria-pressed={on}
className={`border px-3 py-2 text-left transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 ${
on
? "border-amber-600 bg-amber-50 text-gray-900"
: "border-gray-200 bg-white text-gray-600 hover:border-gray-400 hover:text-gray-900"
}`}
>
<span className="block text-[12px] font-bold">{option.label}</span>
<span
className={`mt-0.5 block ${montserrat.className} text-[10px] font-medium ${
on ? "text-amber-700" : "text-gray-400"
}`}
>
{option.id}
</span>
</button>
);
})}
</div>
</div>
<figure className="shrink-0 self-start border border-gray-200 px-3 py-2 sm:self-auto">
<svg
viewBox="0 -0.35 1 1.7"
preserveAspectRatio="none"
aria-hidden="true"
className="block h-14 w-[104px]"
>
<line x1="0" y1="1" x2="1" y2="1" stroke="#e5e7eb" strokeWidth="0.012" />
<line x1="0" y1="0" x2="1" y2="0" stroke="#e5e7eb" strokeWidth="0.012" />
<path
d={curve}
fill="none"
stroke="#d97706"
strokeWidth="0.024"
vectorEffect="non-scaling-stroke"
/>
</svg>
<figcaption className="mt-1 text-center text-[10px] text-gray-500 tabular-nums">
{EASES[easeIndex].duration} ms
</figcaption>
</figure>
</div>
</div>
{/* 一覧(控え) */}
<div>
<p className="border-b border-gray-200 px-5 py-3 text-[11px] font-medium text-gray-500">
項目を選ぶ
</p>
<ul>
{METRICS.map((metric, index) => {
const on = index === active;
return (
<li key={metric.label} className="border-b border-gray-200 last:border-b-0">
<button
type="button"
onClick={() => setActive(index)}
aria-current={on ? "true" : undefined}
className={`group relative flex w-full items-baseline gap-3 px-5 py-4 text-left transition-colors duration-200 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-amber-600 ${
on ? "bg-amber-50/60" : "hover:bg-gray-50"
}`}
>
<span
aria-hidden="true"
className={`absolute top-0 bottom-0 left-0 w-0.5 origin-top bg-amber-600 transition-transform duration-300 motion-reduce:transition-none ${
on ? "scale-y-100" : "scale-y-0"
}`}
/>
<span
className={`${montserrat.className} text-[10px] font-semibold tabular-nums ${
on ? "text-amber-700" : "text-gray-400"
}`}
>
{String(index + 1).padStart(2, "0")}
</span>
<span className="min-w-0 flex-1">
<span
className={`block text-[12px] leading-[1.7] ${on ? "font-bold text-gray-900" : "text-gray-600"}`}
>
{metric.label}
</span>
</span>
<span
className={`${montserrat.className} text-[15px] font-bold tabular-nums ${
on ? "text-amber-600" : "text-gray-900"
}`}
>
{metric.figure}
<span className="ml-0.5 text-[11px] font-medium">{metric.unit}</span>
</span>
</button>
</li>
);
})}
</ul>
<p className="px-5 py-4 text-[11px] leading-[1.9] text-gray-500">
数値は直近12か月の社内集計です。条件によって前後しますので、目安としてご覧ください。
</p>
</div>
</div>
</section>
);
}
/** ease 関数を60点サンプリングして SVG の path にする(0..1 を外れる値もそのまま描く) */
function samplePath(fn: (p: number) => number): string {
const steps = 60;
let d = "";
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const v = 1 - fn(t);
d += `${i === 0 ? "M" : "L"}${t.toFixed(4)} ${v.toFixed(4)} `;
}
return d.trim();
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/point-cloud-metric-morph.json