BLK-86Blocks

3D製品コンフィギュレーター(部位を選んで色と素材を替える)

製品を部位ごとに選んで色と素材を組み立てる、3Dの製品コンフィギュレーター。抽象形状のラウンジチェア(座面・背もたれ・肘掛け・脚の4部位)を画面上でクリックするか部位ボタンで選び、5色の色見本と3種の素材(つや・マット・布)を切り替えると、右の構成表に部位ごとの色・素材・加算額、合計の目安と納期の目安が同時に反映される。入力の正は本物のラジオボタン(fieldset と legend)で、矢印キーでの移動・読み上げ・無効化(脚には布を選べない)がブラウザの標準動作のまま揃うため、キーボードだけで操作が完結する。3D側のクリックは Raycaster で部位を拾う近道で、選んだ部位は一回り大きい裏面の複製(反転ハル)でアンバーに縁取られ、その部位が見やすい角度へ椅子が向きを変えて小さく浮き上がる。素材は1つの MeshPhysicalMaterial のパラメータ差し替えで作り、つやは低い粗さとクリアコート、マットは高い粗さ、布はシーン(毛足の照り)と、32pxの canvas に描いた籠目を繰り返す bumpMap で表す。映り込みは白い部屋と発光板を PMREMGenerator で畳んだ環境マップ、影は ShadowMaterial の床1枚で、画像・モデルファイルは一切使わない。色見本の色が画面でもその色に見えるよう、トーンマッピングは NeutralToneMapping にしている。描画はオンデマンドで、視点・色・浮き上がりの補間が終わると描画ループを止める。3Dライブラリ(three)は画面に入った実寸表示のときだけ useEffect 内の動的importで読み込み(本番ビルドの実測で追加チャンク計 753,967バイト・gzip 187,132バイト)、一覧の縮小カード・WebGL非対応・コンテキスト喪失・低速環境では、同じ部位・色・素材を斜め前から見た線画(SVG)で表示する。prefers-reduced-motion では補間も浮き上がりも使わず、変更のたびに最終状態を1枚だけ描く。価格と納期はサンプル。角丸・末尾の矢印リンク・英字の小見出しは使わず、ラジオの区画と構成表を hairline 罫線で組んでいる。

追加日:
2026-10-01
依存:
three
tags
#3d #product-configurator #customizer #ecommerce #webgl #interactive #keyboard #form #pricing #no-image #dynamic-import

プレビュー

座面から脚まで、部位ごとに選べる椅子

椅子の部位をクリックするか、下の部位ボタンで選んでから、色見本と素材を切り替えてください。組み合わせは33,750通りあり、右の構成表と金額の目安が同時に変わります。

部位
4か所
色見本
5色
素材
3種

クリックで部位を選択・ドラッグで回転(矢印キーでも回転・Enterで部位の正面へ)

部位を選ぶ
座面の色藍・琥珀は受注色(+2,000円)
座面の素材

織り布の張り地。やわらかく、光が表面の毛足で散ります

構成と金額の目安

01座面
生成り・布
+6,000
02背もたれ
生成り・布
+5,000
03肘掛け
墨・マット
±0
04脚
琥珀・つや
+4,500
本体
68,000

合計の目安(税込)

83,500円

納期の目安 約5週間受注色を1か所に含むため、標準より2週間ほど延びます

製品・価格・納期はすべて構成を示すためのサンプルです。自社の品目と価格表に差し替えてお使いください。見積もり依頼は選んだ構成・合計・納期を問い合わせページへ渡すので、送り先を自社のページに差し替えてください。画面の色はディスプレイによって実物と異なって見えることがあります。

"use client";

import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import { Montserrat } from "next/font/google";
// 型だけの import。tsc が消すので 3Dライブラリのコードは1バイトも入らない(実体は下の動的 import)。
import type * as THREE from "three";

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

/**
 * 3D製品コンフィギュレーター(部位を選んで色と素材を替える)
 *
 * 差別化: 既存の3D標本(three-object-gallery-hero)は完成品を切り替えて眺める展示、地球儀(globe-reach-arcs)は
 * 座標データの説明図だった。本ブロックは1つの製品を部位ごとに選んで「色×素材」を組み立てる入力フォームで、
 * 選んだ構成・加算額・合計・納期の目安が右の台帳へ同時に反映される。角丸・末尾の矢印リンク・英字の小見出しは置かない。
 *
 * 汎用技法メモ:
 * 1. **正はフォーム、3Dは従**。部位・色・素材はすべて本物の `<input type="radio">`(fieldset + legend)で持つ。
 *    矢印キーでの移動・読み上げ・無効化(脚に布は選べない)がブラウザ任せで揃い、3Dが無い環境でも操作が完結する。
 *    3D側のクリック(Raycaster)は「部位ラジオを選ぶ近道」にすぎない。
 * 2. **素材は1つの MeshPhysicalMaterial のパラメータ差し替えで作る**。つや=roughness 低+clearcoat、
 *    マット=roughness 高、布=sheen(布の産毛の照り)+ bumpMap。織り目の bumpMap は 32px の canvas に
 *    描いた籠目を CanvasTexture にして繰り返すだけで、画像ファイルは要らない。
 *    clearcoat・sheen・bumpMap の有無はシェーダの分岐が変わるので、切り替え時に `needsUpdate = true`。
 * 3. **トーンマッピングは NeutralToneMapping**。ACES は色見本の彩度と明度を大きく曲げるので、
 *    「選んだ色が画面でその色に見える」ことが要る用途では Neutral の方が素直。
 * 4. **選択中の部位は反転ハル(BackSide の一回り大きい複製)で縁取る**。拡大率を軸ごとに
 *    `1 + 2t / 寸法` で出すと、細い脚でも厚い座面でも縁の太さがそろう。
 * 5. **描画はオンデマンド**。視点の補間・色の補間・部位の小さな跳ねが終わったら rAF を止め、
 *    操作のたびに回し直す。低速判定(立ち上がり10フレームを捨て、48フレームの平均が32ms超で
 *    解像度を下げ、なお遅ければ平面図へ降格)は「動いたフレーム」だけで数える。
 * 6. 起動条件は「画面内」かつ「縮小プレビューでない」(rect.width ÷ offsetWidth)。WebGL が無い・
 *    コンテキストを失った・フレームが間に合わないときは、同じ部位・色・素材を SVG の線画で出す。
 * 7. prefers-reduced-motion では補間も跳ねも使わず、変更のたびに最終状態を1枚だけ描く。
 *
 * 文言・製品形状・価格・配色はすべて First CH のオリジナル(価格はサンプル)。
 */

type ThreeModule = typeof import("three");
type RoundedBoxCtor = typeof import("three/examples/jsm/geometries/RoundedBoxGeometry.js").RoundedBoxGeometry;

const AMBER = 0xd97706;

/* ------------------------------------------------------------------ データ */

type PartId = "seat" | "back" | "arms" | "legs";
type FinishId = "gloss" | "matte" | "fabric";
type ColorId = "kinari" | "sumi" | "koke" | "ai" | "kohaku";

