First CH Designs
BLK-24Blocks

軌跡ストランド・キャンバス(パラメータ操作つき)

Canvas 2D 上でカーソルの軌跡を等間隔ノードのロープに変換し、重力・うねり・距離拘束で落として溜める描画ブロック。First CH のワードマークをオフスクリーンに描いて alpha を 0/1 マスクへ落とし、線が字面に当たると弾き返して接点だけをアンバー(#d97706)に灯す。圧力・重力・うねりの3スライダーと消去/自動描画のトグルを hairline 罫線で区切ったパネルに置き、カード羅列を避けた。描いている線はアンバー、置かれた線はインクへ 900ms かけて変わる。prefers-reduced-motion・画面外(IntersectionObserver)・タブ非表示の3条件で rAF を止め、モーション低減時は同じ物理を同期計算した静止フレームを1枚だけ描く(スライダーは再計算として機能するので操作が死なない)。無操作が続くとリサージュ曲線の仮想ポインタが引き継ぐためタッチ環境でも成立する。依存ライブラリゼロ・画像素材ゼロ。軌跡から曲線ストランドを作る発想とpressure/gravity/curlのパラメータパネルという構成を、コード・文言・配色・構図・ワードマークすべてFirst CHのオリジナルとして再構築した(外部コードの取り込みなし・2026-08-13)。

追加日:
2026-08-13
依存:
なし
tags
#block #canvas #interactive #physics #verlet #rope #pointer #controls #motion #no-image #no-dependency

プレビュー

軌跡ストランド・キャンバス

なぞると描け、押し込むと太くなります

描画パラメータ

線はワードマークに当たると弾かれ、触れた点だけがアンバーに灯ります。

インタラクションの相談
"use client";

import { useEffect, useRef, useState } from "react";
import { Montserrat } from "next/font/google";

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

/**
 * カーソル追従ストリング描画キャンバス(BLK-24)
 *
 * 汎用技法メモ(web-design-playbook へ還流する要点):
 * 1. 軌跡→ストランド化: ポインタ座標をそのまま点として撒かず、「直前のノードから SEG(6px) 進むごとに
 *    1ノード打つ」ループで等間隔のノード列を作る。ポインタが速く動いた1フレームでも while で
 *    間を埋めるので、フレームレートに依らず線の密度が一定になる(=速く動かすと点線になる問題が消える)。
 * 2. ロープ物理は「積分 → 距離拘束 → 衝突」の順で回す。拘束は隣接ノードの距離を SEG に戻す補正を
 *    2回ぶん流すだけ(Jakobsen 法)。バネ定数を上げるより安定し、発散しない。
 * 3. dt スケール: 速度・減衰をフレーム固定値にせず dt/16.667 で正規化する(120Hz で倍速にならない)。
 *    減衰は乗算なので Math.pow(DAMP, dtScale)。
 * 4. **文字の当たり判定はテキストをオフスクリーンに描いて alpha を読む**。ワードマークを
 *    fillText → getImageData → Uint8Array(w*h) の 0/1 マスクへ落とすと、任意の書体・任意の文字列に対して
 *    パスを持たずに衝突が取れる。法線は「左右・上下2px 先が塗りかどうか」の差分(勾配)から作る。
 *    フォントは next/font の `style.fontFamily` を ctx.font に渡し、document.fonts.ready で必ず組み直す
 *    (読み込み前に測るとフォールバック書体の字幅でマスクができてしまう)。
 * 5. リボン描画は1ストランド1 fill: 各ノードの接線から法線を出し、片側→反対側を逆順に辿って
 *    1本の閉パスにする。線分ごとに stroke すると数百回の状態変更になり 60fps を割る。
 *    太さのプロファイルは sin(π·i/n)^0.55 で両端を細らせると手描きの入り抜きになる。
 * 6. 「描いている線=アンバー / 置かれた線=インク」を色の時間変化で表す。ストランドが閉じた時刻から
 *    900ms かけて amber→ink を線形補間し、さらに一定時間後にアルファを落として消す。
 *    無限に溜まらないので、放置されるギャラリー内でも状態が安定する。
 * 7. reduced-motion では rAF を回さず、同じ物理を同期ループで 260 ステップ回した「計算結果」を1枚描く。
 *    アニメーションを止めてもパラメータ(圧力・重力・うねり)は結果の形に効くので、操作が死なない。
 *
 * 着想: Canvas上でカーソル軌跡から曲線ストランドを作る発想と、
 * pressure/gravity/curl を持つパラメータパネルという構成のみ)。ロゴ衝突の着想は First CH の
 * ワードマークへ差し替え、コード・文言・配色・構図はすべて新規に書き起こしている。
 */

type Bead = { x: number; y: number; vx: number; vy: number; w: number; hit: number };
type Strand = { beads: Bead[]; seed: number; settledAt: number };

const SEG = 6; // ノード間の静止距離(px)
const MAX_BEADS = 80; // 1ストランドの最大ノード数
const MAX_STRANDS = 7; // 同時に生かすストランド数
const DAMP = 0.93; // 60fps基準の減衰率
const WARM_MS = 900; // amber → ink へ移る時間
const FADE_HOLD = 3400; // 定着後この時間はそのまま見せる
const FADE_DUR = 1500; // そこから消えるまでの時間
const IDLE_MS = 1800; // 無操作がこれだけ続いたら自動描画へ
const PAD = 2;
const TAU = Math.PI * 2;
const WORDMARK = "FIRST CH";

const INK = [34, 30, 25];
const AMBER = [217, 119, 6];

const PARAMS = [
  { key: "pressure", label: "圧力", note: "線の太さ。押し込むと増える", min: 0, max: 100 },
  { key: "gravity", label: "重力", note: "落下と底への溜まり方", min: 0, max: 100 },
  { key: "curl", label: "うねり", note: "軌跡に乗るゆらぎの強さ", min: 0, max: 100 },
] as const;

export default function CursorStringCanvas() {
  const [pressure, setPressure] = useState(58);
  const [gravity, setGravity] = useState(22);
  const [curl, setCurl] = useState(46);
  const [auto, setAuto] = useState(true);

  const stageRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const paramsRef = useRef({ pressure: 58, gravity: 22, curl: 46, auto: true });
  const apiRef = useRef<{ clear: () => void; restatic: () => void } | null>(null);

  // 値はrAFループ側から参照するのでrefへミラーする(依存に入れて再購読しない)
  useEffect(() => {
    paramsRef.current = { pressure, gravity, curl, auto };
    // reduced-motion のときだけ、パラメータ変更を静止フレームの再計算として反映する
    apiRef.current?.restatic();
  }, [pressure, gravity, curl, auto]);

  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 raf = 0;
    let last = 0;
    let clock = 0;
    let seedCount = 0;

    const strands: Strand[] = [];
    let live: Strand | null = null;

    const ptr = { x: 0, y: 0, inside: false, down: false, at: -Infinity };
    const cur = { x: 0, y: 0, vx: 0, vy: 0, on: false, s: 0, press: 0 };
    let wasAuto = false;

    const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
    let onScreen = true;
    let visible = document.visibilityState !== "hidden";

    // ---- ワードマークのマスク(テキスト → alpha → 0/1 ビットマップ) -------------------
    const maskCv = document.createElement("canvas");
    let mask: Uint8Array | null = null;
    const wm = { size: 0, x: 0, y: 0 };

    const setFont = (c: CanvasRenderingContext2D, size: number) => {
      const withSpacing = c as CanvasRenderingContext2D & { letterSpacing?: string };
      if ("letterSpacing" in withSpacing) withSpacing.letterSpacing = `${size * 0.05}px`;
      c.font = `800 ${size}px ${montserrat.style.fontFamily}, sans-serif`;
      c.textAlign = "center";
      c.textBaseline = "middle";
    };

    const buildMask = () => {
      const mc = maskCv.getContext("2d", { willReadFrequently: true });
      if (!mc) {
        mask = null;
        return;
      }
      maskCv.width = w;
      maskCv.height = h;
      mc.clearRect(0, 0, w, h);

      // 一度仮サイズで測ってから、幅が枠の74%になる実サイズへ組み直す
      let size = Math.min(h * 0.34, w * 0.2);
      setFont(mc, size);
      const measured = mc.measureText(WORDMARK).width || 1;
      size = Math.max(16, Math.min(h * 0.4, (size * (w * 0.74)) / measured));
      setFont(mc, size);
      wm.size = size;
      wm.x = w / 2;
      wm.y = Math.round(h * 0.5);

      mc.fillStyle = "#000";
      mc.fillText(WORDMARK, wm.x, wm.y);
      try {
        const data = mc.getImageData(0, 0, w, h).data;
        const bits = new Uint8Array(w * h);
        for (let i = 0, p = 3; i < bits.length; i++, p += 4) bits[i] = data[p] > 110 ? 1 : 0;
        mask = bits;
      } catch {
        // 画素が読めない環境では衝突なしで成立させる(描画自体は続く)
        mask = null;
      }
    };

    const solid = (x: number, y: number) => {
      if (!mask) return false;
      const ix = Math.floor(x);
      const iy = Math.floor(y);
      if (ix < 0 || iy < 0 || ix >= w || iy >= h) return false;
      return mask[iy * w + ix] === 1;
    };

    // ---- ストランド生成 ---------------------------------------------------------------
    const trim = () => {
      while (strands.length > MAX_STRANDS) {
        const dropped = strands.shift();
        if (dropped === live) live = null;
      }
    };

    const closeLive = () => {
      if (live) live.settledAt = clock;
      live = null;
    };

    const beadWidth = () => {
      const p = paramsRef.current.pressure / 100;
      return (1.5 + p * 5.2) * (1 + cur.press * 0.9);
    };

    const push = (x: number, y: number) => {
      if (!live) {
        live = { beads: [], seed: (seedCount++ * 0.6180339887) % 1, settledAt: 0 };
        strands.push(live);
        trim();
        if (!live) return;
      }
      live.beads.push({
        x,
        y,
        vx: cur.vx * 0.4,
        vy: cur.vy * 0.4,
        w: beadWidth(),
        hit: 0,
      });
      if (live.beads.length >= MAX_BEADS) closeLive();
    };

    /** 直前ノードから SEG ごとにノードを打つ(1フレームで大きく動いても密度が一定になる) */
    const emit = (x: number, y: number) => {
      if (!live || live.beads.length === 0) {
        push(x, y);
        return;
      }
      let guard = 0;
      while (live && guard++ < 10) {
        const head = live.beads[live.beads.length - 1];
        const dx = x - head.x;
        const dy = y - head.y;
        const d = Math.hypot(dx, dy);
        if (d < SEG) return;
        push(head.x + (dx / d) * SEG, head.y + (dy / d) * SEG);
      }
    };

    // ---- 物理(積分 → 距離拘束 → 衝突) ------------------------------------------------
    const collide = (b: Bead) => {
      if (mask && solid(b.x, b.y)) {
        // 法線は「2px 先が塗りかどうか」の差分から作る
        let nx = (solid(b.x - 2, b.y) ? 1 : 0) - (solid(b.x + 2, b.y) ? 1 : 0);
        let ny = (solid(b.x, b.y - 2) ? 1 : 0) - (solid(b.x, b.y + 2) ? 1 : 0);
        if (nx === 0 && ny === 0) {
          // 字面の内側まで潜った場合は入射方向を押し返す
          nx = -b.vx;
          ny = -b.vy;
        }
        let len = Math.hypot(nx, ny);
        if (len < 0.0001) {
          nx = 0;
          ny = -1;
          len = 1;
        }
        nx /= len;
        ny /= len;
        // 押し出しは細かく刻む(大きく飛ばすとロープが折れて見える)
        let guard = 0;
        while (solid(b.x, b.y) && guard++ < 22) {
          b.x += nx;
          b.y += ny;
        }
        const dot = b.vx * nx + b.vy * ny;
        if (dot < 0) {
          b.vx -= 1.15 * dot * nx;
          b.vy -= 1.15 * dot * ny;
        }
        b.vx *= 0.7;
        b.vy *= 0.7;
        b.hit = 1;
      }

      if (b.y > h - PAD) {
        b.y = h - PAD;
        b.vy *= -0.26;
        b.vx *= 0.88;
      } else if (b.y < PAD) {
        b.y = PAD;
        b.vy *= -0.26;
      }
      if (b.x < PAD) {
        b.x = PAD;
        b.vx *= -0.4;
      } else if (b.x > w - PAD) {
        b.x = w - PAD;
        b.vx *= -0.4;
      }
    };

    const stepPhysics = (dtScale: number) => {
      const g = (paramsRef.current.gravity / 100) * 0.34;
      const c = (paramsRef.current.curl / 100) * 0.17;
      const damp = Math.pow(DAMP, dtScale);
      const decay = Math.pow(0.9, dtScale);

      for (let s = strands.length - 1; s >= 0; s--) {
        const strand = strands[s];
        const beads = strand.beads;

        if (strand.settledAt && clock - strand.settledAt > FADE_HOLD + FADE_DUR) {
          strands.splice(s, 1);
          continue;
        }

        for (const b of beads) {
          // うねりは三角関数の合成で作る擬似ノイズ(乱数を使わないので再現できる)
          if (c > 0) {
            const a = b.x * 0.013 + clock * 0.0012 + strand.seed * TAU;
            const e = b.y * 0.011 - clock * 0.0009 + strand.seed * 4.11;
            b.vx += Math.sin(a * 1.3 + Math.cos(e)) * c * dtScale;
            b.vy += Math.cos(e * 1.2 + Math.sin(a)) * c * dtScale;
          }
          b.vy += g * dtScale;
          b.vx *= damp;
          b.vy *= damp;
          b.x += b.vx * dtScale;
          b.y += b.vy * dtScale;
          b.hit *= decay;
        }

        // 距離拘束(Jakobsen法): 隣接ノードの距離を SEG へ戻す補正を流す。
        // 拘束 → 衝突 → 拘束 → 衝突 と交互に回すと、押し出しで折れたロープがその場でならされる
        for (let k = 0; k < 3; k++) {
          for (let i = 1; i < beads.length; i++) {
            const a = beads[i - 1];
            const b = beads[i];
            const dx = b.x - a.x;
            const dy = b.y - a.y;
            const d = Math.hypot(dx, dy) || 0.0001;
            const corr = ((d - SEG) / d) * 0.5;
            const ox = dx * corr;
            const oy = dy * corr;
            a.x += ox;
            a.y += oy;
            b.x -= ox;
            b.y -= oy;
          }
          if (k > 0) for (const b of beads) collide(b);
        }
      }
    };

    const stepInput = (dtScale: number) => {
      const params = paramsRef.current;
      const useAuto = params.auto && clock - ptr.at > IDLE_MS;
      if (useAuto !== wasAuto) {
        closeLive();
        cur.on = false;
        wasAuto = useAuto;
      }

      let tx = 0;
      let ty = 0;
      let spray = false;
      if (useAuto) {
        // ワードマークを横切るリサージュ。無操作でも衝突が起きる軌道と速度にする
        // (遅すぎると SEG ごとの打点が間引かれて線が途切れて見える)
        const a = clock * 0.0013;
        tx = w * (0.5 + 0.34 * Math.sin(a));
        ty = h * (0.44 + 0.2 * Math.sin(a * 1.63 + 0.9));
        spray = true;
      } else if (ptr.inside && (ptr.down || clock - ptr.at < IDLE_MS)) {
        // 押していない・動いてもいないポインタの上で吹き続けない(その場に積み上がる)
        tx = ptr.x;
        ty = ptr.y;
        spray = true;
      }

      cur.press += ((ptr.down ? 1 : 0) - cur.press) * Math.min(1, 0.2 * dtScale);
      cur.s += ((spray ? 1 : 0) - cur.s) * Math.min(1, 0.09 * dtScale);

      if (!spray) {
        cur.on = false;
        closeLive();
        return;
      }
      if (!cur.on) {
        cur.x = tx;
        cur.y = ty;
        cur.vx = 0;
        cur.vy = 0;
        cur.on = true;
      } else {
        const px = cur.x;
        const py = cur.y;
        cur.x += (tx - cur.x) * Math.min(1, 0.35 * dtScale);
        cur.y += (ty - cur.y) * Math.min(1, 0.35 * dtScale);
        cur.vx = cur.x - px;
        cur.vy = cur.y - py;
      }
      emit(cur.x, cur.y);
    };

    // ---- 描画 --------------------------------------------------------------------------
    // リボンの両側の頂点(1ストランド1 fill で使い回す)
    const ax = new Float32Array(MAX_BEADS);
    const ay = new Float32Array(MAX_BEADS);
    const bx = new Float32Array(MAX_BEADS);
    const by = new Float32Array(MAX_BEADS);

    const drawWordmark = () => {
      if (!wm.size) return;
      setFont(ctx, wm.size);
      ctx.fillStyle = "rgba(34, 30, 25, 0.06)";
      ctx.fillText(WORDMARK, wm.x, wm.y);
      ctx.lineWidth = 1;
      ctx.strokeStyle = "rgba(34, 30, 25, 0.24)";
      ctx.strokeText(WORDMARK, wm.x, wm.y);
    };

    const drawStrand = (strand: Strand) => {
      const beads = strand.beads;
      const n = beads.length;
      if (n < 2) return;

      const warm = strand.settledAt
        ? Math.max(0, 1 - (clock - strand.settledAt) / WARM_MS)
        : 1;
      const gone = strand.settledAt
        ? Math.max(0, Math.min(1, (clock - strand.settledAt - FADE_HOLD) / FADE_DUR))
        : 0;
      const alpha = (0.26 + 0.5 * warm) * (1 - gone);
      if (alpha <= 0.01) return;

      // 各ノードの接線から法線を出し、太さプロファイルを掛けて両側の頂点を作る
      for (let i = 0; i < n; i++) {
        const prev = beads[Math.max(0, i - 1)];
        const next = beads[Math.min(n - 1, i + 1)];
        const tx = next.x - prev.x;
        const ty = next.y - prev.y;
        const len = Math.hypot(tx, ty) || 1;
        const prof = Math.pow(Math.sin((Math.PI * (i + 0.5)) / n), 0.55);
        const hw = (beads[i].w * prof) / 2 + 0.35;
        const ox = (-ty / len) * hw;
        const oy = (tx / len) * hw;
        ax[i] = beads[i].x + ox;
        ay[i] = beads[i].y + oy;
        bx[i] = beads[i].x - ox;
        by[i] = beads[i].y - oy;
      }

      const r = Math.round(INK[0] + (AMBER[0] - INK[0]) * warm);
      const g = Math.round(INK[1] + (AMBER[1] - INK[1]) * warm);
      const b = Math.round(INK[2] + (AMBER[2] - INK[2]) * warm);
      ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`;

      // 片側→反対側を逆順に辿って1本の閉パスにする(1ストランド1 fill)。
      // 頂点を lineTo で結ぶとノードの揺れがそのまま角として出るので、
      // 「制御点=頂点 / 終点=次との中点」の二次ベジェで通すと1パスのまま滑らかになる。
      ctx.beginPath();
      ctx.moveTo(ax[0], ay[0]);
      for (let i = 1; i < n - 1; i++) {
        ctx.quadraticCurveTo(ax[i], ay[i], (ax[i] + ax[i + 1]) / 2, (ay[i] + ay[i + 1]) / 2);
      }
      ctx.lineTo(ax[n - 1], ay[n - 1]);
      ctx.lineTo(bx[n - 1], by[n - 1]);
      for (let i = n - 2; i > 0; i--) {
        ctx.quadraticCurveTo(bx[i], by[i], (bx[i] + bx[i - 1]) / 2, (by[i] + by[i - 1]) / 2);
      }
      ctx.lineTo(bx[0], by[0]);
      ctx.closePath();
      ctx.fill();
    };

    const draw = () => {
      ctx.clearRect(0, 0, w, h);
      drawWordmark();
      for (const strand of strands) drawStrand(strand);

      // ワードマークに触れた点だけアンバーで灯す
      for (const strand of strands) {
        for (const b of strand.beads) {
          if (b.hit <= 0.12) continue;
          ctx.fillStyle = `rgba(217, 119, 6, ${0.2 + b.hit * 0.55})`;
          ctx.beginPath();
          ctx.arc(b.x, b.y, b.w * 0.42 + 1.1, 0, TAU);
          ctx.fill();
        }
      }

      // 筆先の計測リング(標本モチーフ。太さの見える化も兼ねる)
      if (cur.on && cur.s > 0.04) {
        const ring = 9 + (paramsRef.current.pressure / 100) * 9 + cur.press * 9;
        ctx.lineWidth = 1;
        ctx.strokeStyle = `rgba(217, 119, 6, ${0.4 * cur.s})`;
        ctx.beginPath();
        ctx.arc(cur.x, cur.y, ring, 0, TAU);
        ctx.stroke();
        ctx.beginPath();
        for (let i = 0; i < 4; i++) {
          const a = (i / 4) * TAU;
          const cos = Math.cos(a);
          const sin = Math.sin(a);
          ctx.moveTo(cur.x + cos * (ring + 3), cur.y + sin * (ring + 3));
          ctx.lineTo(cur.x + cos * (ring + 8), cur.y + sin * (ring + 8));
        }
        ctx.stroke();
      }
    };

    // ---- ループと停止条件 ---------------------------------------------------------------
    const frame = (time: number) => {
      if (!last) last = time;
      const dt = Math.min(50, time - last);
      last = time;
      clock += dt;
      const dtScale = dt / 16.6667;
      stepInput(dtScale);
      stepPhysics(dtScale);
      draw();
      raf = requestAnimationFrame(frame);
    };

    const stop = () => {
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
    };

    const start = () => {
      if (raf || mql.matches || !onScreen || !visible) return;
      last = 0;
      raf = requestAnimationFrame(frame);
    };

    /** reduced-motion 用: 同じ物理を同期で回し、落ち着いた「計算結果」を1枚だけ描く */
    const drawStatic = () => {
      strands.length = 0;
      live = null;
      cur.on = false;
      cur.vx = 0;
      cur.vy = 0;
      cur.press = 0;
      cur.s = 0;

      const sweeps = [
        { y0: 0.24, y1: 0.62, amp: 0.1 },
        { y0: 0.68, y1: 0.34, amp: 0.14 },
      ];
      for (const sweep of sweeps) {
        for (let i = 0; i <= 64; i++) {
          const t = i / 64;
          const x = w * (0.1 + 0.8 * t);
          const y = h * (sweep.y0 + (sweep.y1 - sweep.y0) * t + sweep.amp * Math.sin(t * 7.2));
          emit(x, y);
        }
        closeLive();
      }
      for (let i = 0; i < 260; i++) {
        clock += 16.6667;
        stepPhysics(1);
      }
      // 「置かれた線」と「描きたての線」の両方が見えるよう定着時刻をずらす
      if (strands[0]) strands[0].settledAt = clock - WARM_MS;
      if (strands[1]) strands[1].settledAt = clock - WARM_MS * 0.4;
      draw();
    };

    const ro = new ResizeObserver((entries) => {
      // contentRect はレイアウト寸法(CSS transform の影響を受けない)
      const box = entries[0].contentRect;
      w = Math.max(1, Math.round(box.width));
      h = Math.max(1, Math.round(box.height));
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.round(w * dpr);
      canvas.height = Math.round(h * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      buildMask();
      if (mql.matches) drawStatic();
      else if (!raf) draw();
    });
    ro.observe(stage);

    const io = new IntersectionObserver(
      (entries) => {
        onScreen = entries[0].isIntersecting;
        if (onScreen) start();
        else stop();
      },
      { rootMargin: "120px" },
    );
    io.observe(stage);

    const onVisibility = () => {
      visible = document.visibilityState !== "hidden";
      if (visible) start();
      else stop();
    };

    const toLocal = (clientX: number, clientY: number) => {
      const rect = stage.getBoundingClientRect();
      if (!rect.width || !rect.height) return;
      // 変形後の矩形 → 論理座標へ比率で換算(親に scale が掛かっていても合う)
      ptr.x = ((clientX - rect.left) / rect.width) * w;
      ptr.y = ((clientY - rect.top) / rect.height) * h;
      ptr.inside = true;
      ptr.at = clock;
    };

    const onMove = (e: PointerEvent) => toLocal(e.clientX, e.clientY);
    const onDown = (e: PointerEvent) => {
      toLocal(e.clientX, e.clientY);
      ptr.down = true;
    };
    const onUp = () => {
      ptr.down = false;
    };
    const onLeave = () => {
      ptr.inside = false;
      ptr.down = false;
    };
    const onMotionChange = () => {
      if (mql.matches) {
        stop();
        drawStatic();
      } else {
        strands.length = 0;
        live = null;
        start();
      }
    };

    stage.addEventListener("pointermove", onMove, { passive: true });
    stage.addEventListener("pointerdown", onDown, { passive: true });
    stage.addEventListener("pointerup", onUp, { passive: true });
    stage.addEventListener("pointercancel", onLeave, { passive: true });
    stage.addEventListener("pointerleave", onLeave, { passive: true });
    document.addEventListener("visibilitychange", onVisibility);
    mql.addEventListener("change", onMotionChange);

    // 実フォントが載る前に測るとフォールバック書体の字幅でマスクができるので必ず組み直す
    let alive = true;
    document.fonts?.ready.then(() => {
      if (!alive) return;
      buildMask();
      if (mql.matches) drawStatic();
    });

    apiRef.current = {
      clear: () => {
        strands.length = 0;
        live = null;
        if (mql.matches) drawStatic();
        else if (!raf) draw();
      },
      restatic: () => {
        if (mql.matches) drawStatic();
      },
    };

    if (mql.matches) drawStatic();
    else start();

    return () => {
      alive = false;
      apiRef.current = null;
      stop();
      ro.disconnect();
      io.disconnect();
      stage.removeEventListener("pointermove", onMove);
      stage.removeEventListener("pointerdown", onDown);
      stage.removeEventListener("pointerup", onUp);
      stage.removeEventListener("pointercancel", onLeave);
      stage.removeEventListener("pointerleave", onLeave);
      document.removeEventListener("visibilitychange", onVisibility);
      mql.removeEventListener("change", onMotionChange);
    };
  }, []);

  const values = { pressure, gravity, curl };
  const setters = { pressure: setPressure, gravity: setGravity, curl: setCurl };

  return (
    <section
      aria-labelledby="canvas-string-title"
      className="w-full max-w-4xl overflow-hidden rounded-2xl border border-gray-200 bg-white"
    >
      {/* 見出し行 */}
      <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-5 py-4 sm:px-7 sm:py-5">
        <h2
          id="canvas-string-title"
          className="flex items-center gap-2 text-[12px] font-bold tracking-[0.14em] whitespace-nowrap text-gray-900"
        >
          <span aria-hidden="true" className="inline-block size-1 shrink-0 bg-amber-600" />
          軌跡ストランド・キャンバス
        </h2>
        {/* ポインタ種別の出し分けは純CSSで行う(JSでデバイス判定するとハイドレーション不一致になる) */}
        <p className="text-[11px] text-gray-500">
          <span className="motion-reduce:hidden">
            <span className="[@media(pointer:coarse)]:hidden">なぞると描け、押し込むと太くなります</span>
            <span className="hidden [@media(pointer:coarse)]:inline">指でなぞると描けます</span>
          </span>
          <span className="hidden motion-reduce:inline">静止画として計算結果を表示中</span>
        </p>
      </div>

      <div className="flex flex-col border-t border-gray-200 md:flex-row md:items-stretch">
        {/* 描画面 */}
        <div
          ref={stageRef}
          className="relative min-h-[260px] w-full touch-pan-y select-none sm:min-h-[300px] md:min-h-[360px] md:flex-1"
        >
          <canvas ref={canvasRef} aria-hidden="true" className="absolute inset-0 block h-full w-full" />
        </div>

        {/* パラメータパネル: カードを並べず hairline で区切る */}
        <div className="flex shrink-0 flex-col border-t border-gray-200 md:w-[224px] md:border-t-0 md:border-l">
          <p className="border-b border-gray-200 px-5 py-2.5 text-[11px] font-bold tracking-[0.12em] text-gray-500">
            描画パラメータ
          </p>

          <div className="flex flex-1 flex-col">
            {PARAMS.map((p) => (
              <label key={p.key} className="block border-b border-gray-200 px-5 py-3.5">
                <span className="flex items-baseline justify-between gap-2">
                  <span className="text-[12px] font-bold tracking-[0.08em] text-gray-900">
                    {p.label}
                  </span>
                  {/* range が値を読み上げるので、この表示は装飾として外す。
                      <output> はラベル対象要素なので label 直下に置くと input と紐づかなくなる */}
                  <span
                    aria-hidden="true"
                    className="font-mono text-[10px] font-bold text-amber-600 tabular-nums"
                  >
                    {String(values[p.key]).padStart(3, "0")}
                  </span>
                </span>
                <input
                  type="range"
                  min={p.min}
                  max={p.max}
                  value={values[p.key]}
                  onChange={(e) => setters[p.key](Number(e.target.value))}
                  className="mt-2.5 h-1 w-full cursor-pointer accent-amber-600 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
                />
                <span className="mt-2 block text-[10px] leading-relaxed text-gray-500">{p.note}</span>
              </label>
            ))}
          </div>

          <div className="flex items-center gap-2 px-5 py-3.5">
            <button
              type="button"
              onClick={() => apiRef.current?.clear()}
              className="flex-1 border border-gray-300 px-3 py-2 text-[11px] font-bold tracking-[0.08em] text-gray-700 transition-colors duration-200 hover:border-gray-900 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
            >
              消去
            </button>
            <button
              type="button"
              aria-pressed={auto}
              onClick={() => setAuto((v) => !v)}
              className={`flex-1 border px-3 py-2 text-[11px] font-bold tracking-[0.08em] transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none ${
                auto
                  ? "border-amber-600 bg-amber-600 text-white hover:bg-amber-700"
                  : "border-gray-300 text-gray-700 hover:border-gray-900 hover:text-gray-900"
              }`}
            >
              自動描画
            </button>
          </div>
        </div>
      </div>

      {/* 注記とテキストCTA */}
      <div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-3 border-t border-gray-200 px-5 py-4 sm:px-7">
        <p className="max-w-sm text-[11px] leading-[1.8] text-gray-500">
          線はワードマークに当たると弾かれ、触れた点だけがアンバーに灯ります。<span className="hidden motion-reduce:inline">モーション低減の設定を検知したため、アニメーションは止めて計算結果を1枚だけ描いています(数値を変えると再計算します)。</span>
        </p>
        <a
          href="#"
          className="group inline-flex items-center gap-2 text-[13px] font-semibold whitespace-nowrap text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
        >
          <span className="relative">
            インタラクションの相談
            <span className="absolute -bottom-1 left-0 h-px w-full origin-left scale-x-100 bg-gray-900 transition-transform duration-300 ease-out group-hover:scale-x-0 motion-reduce:transition-none" />
            <span className="absolute -bottom-1 left-0 h-px w-full origin-right scale-x-0 bg-amber-600 transition-transform delay-150 duration-300 ease-out group-hover:origin-left group-hover:scale-x-100 motion-reduce:transition-none" />
          </span>
          <span
            aria-hidden="true"
            className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1 motion-reduce:transition-none"
          >
            →
          </span>
        </a>
      </div>
    </section>
  );
}

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

npx shadcn@latest add https://designs.first-ch.com/r/cursor-string-canvas.json