First CH Designs
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ページ規模の平均。原稿と写真が揃っている場合の実働です。

移り方を選ぶ

1100 ms

項目を選ぶ

数値は直近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