type Part = {
  id: PartId;
  name: string;
  no: string;
  /** 選んだときに向ける視点(台の回転・カメラの仰角) */
  view: { yaw: number; elev: number };
  /** ラベルを付ける点(椅子のローカル座標) */
  anchor: [number, number, number];
  /** 素材ごとの加算額。null はその部位では選べない */
  finishPrice: Record<FinishId, number | null>;
};

const PARTS: Part[] = [
  {
    id: "seat",
    name: "座面",
    no: "01",
    view: { yaw: -0.6, elev: 0.46 },
    anchor: [0.2, 0.9, 0.36],
    finishPrice: { gloss: 4000, matte: 0, fabric: 6000 },
  },
  {
    id: "back",
    name: "背もたれ",
    no: "02",
    view: { yaw: 0.55, elev: 0.28 },
    anchor: [0, 1.72, -0.6],
    finishPrice: { gloss: 3000, matte: 0, fabric: 5000 },
  },
  {
    id: "arms",
    name: "肘掛け",
    no: "03",
    view: { yaw: -1.05, elev: 0.32 },
    anchor: [0.8, 1.22, 0.3],
    finishPrice: { gloss: 2000, matte: 0, fabric: 3000 },
  },
  {
    id: "legs",
    name: "脚",
    no: "04",
    view: { yaw: -0.42, elev: 0.08 },
    anchor: [0.64, 0.24, 0.52],
    finishPrice: { gloss: 2500, matte: 0, fabric: null },
  },
];

const PART_INDEX: Record<PartId, number> = { seat: 0, back: 1, arms: 2, legs: 3 };

type Finish = { id: FinishId; name: string; note: string };

const FINISHES: Finish[] = [
  { id: "gloss", name: "つや", note: "艶のある塗装仕上げ。水拭きしやすく、光を受けると輪郭が立ちます" },
  { id: "matte", name: "マット", note: "艶を抑えた仕上げ。指紋が目立たず、落ち着いた見え方になります" },
  { id: "fabric", name: "布", note: "織り布の張り地。やわらかく、光が表面の毛足で散ります" },
];

type Swatch = { id: ColorId; name: string; hex: string; premium: boolean };

const SWATCHES: Swatch[] = [
  { id: "kinari", name: "生成り", hex: "#e8e0d0", premium: false },
  { id: "sumi", name: "墨", hex: "#2f2b28", premium: false },
  { id: "koke", name: "苔", hex: "#66724f", premium: false },
  { id: "ai", name: "藍", hex: "#34475f", premium: true },
  { id: "kohaku", name: "琥珀", hex: "#d97706", premium: true },
];

const SWATCH: Record<ColorId, Swatch> = Object.fromEntries(SWATCHES.map((s) => [s.id, s])) as Record<ColorId, Swatch>;
const FINISH: Record<FinishId, Finish> = Object.fromEntries(FINISHES.map((f) => [f.id, f])) as Record<FinishId, Finish>;

const BASE_PRICE = 68000;
const PREMIUM_PRICE = 2000;
const LEAD_WEEKS = 3;
const PREMIUM_LEAD_WEEKS = 5;
// 見積もり依頼の送り先。自社の問い合わせページに差し替え、受け取った値をフォームの初期値に入れる
const QUOTE_ACTION = "/contact";

type Choice = { color: ColorId; finish: FinishId };
type Config = Record<PartId, Choice>;

const DEFAULT_CONFIG: Config = {
  seat: { color: "kinari", finish: "fabric" },
  back: { color: "kinari", finish: "fabric" },
  arms: { color: "sumi", finish: "matte" },
  legs: { color: "kohaku", finish: "gloss" },
};

/** 組み合わせの総数(部位ごとに 色の数 × 選べる素材の数 を掛ける) */
const COMBINATIONS = PARTS.reduce(
  (acc, p) => acc * SWATCHES.length * FINISHES.filter((f) => p.finishPrice[f.id] !== null).length,
  1,
);

// toLocaleString はSSRとブラウザで結果が割れうるので使わない
const yen = (n: number) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");

const partPrice = (p: Part, c: Choice) => (p.finishPrice[c.finish] ?? 0) + (SWATCH[c.color].premium ? PREMIUM_PRICE : 0);

/* ------------------------------------------------------------------ 3D */

type ConfiguratorApi = {
  apply: (config: Config, active: PartId) => void;
  nudge: (dYaw: number, dElev: number) => void;
  recenter: () => void;
  setRunning: (running: boolean) => void;
  dispose: () => void;
};

type ConfiguratorOptions = {
  canvas: HTMLCanvasElement;
  host: HTMLElement;
  label: HTMLElement;
  config: Config;
  active: PartId;
  onPick: (part: PartId) => void;
  onFirstFrame: () => void;
  onFail: () => void;
};

/** 布の織り目(籠目)を 32px の canvas に描き、bumpMap 用のテクスチャにする */
function makeWeave(T: ThreeModule) {
  const size = 32;
  const c = document.createElement("canvas");
  c.width = size;
  c.height = size;
  const ctx = c.getContext("2d");
  if (ctx) {
    ctx.fillStyle = "#6e6e6e";
    ctx.fillRect(0, 0, size, size);
    for (let cy = 0; cy < 4; cy++) {
      for (let cx = 0; cx < 4; cx++) {
        const x = cx * 8;
        const y = cy * 8;
        ctx.fillStyle = "#c8c8c8";
        if ((cx + cy) % 2 === 0) {
          ctx.fillRect(x, y + 1, 8, 2);
          ctx.fillRect(x, y + 5, 8, 2);
        } else {
          ctx.fillRect(x + 1, y, 2, 8);
          ctx.fillRect(x + 5, y, 2, 8);
        }
      }
    }
  }
  const tex = new T.CanvasTexture(c);
  tex.wrapS = T.RepeatWrapping;
  tex.wrapT = T.RepeatWrapping;
  tex.repeat.set(7, 7);
  tex.colorSpace = T.NoColorSpace;
  return tex;
}

