Point-Cloud Metric Board (shape-to-shape morph)
A metric board where 1600 points form the digits of the selected figure. Picking a row unravels the current shape and morphs it into the next glyph, pairing source and target points by x-order so trajectories barely cross. Points are sampled on a z=0 plane, rotated in yaw and pitch, then scaled by a k=f/(f-z) perspective factor applied to both size and opacity, so a flat numeral reads as a solid in space. Each point arcs out of the plane while travelling, and one in every 110 leaves an amber trail. Four easing curves can be swapped and are plotted live as SVG. Under prefers-reduced-motion the animation loop stops and switching becomes an instant redraw.
- Added:
- 2026-09-25
- Dependencies:
- gsap
- tags
- #block #metrics #canvas #point-cloud #morph #easing #projection #interactive #motion #no-image
Preview
数字でみる制作の進み方
点の集まりが、選んだ数字の形に組み直る
右の一覧から項目を選ぶと、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();
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/point-cloud-metric-morph.json