function createConfigurator(T: ThreeModule, RoundedBox: RoundedBoxCtor, opts: ConfiguratorOptions): ConfiguratorApi {
  const { canvas, host, label, onPick, onFirstFrame, onFail } = opts;

  const renderer = new T.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: "low-power" });
  let dpr = Math.min(window.devicePixelRatio || 1, 2);
  renderer.setPixelRatio(dpr);
  renderer.toneMapping = T.NeutralToneMapping;
  renderer.toneMappingExposure = 0.94;
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = T.PCFShadowMap;

  const scene = new T.Scene();
  const camera = new T.PerspectiveCamera(30, 1, 0.1, 40);
  const TARGET_Y = 0.84;

  const geometries: THREE.BufferGeometry[] = [];
  const materials: THREE.Material[] = [];
  const keep = <G extends THREE.BufferGeometry>(g: G) => {
    geometries.push(g);
    return g;
  };

  // --- 環境マップ: 白い部屋+発光板を畳んで作る(画像は読まない)-------------------------
  const envScene = new T.Scene();
  const envBox = new T.BoxGeometry();
  const envMats: THREE.Material[] = [];
  const roomMat = new T.MeshBasicMaterial({ color: 0xefebe4, side: T.BackSide });
  envMats.push(roomMat);
  const room = new T.Mesh(envBox, roomMat);
  room.scale.set(20, 14, 20);
  envScene.add(room);
  const panel = (intensity: number, color: number, p: [number, number, number], s: [number, number, number]) => {
    const mat = new T.MeshBasicMaterial({ color: new T.Color(color).multiplyScalar(intensity) });
    envMats.push(mat);
    const m = new T.Mesh(envBox, mat);
    m.position.set(...p);
    m.scale.set(...s);
    envScene.add(m);
  };
  panel(9, 0xffffff, [-3.6, 3.4, 2.8], [0.1, 3.2, 4.4]); // キー(左上の面光源)
  panel(4, 0xfff4e6, [4.2, 1.6, 1.4], [0.1, 2.8, 3.6]); // フィル
  panel(6, 0xffffff, [0, 6.4, 0], [5, 0.1, 5]); // トップ
  panel(3, AMBER, [0, 0.2, -5.8], [4, 2.2, 0.1]); // 背面のアンバー(つやの映り込みに色を差す)
  const pmrem = new T.PMREMGenerator(renderer);
  const envTarget = pmrem.fromScene(envScene, 0.03);
  scene.environment = envTarget.texture;
  scene.environmentIntensity = 0.9;
  envBox.dispose();
  for (const m of envMats) m.dispose();
  pmrem.dispose();

  // --- ライト -------------------------------------------------------------------------------
  const key = new T.DirectionalLight(0xffffff, 1.9);
  key.position.set(2.2, 4.2, 2.8);
  key.castShadow = true;
  key.shadow.mapSize.set(1024, 1024);
  key.shadow.camera.near = 1;
  key.shadow.camera.far = 12;
  key.shadow.camera.left = -2;
  key.shadow.camera.right = 2;
  key.shadow.camera.top = 2;
  key.shadow.camera.bottom = -2;
  key.shadow.bias = -0.0008;
  key.shadow.radius = 4;
  scene.add(key);
  scene.add(new T.HemisphereLight(0xffffff, 0xe7dfd2, 0.7));

  const groundGeo = keep(new T.PlaneGeometry(12, 12));
  const groundMat = new T.ShadowMaterial({ opacity: 0.16, color: 0x231f1b });
  materials.push(groundMat);
  const ground = new T.Mesh(groundGeo, groundMat);
  ground.rotation.x = -Math.PI / 2;
  ground.receiveShadow = true;
  scene.add(ground);

  // --- 椅子(形はすべてコード生成)-----------------------------------------------------------
  const chair = new T.Group();
  scene.add(chair);

  const weave = makeWeave(T);
  const outlineOn = new T.MeshBasicMaterial({ color: AMBER, side: T.BackSide });
  const outlineHover = new T.MeshBasicMaterial({ color: 0xa8a29e, side: T.BackSide });
  materials.push(outlineOn, outlineHover);

  type PartRig = {
    id: PartId;
    group: THREE.Group;
    mat: THREE.MeshPhysicalMaterial;
    meshes: { mesh: THREE.Mesh; outline: THREE.Mesh; home: THREE.Vector3; hop: THREE.Vector3 }[];
    color: THREE.Color;
    colorTarget: THREE.Color;
    finish: FinishId | null;
  };

  const rigs: Record<PartId, PartRig> = {} as Record<PartId, PartRig>;
  const pickables: THREE.Mesh[] = [];

  const addPart = (id: PartId) => {
    const group = new T.Group();
    chair.add(group);
    const mat = new T.MeshPhysicalMaterial({ color: 0xffffff, roughness: 0.7 });
    materials.push(mat);
    rigs[id] = { id, group, mat, meshes: [], color: new T.Color(), colorTarget: new T.Color(), finish: null };
    return group;
  };

  const OUTLINE = 0.016;
  const addMesh = (
    id: PartId,
    geo: THREE.BufferGeometry,
    pos: [number, number, number],
    rot: [number, number, number],
    hop: [number, number, number],
  ) => {
    const rig = rigs[id];
    keep(geo);
    const mesh = new T.Mesh(geo, rig.mat);
    mesh.position.set(...pos);
    mesh.rotation.set(...rot);
    mesh.castShadow = true;
    mesh.receiveShadow = true;
    mesh.userData.part = id;
    // 反転ハル。軸ごとに 1 + 2t/寸法 で拡げると、細い脚でも厚い座面でも縁の太さがそろう
    geo.computeBoundingBox();
    const size = new T.Vector3();
    geo.boundingBox?.getSize(size);
    const outline = new T.Mesh(geo, outlineOn);
    outline.scale.set(1 + (2 * OUTLINE) / size.x, 1 + (2 * OUTLINE) / size.y, 1 + (2 * OUTLINE) / size.z);
    outline.visible = false;
    mesh.add(outline);
    rig.group.add(mesh);
    rig.meshes.push({ mesh, outline, home: mesh.position.clone(), hop: new T.Vector3(...hop) });
    pickables.push(mesh);
  };

  addPart("legs");
  const legGeo = () => new T.CylinderGeometry(0.036, 0.05, 0.66, 20);
  for (const [x, z] of [
    [-0.6, 0.46],
    [0.6, 0.46],
    [-0.6, -0.46],
    [0.6, -0.46],
  ] as const) {
    // 脚は外へ少し開く(下端が外側に出る)
    addMesh("legs", legGeo(), [x, 0.33, z], [Math.sign(z) * 0.07, 0, -Math.sign(x) * 0.07], [Math.sign(x) * 0.07, 0, Math.sign(z) * 0.05]);
  }
  addMesh("legs", new T.BoxGeometry(1.3, 0.06, 1.02), [0, 0.63, 0], [0, 0, 0], [0, -0.06, 0]);

  addPart("seat");
  addMesh("seat", new RoundedBox(1.42, 0.24, 1.14, 4, 0.085), [0, 0.78, 0.02], [0, 0, 0], [0, 0.1, 0]);

  addPart("back");
  addMesh("back", new RoundedBox(1.42, 0.94, 0.22, 4, 0.085), [0, 1.3, -0.5], [-0.2, 0, 0], [0, 0.03, -0.1]);

  addPart("arms");
  for (const side of [-1, 1]) {
    addMesh("arms", new RoundedBox(0.16, 0.52, 1.1, 4, 0.06), [side * 0.8, 0.94, -0.01], [0, 0, 0], [side * 0.09, 0, 0]);
  }

  const white = new T.Color(0xffffff);

  const setFinish = (rig: PartRig, finish: FinishId) => {
    if (rig.finish === finish) return;
    rig.finish = finish;
    const m = rig.mat;
    m.roughness = finish === "gloss" ? 0.16 : finish === "matte" ? 0.74 : 0.95;
    m.metalness = 0;
    m.clearcoat = finish === "gloss" ? 1 : 0;
    m.clearcoatRoughness = 0.07;
    m.sheen = finish === "fabric" ? 1 : 0;
    m.sheenRoughness = 0.65;
    m.bumpMap = finish === "fabric" ? weave : null;
    m.bumpScale = 1.4;
    m.envMapIntensity = finish === "gloss" ? 1.05 : 0.7;
    // clearcoat・sheen・bumpMap の有無はシェーダの分岐が変わる
    m.needsUpdate = true;
  };

  const paintColor = (rig: PartRig) => {
    rig.mat.color.copy(rig.color);
    rig.mat.sheenColor.copy(rig.color).lerp(white, 0.35);
  };

  // --- 状態 ------------------------------------------------------------------------------
  const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  let motionOK = !motionQuery.matches;
  let running = false;
  let raf = 0;
  let last = 0;
  let firstFrame = true;
  let active: PartId = opts.active;
  let hovered: PartId | null = null;
  let yaw = 0;
  let elev = 0.3;
  let yawTarget = 0;
  let elevTarget = 0.3;
  let hopT = 1;
  let frames = 0;
  let elapsed = 0;
  let degraded = false;
  let width = 1;
  let height = 1;
  let dist = 5;

  const HOP_SEC = 0.55;
  const HOP_AMP = 0.9;

  const aim = (id: PartId) => {
    const v = PARTS[PART_INDEX[id]].view;
    let y = v.yaw;
    while (y - yaw > Math.PI) y -= Math.PI * 2;
    while (y - yaw < -Math.PI) y += Math.PI * 2;
    yawTarget = y;
    elevTarget = v.elev;
  };

  const paintOutlines = () => {
    for (const id of Object.keys(rigs) as PartId[]) {
      const rig = rigs[id];
      for (const m of rig.meshes) {
        m.outline.visible = id === active || id === hovered;
        m.outline.material = id === active ? outlineOn : outlineHover;
      }
    }
  };

  const placeHops = (k: number) => {
    const rig = rigs[active];
    for (const id of Object.keys(rigs) as PartId[]) {
      for (const m of rigs[id].meshes) {
        m.mesh.position.copy(m.home);
        if (id === rig.id && k > 0) m.mesh.position.addScaledVector(m.hop, k);
      }
    }
  };

  const anchor = new T.Vector3();
  const placeLabel = () => {
    anchor.set(...PARTS[PART_INDEX[active]].anchor);
    anchor.applyMatrix4(chair.matrixWorld);
    anchor.project(camera);
    const x = ((anchor.x + 1) / 2) * width;
    const y = ((1 - anchor.y) / 2) * height;
    label.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) translate(-50%, -140%)`;
    label.style.opacity = "1";
  };

  const placeCamera = () => {
    camera.position.set(0, TARGET_Y + Math.sin(elev) * dist, Math.cos(elev) * dist);
    camera.lookAt(0, TARGET_Y, 0);
  };

  const renderOnce = () => {
    chair.rotation.y = yaw;
    placeCamera();
    chair.updateMatrixWorld();
    renderer.render(scene, camera);
    placeLabel();
    if (firstFrame) {
      firstFrame = false;
      onFirstFrame();
    }
  };

  /** 補間を使わずに最終状態を置く(モーション低減・画面外・停止中の共通経路) */
  const settle = () => {
    yaw = yawTarget;
    elev = elevTarget;
    hopT = 1;
    for (const rig of Object.values(rigs)) {
      rig.color.copy(rig.colorTarget);
      paintColor(rig);
    }
    placeHops(0);
    renderOnce();
  };

  const resize = () => {
    const rect = host.getBoundingClientRect();
    width = Math.max(1, Math.round(rect.width));
    height = Math.max(1, Math.round(rect.height));
    const aspect = width / height;
    camera.aspect = aspect;
    camera.updateProjectionMatrix();
    // 椅子(高さ約1.8・回したときの幅約1.9)が縦横どちらでも収まる距離を逆算する
    const t = Math.tan((camera.fov * Math.PI) / 360);
    dist = Math.max(1.22 / t, 1.3 / (t * aspect));
    renderer.setSize(width, height, false);
  };

  const frame = (now: number) => {
    raf = requestAnimationFrame(frame);
    const dt = Math.min((now - last) / 1000, 0.06);
    last = now;

    const ease = 1 - Math.pow(0.002, dt);
    yaw += (yawTarget - yaw) * ease;
    elev += (elevTarget - elev) * ease;

    let colorsDone = true;
    for (const rig of Object.values(rigs)) {
      rig.color.lerp(rig.colorTarget, 1 - Math.pow(0.0005, dt));
      const d =
        Math.abs(rig.color.r - rig.colorTarget.r) +
        Math.abs(rig.color.g - rig.colorTarget.g) +
        Math.abs(rig.color.b - rig.colorTarget.b);
      if (d < 0.002) rig.color.copy(rig.colorTarget);
      else colorsDone = false;
      paintColor(rig);
    }

    hopT = Math.min(1, hopT + dt / HOP_SEC);
    placeHops(Math.sin(Math.PI * hopT) * HOP_AMP);

    renderOnce();

    // 低スペック対策: 動いたフレームだけで数える(立ち上がり10フレームを捨て、続く48フレームの平均)
    frames += 1;
    if (frames > 10 && frames <= 58) {
      elapsed += dt * 1000;
      if (frames === 58 && elapsed / 48 > 32) {
        if (degraded) {
          stop();
          onFail();
          return;
        }
        degraded = true;
        dpr = Math.max(1, dpr * 0.6);
        renderer.setPixelRatio(dpr);
        resize();
        frames = 0;
        elapsed = 0;
      }
    }

    const viewDone = Math.abs(yawTarget - yaw) < 1e-3 && Math.abs(elevTarget - elev) < 1e-3;
    if (viewDone && colorsDone && hopT >= 1) {
      yaw = yawTarget;
      elev = elevTarget;
      renderOnce();
      stop();
    }
  };

  /** 変化があったときに呼ぶ。動かせない状況なら最終状態を1枚だけ描く */
  const kick = () => {
    if (!running || !motionOK) {
      settle();
      return;
    }
    if (raf) return;
    last = performance.now();
    raf = requestAnimationFrame(frame);
  };

  function stop() {
    if (!raf) return;
    cancelAnimationFrame(raf);
    raf = 0;
  }

  const apply = (config: Config, nextActive: PartId) => {
    for (const p of PARTS) {
      const rig = rigs[p.id];
      setFinish(rig, config[p.id].finish);
      rig.colorTarget.set(SWATCH[config[p.id].color].hex);
    }
    if (nextActive !== active || firstFrame) {
      active = nextActive;
      aim(active);
      hopT = firstFrame ? 1 : 0;
    }
    paintOutlines();
    kick();
  };

  const onMotionChange = () => {
    motionOK = !motionQuery.matches;
    if (!motionOK) {
      stop();
      settle();
    }
  };
  motionQuery.addEventListener("change", onMotionChange);

  // --- ポインタ: クリックで部位を選ぶ・ドラッグで回す(回転はモーション低減時も許可)------------
  const raycaster = new T.Raycaster();
  const ndc = new T.Vector2();
  const pickAt = (clientX: number, clientY: number): PartId | null => {
    const rect = canvas.getBoundingClientRect();
    ndc.set(((clientX - rect.left) / rect.width) * 2 - 1, -((clientY - rect.top) / rect.height) * 2 + 1);
    raycaster.setFromCamera(ndc, camera);
    const hit = raycaster.intersectObjects(pickables, false)[0];
    return hit ? (hit.object.userData.part as PartId) : null;
  };

  let pointerId: number | null = null;
  let downX = 0;
  let downY = 0;
  let lastX = 0;
  let lastY = 0;
  let dragged = false;

  const onPointerDown = (e: PointerEvent) => {
    if (pointerId !== null) return;
    pointerId = e.pointerId;
    downX = lastX = e.clientX;
    downY = lastY = e.clientY;
    dragged = false;
    canvas.setPointerCapture(e.pointerId);
  };
  const onPointerMove = (e: PointerEvent) => {
    if (pointerId === null) {
      // ホバー: 部位を薄い縁で示す
      const h = e.pointerType === "mouse" ? pickAt(e.clientX, e.clientY) : null;
      canvas.style.cursor = h ? "pointer" : "grab";
      if (h !== hovered) {
        hovered = h;
        paintOutlines();
        if (!raf) renderOnce();
      }
      return;
    }
    if (e.pointerId !== pointerId) return;
    if (!dragged && Math.hypot(e.clientX - downX, e.clientY - downY) < 5) return;
    dragged = true;
    canvas.style.cursor = "grabbing";
    const k = 3 / Math.max(width, 1);
    yawTarget += (e.clientX - lastX) * k;
    elevTarget = Math.max(0.02, Math.min(0.95, elevTarget + (e.clientY - lastY) * k));
    // ドラッグ中は補間を待たずに指へ貼り付ける
    yaw = yawTarget;
    elev = elevTarget;
    lastX = e.clientX;
    lastY = e.clientY;
    if (!raf) renderOnce();
  };
  const onPointerUp = (e: PointerEvent) => {
    if (e.pointerId !== pointerId) return;
    pointerId = null;
    if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId);
    canvas.style.cursor = "grab";
    if (!dragged && e.type === "pointerup") {
      const id = pickAt(e.clientX, e.clientY);
      if (id) onPick(id);
    }
  };
  const onPointerLeave = () => {
    if (hovered === null) return;
    hovered = null;
    paintOutlines();
    if (!raf) renderOnce();
  };
  canvas.addEventListener("pointerdown", onPointerDown);
  canvas.addEventListener("pointermove", onPointerMove);
  canvas.addEventListener("pointerup", onPointerUp);
  canvas.addEventListener("pointercancel", onPointerUp);
  canvas.addEventListener("pointerleave", onPointerLeave);

  const onContextLost = (e: Event) => {
    e.preventDefault();
    stop();
    onFail();
  };
  canvas.addEventListener("webglcontextlost", onContextLost);

  // canvas の寸法を書き換えると中身が消えるので、止まっていても必ず1枚描き直す
  const ro = new ResizeObserver(() => {
    resize();
    renderOnce();
  });
  ro.observe(host);

  resize();
  // 最初の1枚(running はまだ false なので、最終状態を1枚だけ描いて onFirstFrame を呼ぶ)
  apply(opts.config, opts.active);

  return {
    apply,
    nudge: (dYaw, dElev) => {
      yawTarget += dYaw;
      elevTarget = Math.max(0.02, Math.min(0.95, elevTarget + dElev));
      kick();
    },
    recenter: () => {
      aim(active);
      kick();
    },
    setRunning: (next) => {
      running = next;
      if (!next) stop();
      else if (!firstFrame) kick();
    },
    dispose: () => {
      stop();
      ro.disconnect();
      motionQuery.removeEventListener("change", onMotionChange);
      canvas.removeEventListener("pointerdown", onPointerDown);
      canvas.removeEventListener("pointermove", onPointerMove);
      canvas.removeEventListener("pointerup", onPointerUp);
      canvas.removeEventListener("pointercancel", onPointerUp);
      canvas.removeEventListener("pointerleave", onPointerLeave);
      canvas.removeEventListener("webglcontextlost", onContextLost);
      for (const g of geometries) g.dispose();
      for (const m of materials) m.dispose();
      weave.dispose();
      envTarget.dispose();
      renderer.dispose();
      renderer.forceContextLoss();
    },
  };
}

/* ------------------------------------------------------------------ 平面図(SVG) */

// 斜め前から見た線画。部位ごとに面(多角形)を持ち、上面だけ明るく重ねて立体に見せる
const FLAT: Record<PartId, { faces: string[]; tops: string[] }> = {
  legs: {
    faces: [
      "83,116 89,116 90,160 86,160",
      "171,116 177,116 174,160 170,160",
      "60,136 60,142 200,142 200,136",
      "57,138 65,138 63,186 58,186",
      "195,138 203,138 202,186 197,186",
    ],
    tops: [],
  },
  back: {
    faces: ["80,102 180,102 187,38 73,38"],
    tops: ["73,38 187,38 183,31 77,31"],
  },
  seat: {
    faces: ["56,118 204,118 204,137 56,137"],
    tops: ["56,118 204,118 184,100 76,100"],
  },
  arms: {
    faces: ["38,93 56,93 56,140 38,140", "222,93 204,93 204,140 222,140"],
    tops: ["38,93 56,93 76,73 60,73", "222,93 204,93 184,73 200,73"],
  },
};
// 奥から手前へ描く順
const FLAT_ORDER: PartId[] = ["legs", "back", "seat", "arms"];

/* ------------------------------------------------------------------ 本体 */

const STEP = (15 * Math.PI) / 180;

export default function ProductConfigurator3D() {
  const uid = useId().replace(/:/g, "");
  const headingId = `configurator-heading-${uid}`;
  const hintId = `configurator-hint-${uid}`;
  const stageRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const labelRef = useRef<HTMLSpanElement>(null);
  const apiRef = useRef<ConfiguratorApi | null>(null);
  const [live, setLive] = useState(false);
  const [inView, setInView] = useState(false);
  const [pageVisible, setPageVisible] = useState(true);
  const [ready, setReady] = useState(false);
  const [failed, setFailed] = useState(false);
  const [config, setConfig] = useState<Config>(DEFAULT_CONFIG);
  const [active, setActive] = useState<PartId>("seat");
  const [announce, setAnnounce] = useState("");

  // 起動時の構成は ref 経由で渡す(3Dを作り直すのは live が立つときの1回だけ)
  const initialRef = useRef<{ config: Config; active: PartId }>({ config: DEFAULT_CONFIG, active: "seat" });
  useEffect(() => {
    initialRef.current = { config, active };
  }, [config, active]);

  // 起動条件: 画面内 かつ 実寸表示。一覧のカードは親に scale が掛かっているので
  // rect.width(変形後)÷ offsetWidth(レイアウト幅)が 1 未満になり、3Dライブラリを読み込まない。
  useEffect(() => {
    const el = stageRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const fullSize = el.offsetWidth > 0 ? rect.width / el.offsetWidth > 0.75 : true;
    const io = new IntersectionObserver(
      ([entry]) => {
        setInView(entry.isIntersecting);
        if (entry.isIntersecting && fullSize) setLive(true);
      },
      { rootMargin: "160px" },
    );
    io.observe(el);
    const onVisibility = () => setPageVisible(document.visibilityState === "visible");
    document.addEventListener("visibilitychange", onVisibility);
    return () => {
      io.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, []);

  useEffect(() => {
    if (!live) return;
    const canvas = canvasRef.current;
    const host = stageRef.current;
    const label = labelRef.current;
    if (!canvas || !host || !label) return;

    let cancelled = false;
    const boot = async () => {
      // 非対応なら重いライブラリを取りに行かず、平面図のまま確定させる
      const probe = document.createElement("canvas");
      const gl = probe.getContext("webgl2") ?? (probe.getContext("webgl") as WebGLRenderingContext | null);
      if (!gl) {
        setFailed(true);
        return;
      }
      gl.getExtension("WEBGL_lose_context")?.loseContext();

      const [T, { RoundedBoxGeometry }] = await Promise.all([
        import("three"),
        import("three/examples/jsm/geometries/RoundedBoxGeometry.js"),
      ]);
      if (cancelled) return;
      apiRef.current = createConfigurator(T, RoundedBoxGeometry, {
        canvas,
        host,
        label,
        config: initialRef.current.config,
        active: initialRef.current.active,
        onPick: (part) => setActive(part),
        onFirstFrame: () => setReady(true),
        onFail: () => setFailed(true),
      });
    };
    boot().catch(() => {
      if (!cancelled) setFailed(true);
    });

    return () => {
      cancelled = true;
      apiRef.current?.dispose();
      apiRef.current = null;
    };
  }, [live]);

  useEffect(() => {
    apiRef.current?.setRunning(inView && pageVisible && !failed);
  }, [inView, pageVisible, failed, ready]);

  useEffect(() => {
    apiRef.current?.apply(config, active);
  }, [config, active, ready]);

  const part = PARTS[PART_INDEX[active]];
  const choice = config[active];
  const lines = PARTS.map((p) => ({ part: p, choice: config[p.id], price: partPrice(p, config[p.id]) }));
  const total = BASE_PRICE + lines.reduce((s, l) => s + l.price, 0);
  const premiumCount = lines.filter((l) => SWATCH[l.choice.color].premium).length;
  const weeks = premiumCount > 0 ? PREMIUM_LEAD_WEEKS : LEAD_WEEKS;
  const changed = active !== "seat" || PARTS.some((p) => {
    const a = config[p.id];
    const b = DEFAULT_CONFIG[p.id];
    return a.color !== b.color || a.finish !== b.finish;
  });

  const say = (next: Config, id: PartId) => {
    const c = next[id];
    const sum = BASE_PRICE + PARTS.reduce((s, p) => s + partPrice(p, next[p.id]), 0);
    setAnnounce(`${PARTS[PART_INDEX[id]].name}を${SWATCH[c.color].name}・${FINISH[c.finish].name}に変更。合計の目安 ${yen(sum)}円`);
  };

  const choose = (patch: Partial<Choice>) => {
    const next = { ...config, [active]: { ...config[active], ...patch } };
    setConfig(next);
    say(next, active);
  };

  const pickPart = (id: PartId) => {
    setActive(id);
  };

  const reset = () => {
    setConfig(DEFAULT_CONFIG);
    setActive("seat");
    setAnnounce(`初期の構成に戻しました。合計の目安 ${yen(BASE_PRICE + PARTS.reduce((s, p) => s + partPrice(p, DEFAULT_CONFIG[p.id]), 0))}円`);
  };

  const onStageKey = (e: KeyboardEvent<HTMLDivElement>) => {
    const api = apiRef.current;
    if (!api) return;
    const moves: Record<string, [number, number]> = {
      ArrowLeft: [-STEP, 0],
      ArrowRight: [STEP, 0],
      ArrowUp: [0, STEP / 2],
      ArrowDown: [0, -STEP / 2],
    };
    if (e.key in moves) {
      e.preventDefault();
      api.nudge(...moves[e.key]);
    } else if (e.key === "Home" || e.key === "Enter") {
      e.preventDefault();
      api.recenter();
    }
  };

  const show3d = ready && !failed;
  const focusRing = "has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-amber-600";

  return (
    <section aria-labelledby={headingId} className="@container w-full max-w-5xl bg-white text-gray-900">
      {/* 冒頭: 見出しと数の帯。英字の小見出しは置かず、組み合わせの数で節を開く */}
      <div className="grid gap-6 @2xl:grid-cols-[minmax(0,1fr)_auto] @2xl:items-end">
        <div>
          <h2 id={headingId} className="text-[clamp(1.35rem,3.4vw,2.05rem)] leading-[1.35] font-bold tracking-tight">
            座面から脚まで、
            <span className="whitespace-nowrap">部位ごとに選べる椅子</span>
          </h2>
          <p className="mt-4 max-w-xl text-[14px] leading-[1.9] font-medium text-gray-600">
            椅子の部位をクリックするか、下の部位ボタンで選んでから、色見本と素材を切り替えてください。組み合わせは{yen(COMBINATIONS)}通りあり、右の構成表と金額の目安が同時に変わります。
          </p>
        </div>
        <dl className="grid grid-cols-3 border-y border-gray-200 @2xl:w-[19rem]">
          {[
            { term: "部位", value: String(PARTS.length), unit: "か所" },
            { term: "色見本", value: String(SWATCHES.length), unit: "色" },
            { term: "素材", value: String(FINISHES.length), unit: "種" },
          ].map((s, i) => (
            <div key={s.term} className={`py-3 ${i > 0 ? "border-l border-gray-200 pl-3" : ""}`}>
              <dt className="text-[11px] font-bold tracking-wide whitespace-nowrap text-gray-500">{s.term}</dt>
              <dd className="mt-1 flex items-baseline gap-1 whitespace-nowrap">
                <span className={`${montserrat.className} text-[1.3rem] leading-none font-semibold tabular-nums`}>{s.value}</span>
                <span className="text-[11px] text-gray-500">{s.unit}</span>
              </dd>
            </div>
          ))}
        </dl>
      </div>

      <div className="mt-8 grid gap-8 border-t border-gray-200 pt-8 @2xl:grid-cols-[minmax(0,1fr)_16.5rem] @2xl:gap-9">
        <div className="min-w-0">
          <div
            ref={stageRef}
            tabIndex={0}
            role="group"
            aria-roledescription="3Dプレビュー"
            aria-label={`椅子の3Dプレビュー。選択中の部位は${part.name}`}
            aria-describedby={hintId}
            onKeyDown={onStageKey}
            className="relative aspect-[4/3] w-full overflow-hidden border border-gray-200 bg-[radial-gradient(60%_55%_at_50%_58%,rgba(217,119,6,0.06)_0%,rgba(255,255,255,0)_70%),linear-gradient(180deg,#ffffff_0%,#f7f5f1_100%)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
          >
            {/* 平面図: 3Dが立ち上がるまで・縮小プレビュー・WebGL非対応のときの表示。同じ部位・色・素材を持つ */}
            <div
              className={`absolute inset-0 flex flex-col items-center justify-center p-4 transition-opacity duration-700 motion-reduce:transition-none ${
                show3d ? "pointer-events-none opacity-0" : "opacity-100"
              }`}
            >
              <svg viewBox="0 0 260 200" aria-hidden="true" className="block h-auto w-full max-w-[26rem]">
                <defs>
                  <pattern id={`weave-${uid}`} width="4" height="4" patternUnits="userSpaceOnUse">
                    <path d="M0 1h2M2 3h2M1 0v2M3 2v2" stroke="rgba(255,255,255,0.35)" strokeWidth="0.8" />
                  </pattern>
                  <linearGradient id={`gloss-${uid}`} x1="0" y1="0" x2="1" y2="1">
                    <stop offset="0" stopColor="#fff" stopOpacity="0.55" />
                    <stop offset="0.35" stopColor="#fff" stopOpacity="0" />
                  </linearGradient>
                </defs>
                <ellipse cx="130" cy="186" rx="90" ry="7" fill="rgba(35,31,27,0.08)" />
                {FLAT_ORDER.map((id) => {
                  const c = config[id];
                  const hex = SWATCH[c.color].hex;
                  const on = id === active;
                  const overlay = c.finish === "fabric" ? `url(#weave-${uid})` : c.finish === "gloss" ? `url(#gloss-${uid})` : "none";
                  return (
                    <g key={id} onClick={() => pickPart(id)} className="cursor-pointer">
                      {FLAT[id].faces.map((pts) => (
                        <g key={pts}>
                          <polygon points={pts} fill={hex} stroke={on ? "#d97706" : "#44403c"} strokeWidth={on ? 1.6 : 0.6} strokeLinejoin="round" />
                          <polygon points={pts} fill={overlay} />
                        </g>
                      ))}
                      {FLAT[id].tops.map((pts) => (
                        <g key={pts}>
                          <polygon points={pts} fill={hex} stroke={on ? "#d97706" : "#44403c"} strokeWidth={on ? 1.6 : 0.6} strokeLinejoin="round" />
                          <polygon points={pts} fill="rgba(255,255,255,0.22)" />
                          <polygon points={pts} fill={overlay} />
                        </g>
                      ))}
                    </g>
                  );
                })}
              </svg>
              {failed && (
                <p className="mt-2 text-center text-[11px] leading-[1.7] text-gray-500">
                  この環境では3D表示が使えないため、平面図で表示しています
                </p>
              )}
            </div>

            <canvas
              ref={canvasRef}
              aria-hidden="true"
              className={`absolute inset-0 block size-full cursor-grab touch-pan-y transition-opacity duration-700 motion-reduce:transition-none ${
                show3d ? "opacity-100" : "pointer-events-none opacity-0"
              }`}
            />

            {/* 部位ラベル(位置は3D側が transform で書き換える) */}
            <div
              aria-hidden="true"
              className={`pointer-events-none absolute inset-0 transition-opacity duration-500 motion-reduce:transition-none ${
                show3d ? "opacity-100" : "opacity-0"
              }`}
            >
              <span
                ref={labelRef}
                className="absolute top-0 left-0 border border-amber-600 bg-white/90 px-1.5 py-0.5 text-[11px] leading-none font-bold whitespace-nowrap text-amber-700 opacity-0"
              >
                {part.no} {part.name}
              </span>
            </div>

            <p
              id={hintId}
              className={`pointer-events-none absolute bottom-2.5 left-3 bg-white/85 px-1.5 text-[10.5px] leading-[1.6] text-gray-500 transition-opacity duration-500 motion-reduce:transition-none ${
                show3d ? "opacity-100" : "opacity-0"
              }`}
            >
              クリックで部位を選択・ドラッグで回転
              <span className="sr-only @md:not-sr-only">(矢印キーでも回転・Enterで部位の正面へ)</span>
            </p>
          </div>

          {/* 操作(ここが入力の正。3Dのクリックは部位ラジオを選ぶ近道) */}
          <div className="mt-6 grid gap-6">
            <fieldset className="min-w-0">
              <legend className="text-[12px] font-bold tracking-wide text-gray-500">部位を選ぶ</legend>
              <div className="mt-2.5 grid grid-cols-4 border-y border-l border-gray-200">
                {PARTS.map((p) => {
                  const on = p.id === active;
                  return (
                    <label
                      key={p.id}
                      className={`relative flex cursor-pointer flex-col items-center gap-1 border-r border-gray-200 px-1 py-2.5 text-center ${focusRing} ${
                        on ? "bg-amber-50/60" : "hover:bg-gray-50"
                      }`}
                    >
                      <input
                        type="radio"
                        name={`part-${uid}`}
                        value={p.id}
                        checked={on}
                        onChange={() => pickPart(p.id)}
                        className="sr-only"
                      />
                      <span
                        aria-hidden="true"
                        className={`absolute inset-x-0 -bottom-px h-[3px] origin-left bg-amber-600 transition-transform duration-300 ease-out motion-reduce:transition-none ${
                          on ? "scale-x-100" : "scale-x-0"
                        }`}
                      />
                      <span className={`${montserrat.className} text-[10px] font-semibold text-gray-400 tabular-nums`}>{p.no}</span>
                      <span className={`text-[13px] font-bold whitespace-nowrap ${on ? "text-gray-900" : "text-gray-600"}`}>{p.name}</span>
                    </label>
                  );
                })}
              </div>
            </fieldset>

            <fieldset className="min-w-0">
              <legend className="text-[12px] font-bold tracking-wide text-gray-500">
                {part.name}の色
                <span className="ml-2 font-medium text-gray-400">藍・琥珀は受注色(+{yen(PREMIUM_PRICE)}円)</span>
              </legend>
              <div className="mt-2.5 grid grid-cols-5 gap-2">
                {SWATCHES.map((s) => {
                  const on = choice.color === s.id;
                  return (
                    <label key={s.id} className={`flex cursor-pointer flex-col items-center gap-1.5 ${focusRing}`}>
                      <input
                        type="radio"
                        name={`color-${uid}`}
                        value={s.id}
                        checked={on}
                        onChange={() => choose({ color: s.id })}
                        className="sr-only"
                      />
                      <span
                        aria-hidden="true"
                        className={`block aspect-square w-full max-w-12 border p-[3px] ${on ? "border-amber-600" : "border-gray-200 hover:border-gray-400"}`}
                      >
                        <span className="block size-full" style={{ backgroundColor: s.hex }} />
                      </span>
                      <span className={`text-[11.5px] leading-none whitespace-nowrap ${on ? "font-bold text-gray-900" : "text-gray-500"}`}>
                        {s.name}
                      </span>
                    </label>
                  );
                })}
              </div>
            </fieldset>

            <fieldset className="min-w-0">
              <legend className="text-[12px] font-bold tracking-wide text-gray-500">{part.name}の素材</legend>
              <div className="mt-2.5 grid grid-cols-3 border-y border-l border-gray-200">
                {FINISHES.map((f) => {
                  const price = part.finishPrice[f.id];
                  const disabled = price === null;
                  const on = choice.finish === f.id;
                  return (
                    <label
                      key={f.id}
                      className={`flex flex-col gap-1 border-r border-gray-200 px-2.5 py-2.5 ${focusRing} ${
                        disabled
                          ? "cursor-not-allowed bg-[repeating-linear-gradient(135deg,#fafaf9_0_6px,#f5f5f4_6px_7px)] text-gray-300"
                          : on
                            ? "cursor-pointer bg-amber-50/60"
                            : "cursor-pointer hover:bg-gray-50"
                      }`}
                    >
                      <input
                        type="radio"
                        name={`finish-${uid}`}
                        value={f.id}
                        checked={on}
                        disabled={disabled}
                        onChange={() => choose({ finish: f.id })}
                        className="sr-only"
                      />
                      <span className="flex items-center gap-2">
                        <span
                          aria-hidden="true"
                          className={`size-2.5 flex-none border ${on ? "border-amber-600 bg-amber-600" : disabled ? "border-gray-200" : "border-gray-400"}`}
                        />
                        <span className={`text-[13px] font-bold ${disabled ? "" : on ? "text-gray-900" : "text-gray-600"}`}>{f.name}</span>
                      </span>
                      <span className={`text-[11px] tabular-nums ${disabled ? "" : "text-gray-500"}`}>
                        {disabled ? "選べません" : price === 0 ? "加算なし" : `+${yen(price)}円`}
                      </span>
                    </label>
                  );
                })}
              </div>
              <p className="mt-2.5 text-[12px] leading-[1.8] text-gray-500">
                {FINISH[choice.finish].note}
                {active === "legs" && <span className="block">脚は荷重がかかり汚れやすいため、布は選べない設定にしています</span>}
              </p>
            </fieldset>
          </div>
        </div>

        {/* 構成の要約(選んだ内容・加算額・合計・納期) */}
        <div className="min-w-0 @2xl:border-l @2xl:border-gray-200 @2xl:pl-9">
          <p className="text-[12px] font-bold tracking-wide text-gray-500">構成と金額の目安</p>
          <dl className="mt-3 border-t border-gray-200">
            {lines.map(({ part: p, choice: c, price }) => (
              <div key={p.id} className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-baseline gap-x-3 border-b border-gray-200 py-2.5">
                <dt className={`text-[12.5px] font-bold whitespace-nowrap ${p.id === active ? "text-amber-700" : "text-gray-900"}`}>
                  <span className={`${montserrat.className} mr-1.5 text-[10px] font-semibold text-gray-400 tabular-nums`}>{p.no}</span>
                  {p.name}
                </dt>
                <dd className="flex min-w-0 items-center gap-1.5 text-[12px] text-gray-600">
                  <span aria-hidden="true" className="size-2.5 flex-none border border-gray-300" style={{ backgroundColor: SWATCH[c.color].hex }} />
                  <span className="truncate">
                    {SWATCH[c.color].name}・{FINISH[c.finish].name}
                  </span>
                </dd>
                <dd className="text-right text-[11.5px] whitespace-nowrap text-gray-500 tabular-nums">
                  {price === 0 ? "±0" : `+${yen(price)}`}
                </dd>
              </div>
            ))}
            <div className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-x-3 border-b border-gray-200 py-2.5">
              <dt className="text-[12.5px] font-bold text-gray-900">本体</dt>
              <dd className="text-right text-[11.5px] text-gray-500 tabular-nums">{yen(BASE_PRICE)}</dd>
            </div>
          </dl>

          <div className="mt-5">
            <p className="text-[12px] font-bold text-gray-500">合計の目安(税込)</p>
            <p className="mt-1.5 flex items-baseline gap-1 whitespace-nowrap">
              <span className={`${montserrat.className} text-[2rem] leading-none font-bold tracking-tight tabular-nums`}>{yen(total)}</span>
              <span className="text-[13px] font-bold">円</span>
            </p>
            <p className="mt-3 border-t border-gray-100 pt-3 text-[12px] leading-[1.8] text-gray-600">
              納期の目安 <span className="font-bold text-gray-900">約{weeks}週間</span>
              <span className="block text-[11.5px] text-gray-400">
                {premiumCount > 0 ? `受注色を${premiumCount}か所に含むため、標準より2週間ほど延びます` : "標準色だけの構成です"}
              </span>
            </p>
          </div>

          <div className="mt-6 flex flex-wrap items-center gap-x-5 gap-y-3">
            <form action={QUOTE_ACTION} method="get">
              {lines.map((l) => (
                <input key={l.part.id} type="hidden" name={l.part.id} value={`${l.choice.color}-${l.choice.finish}`} />
              ))}
              <input type="hidden" name="total" value={total} />
              <input type="hidden" name="weeks" value={weeks} />
              <button
                type="submit"
                className="group inline-flex cursor-pointer items-center text-[13px] font-semibold text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
              >
                <span className="relative">
                  この構成で見積もりを依頼
                  <span
                    aria-hidden="true"
                    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
                    aria-hidden="true"
                    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>
              </button>
            </form>
            <button
              type="button"
              onClick={reset}
              disabled={!changed}
              className="text-[12px] font-medium text-gray-500 underline decoration-gray-300 underline-offset-4 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 disabled:cursor-default disabled:text-gray-300 disabled:no-underline"
            >
              初期の構成に戻す
            </button>
          </div>

          <p aria-live="polite" className="sr-only">
            {announce}
          </p>
        </div>
      </div>

      <p className="mt-8 border-t border-gray-200 pt-6 text-[12px] leading-[1.9] text-gray-500">
        製品・価格・納期はすべて構成を示すためのサンプルです。自社の品目と価格表に差し替えてお使いください。見積もり依頼は選んだ構成・合計・納期を問い合わせページへ渡すので、送り先を自社のページに差し替えてください。画面の色はディスプレイによって実物と異なって見えることがあります。
      </p>
    </section>
  );
}

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

npx shadcn@latest add https://designs.first-ch.com/r/product-configurator-3d.json