First CH Designs
BLK-80Blocks

構成ツリー・ノード図(節点を開いて確かめる階層)

サイトやシステムの構成を、節点と接続線の図で示すブロック。節点を押すと直下の項目が親の位置から生え、残りの節点は間隔を取り直して押し出され、接続線がその場で引き直される——図そのものが操作で組み替わる。開く枝は1本ずつで、別の枝を押すと前の枝は親へ吸い込まれて畳まれるので、節点の間隔が箱の高さを割り込まない。選んだ節点は根まで系統が灯り、右の明細に役割・直下の件数・配下のページ数・全体比が出るので、構成の提案や引き継ぎにそのまま使える。作図はSVGを viewBox 1000×実高さ+preserveAspectRatio:none で置き、縦はpx・横は千分率で持つので幅の実測が要らない。GSAPは素のオブジェクト配列だけを動かし、節点のtransformと接続線のd属性は毎フレーム自分で描くため線が節点から剥がれない。出現はScrollTriggerで根→枝→葉の順、狭い器では同じ配列を入れ子の開閉リストへ組み替える。画像素材ゼロ。

追加日:
2026-09-26
依存:
gsap
tags
#diagram #tree #hierarchy #information-architecture #svg #gsap #scrolltrigger #interactive #container-query #section #brand #no-image #reduced-motion

プレビュー

どの面が何のために要るのか
節点をひらいて確かめる

総ページ数
24
末端の面
13
    • サービス一覧領域の見取り図1P
    • サービス詳細範囲と進め方4P
    • 実績一覧事例の索引1P
    • 実績詳細課題から結果まで6P

ページ数は公開時点の想定値です。節点を押すと直下の項目が開き、図は位置を取り直します(開く枝は1本ずつ)

構成案の作り方を見る
"use client";

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

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

/**
 * 構成ツリー・ノード図(節点を開いて確かめる階層)
 * 既存の図解ブロックは配分(帯の太さ=量)を見せる固定レイアウトのサンキー型で、図の形は
 * 最初から最後まで変わらない。本ブロックが示すのは量ではなく**関係と階層**で、節点を押すと
 * 直下の項目が生え、残りの節点は押し出されて位置を取り直し、接続線がその場で引き直される
 * ——図そのものが操作で組み替わるのが主張。選んだ節点は根まで系統が灯り、右の明細に
 * 役割・直下の件数・配下のページ数が出るので、構成の提案や引き継ぎ資料にそのまま使える。
 * 飽和idiomは英字キッカーと末尾の「→」リンクを外し、角丸も一切使わない(直線と罫線の作図に寄せる)。
 * 掲載している構成・名称・ページ数はすべて架空のサンプル。画像素材ゼロ。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * - **縦はpx・横は‰で持つと、可変幅のノード図が測定なしで書ける**: SVGを
 *   `viewBox="0 0 1000 <実高さ>"` + `preserveAspectRatio="none"` で置き、高さだけ実px固定にすると、
 *   縦倍率は常に1のままで横だけが器に追従する。y座標はHTML側の translateY(px) とそのまま共有でき、
 *   x座標は器の幅に対する千分率として書ける。ResizeObserverもgetBoundingClientRectも要らない。
 *   線の太さが横倍率で潰れるのは `vector-effect="non-scaling-stroke"` で消える。
 * - **アニメーションはDOMではなくプレーンなオブジェクトへ掛ける**: GSAPに要素を直接触らせると、
 *   位置(transform)と表示(opacity)と接続線(d属性)の3系統を同期させる術がない。`{id,y,g}` の
 *   素の配列をtweenし、onUpdateで「節点のtransform → 接続線のd」の順に**毎フレーム自分で描く**と、
 *   線が節点から遅れて剥がれることが構造的に起きない(GSAPはDOMを一切知らないままでよい)。
 * - **開閉は「生える/吸い込まれる」を1つの進捗で表す**: 節点ごとの g(0→1)を、透明度と
 *   接続線の終端位置の両方へ掛ける。g=0 では線が親の位置に畳まれ、子も親に重なっているので、
 *   閉じる操作が「親へ吸い込まれる」動きになる。線の描画にstroke-dasharrayは要らない。
 * - **節点の間隔は葉の数から逆算する**: `slot = min(最大間隔, 使える高さ / (葉の数 - 1))` とすると、
 *   畳んだ状態では余白いっぱいに広がり、開くと自動で詰まる。図の外枠の高さが跳ねないので、
 *   開閉しても後続セクションが動かない(節点の増減でページが伸び縮みする図はレイアウトを壊す)。
 *   ただし**間隔が箱の高さを下回ると節点どうしが重なる**ので、同時に開く枝は1本に制限する
 *   (最大の葉数=開いた枝の子+畳んだ兄弟=7、470pxなら間隔68pxで箱51pxに収まる)。
 * - **縮小プレビューの中ではScrollTriggerを作らない**: 祖先に transform: scale があると
 *   getBoundingClientRect が縮尺ぶん小さい値を返し、開始位置が実寸とずれる。
 *   `rect.width / offsetWidth` が1から外れていたら、出現アニメを飛ばして完成状態で静止させる。
 */

type Node = {
  id: string;
  name: string;
  /** 節点の役割を1行で */
  role: string;
  /** 配下(葉なら自身)のページ数 */
  pages: number;
  children?: Node[];
};

/** 図に出す構成(架空のコーポレートサイト) */
const tree: Node = {
  id: "root",
  name: "コーポレートサイト",
  role: "公開する全ページの親",
  pages: 0,
  children: [
    {
      id: "entry",
      name: "入口",
      role: "最初に着く面と、その先の行き先",
      pages: 0,
      children: [
        { id: "entry-top", name: "トップページ", role: "全体の索引を兼ねる", pages: 1 },
        { id: "entry-news", name: "お知らせ一覧", role: "更新の有無を示す", pages: 3 },
        { id: "entry-map", name: "サイトマップ", role: "全ページの一覧", pages: 1 },
      ],
    },
    {
      id: "know",
      name: "知ってもらう",
      role: "何をどこまでやるかを説明する面",
      pages: 0,
      children: [
        { id: "know-service", name: "サービス一覧", role: "領域の見取り図", pages: 1 },
        { id: "know-detail", name: "サービス詳細", role: "範囲と進め方", pages: 4 },
        { id: "know-works", name: "実績一覧", role: "事例の索引", pages: 1 },
        { id: "know-case", name: "実績詳細", role: "課題から結果まで", pages: 6 },
      ],
    },
    {
      id: "check",
      name: "確かめる",
      role: "迷いどころを先に潰す面",
      pages: 0,
      children: [
        { id: "check-price", name: "料金の目安", role: "幅と変動要因", pages: 1 },
        { id: "check-faq", name: "よくある質問", role: "問い合わせ前の疑問", pages: 1 },
        { id: "check-about", name: "会社概要・アクセス", role: "相手の実在を示す", pages: 2 },
      ],
    },
    {
      id: "act",
      name: "動いてもらう",
      role: "相談を受け取る面",
      pages: 0,
      children: [
        { id: "act-contact", name: "お問い合わせ", role: "本命の受け口", pages: 1 },
        { id: "act-doc", name: "資料ダウンロード", role: "まだ話せない人向け", pages: 1 },
        { id: "act-thanks", name: "送信完了", role: "次の行き先を出す", pages: 1 },
      ],
    },
  ],
};

type FlatNode = {
  id: string;
  name: string;
  role: string;
  depth: number;
  parent: string | null;
  childIds: string[];
  /** 配下の合計ページ数 */
  pages: number;
  /** 兄弟の中での位置(出現の順番付けに使う) */
  order: number;
};

/** ツリーを平たい配列へ。深さ優先なので配列の順=図の上から下の順になる */
function flatten(node: Node, depth: number, parent: string | null, order: number, out: FlatNode[]): number {
  const entry: FlatNode = {
    id: node.id,
    name: node.name,
    role: node.role,
    depth,
    parent,
    childIds: (node.children ?? []).map((c) => c.id),
    pages: node.pages,
    order,
  };
  out.push(entry);
  let sum = node.pages;
  (node.children ?? []).forEach((child, i) => {
    sum += flatten(child, depth + 1, node.id, i, out);
  });
  entry.pages = sum;
  return sum;
}

const flat: FlatNode[] = [];
flatten(tree, 0, null, 0, flat);
const byId = new Map(flat.map((n) => [n.id, n]));
const INDEX_OF = new Map(flat.map((n, i) => [n.id, i]));
const TOTAL_PAGES = byId.get("root")!.pages;
const LEAF_COUNT = flat.filter((n) => n.childIds.length === 0).length;

/** 列の位置と幅(器の幅に対する千分率)。右端は 1000 で器の右端に接する */
const COLUMNS = [
  { left: 0, width: 280 },
  { left: 350, width: 270 },
  { left: 690, width: 310 },
];

const STAGE_H = 470;
const STAGE_PAD = 28;
/** 節点どうしの最大間隔。畳んだときに広がりすぎないための上限 */
const SLOT_MAX = 98;

type Placement = { y: number; open: boolean; visible: boolean };

/**
 * 開いている節点から図の座標を作る。
 * 葉を上から順に等間隔で並べ、親は子の平均へ置く(枝が子の重心に来る古典的な組み方)。
 */
function computeLayout(open: Record<string, boolean>): Map<string, Placement> {
  const isOpen = (n: FlatNode) => n.childIds.length > 0 && (n.depth === 0 || open[n.id] === true);
  const visible = new Set<string>(["root"]);
  for (const n of flat) {
    if (n.parent && visible.has(n.parent) && isOpen(byId.get(n.parent)!)) visible.add(n.id);
  }
  const leaves = flat.filter((n) => visible.has(n.id) && !isOpen(n));
  const usable = STAGE_H - STAGE_PAD * 2;
  const slot = leaves.length > 1 ? Math.min(SLOT_MAX, usable / (leaves.length - 1)) : 0;
  const span = slot * Math.max(0, leaves.length - 1);
  const top = (STAGE_H - span) / 2;

  const y = new Map<string, number>();
  leaves.forEach((n, i) => y.set(n.id, top + slot * i));
  // 葉から根へ向かって畳む(配列は深さ優先なので、後ろから見れば子が先に決まっている)
  for (let i = flat.length - 1; i >= 0; i -= 1) {
    const n = flat[i];
    if (!visible.has(n.id) || y.has(n.id)) continue;
    const kids = n.childIds.filter((id) => y.has(id));
    y.set(n.id, kids.reduce((s, id) => s + y.get(id)!, 0) / kids.length);
  }

  const out = new Map<string, Placement>();
  for (const n of flat) {
    if (visible.has(n.id)) {
      out.set(n.id, { y: y.get(n.id)!, open: isOpen(n), visible: true });
    } else {
      // 見えない節点は親の位置に畳んでおく(開くときはそこから生えてくる)
      const anchor = n.parent ? out.get(n.parent) : undefined;
      out.set(n.id, { y: anchor ? anchor.y : STAGE_H / 2, open: false, visible: false });
    }
  }
  return out;
}

/** 描画時の状態。GSAPはこの素のオブジェクトだけを動かす */
type Motion = { id: string; y: number; g: number };

const anchorOut = (depth: number) => COLUMNS[depth].left + COLUMNS[depth].width;
const anchorIn = (depth: number) => COLUMNS[depth].left;

const edgePath = (x0: number, y0: number, x1: number, y1: number) => {
  const xm = (x0 + x1) / 2;
  return `M ${x0.toFixed(1)},${y0.toFixed(1)} C ${xm.toFixed(1)},${y0.toFixed(1)} ${xm.toFixed(1)},${y1.toFixed(1)} ${x1.toFixed(1)},${y1.toFixed(1)}`;
};

const initialOpen: Record<string, boolean> = { know: true };

export default function StructureTreeDiagram() {
  const headingId = useId();
  const [open, setOpen] = useState<Record<string, boolean>>(initialOpen);
  const [selected, setSelected] = useState<string>("know");

  const sectionRef = useRef<HTMLElement | null>(null);
  const nodeRefs = useRef<Record<string, HTMLDivElement | null>>({});
  const edgeRefs = useRef<Record<string, SVGPathElement | null>>({});
  const motionRef = useRef<Motion[] | null>(null);
  const gsapRef = useRef<typeof import("gsap").gsap | null>(null);
  const shownRef = useRef(false);
  const killRef = useRef<(() => void) | null>(null);

  /** 描画状態の配列。並びは flat と同じなので id からの引き当ては INDEX_OF で足りる */
  const readMotion = useCallback(() => {
    const current = motionRef.current;
    if (current) return current;
    const created = flat.map((n) => ({ id: n.id, y: STAGE_H / 2, g: 0 }));
    motionRef.current = created;
    return created;
  }, []);

  /** 節点のtransform → 接続線のd の順に、毎フレーム自分で描く */
  const paint = useCallback(() => {
    const motion = readMotion();
    for (const m of motion) {
      const el = nodeRefs.current[m.id];
      if (!el) continue;
      el.style.transform = `translate3d(${(1 - m.g) * -14}px, ${m.y.toFixed(2)}px, 0)`;
      el.style.opacity = m.g.toFixed(3);
      el.style.pointerEvents = m.g > 0.6 ? "auto" : "none";
    }
    for (const n of flat) {
      if (!n.parent) continue;
      const path = edgeRefs.current[n.id];
      if (!path) continue;
      const parent = motion[INDEX_OF.get(n.parent)!];
      const self = motion[INDEX_OF.get(n.id)!];
      const x0 = anchorOut(n.depth - 1);
      const x1 = anchorIn(n.depth);
      // g を終端位置へ掛けるので、閉じるときは線が親へ吸い込まれる
      const ex = x0 + (x1 - x0) * self.g;
      const ey = parent.y + (self.y - parent.y) * self.g;
      path.setAttribute("d", edgePath(x0, parent.y, ex, ey));
      path.style.opacity = (self.g * 0.9).toFixed(3);
    }
  }, [readMotion]);

  const layout = useMemo(() => computeLayout(open), [open]);

  /** 目標値へ寄せる。GSAPが無い/reduced-motion のときは即座に完成状態へ */
  const applyLayout = useCallback(
    (animate: boolean) => {
      const targets = layout;
      const motion = readMotion();
      const gsap = gsapRef.current;
      const shown = shownRef.current;
      if (!animate || !gsap) {
        for (const m of motion) {
          const t = targets.get(m.id)!;
          m.y = t.y;
          m.g = shown && t.visible ? 1 : 0;
        }
        paint();
        return;
      }
      gsap.to(motion, {
        y: (_i: number, target: Motion) => targets.get(target.id)!.y,
        g: (_i: number, target: Motion) => (targets.get(target.id)!.visible ? 1 : 0),
        duration: 0.62,
        ease: "power3.out",
        overwrite: true,
        onUpdate: paint,
      });
    },
    [layout, paint, readMotion],
  );

  /** 出現: 根 → 第1階層 → 開いている第2階層 の順に生やす */
  const reveal = useCallback(
    (animate: boolean) => {
      if (shownRef.current) return;
      shownRef.current = true;
      const gsap = gsapRef.current;
      const targets = layout;
      const motion = readMotion();
      for (const m of motion) m.y = targets.get(m.id)!.y;
      if (!animate || !gsap) {
        applyLayout(false);
        return;
      }
      const ordered = motion
        .filter((m) => targets.get(m.id)!.visible)
        .sort((a, b) => {
          const na = byId.get(a.id)!;
          const nb = byId.get(b.id)!;
          return na.depth - nb.depth || na.order - nb.order;
        });
      paint();
      gsap.to(ordered, {
        g: 1,
        duration: 0.5,
        ease: "power2.out",
        stagger: 0.055,
        onUpdate: paint,
      });
    },
    [applyLayout, layout, paint, readMotion],
  );

  // 出現の実体は open に依存して作り直されるので、ref 越しに最新版を呼ぶ(初期化effectは1回だけ走らせる)
  const revealRef = useRef<(animate: boolean) => void>(() => {});
  useEffect(() => {
    revealRef.current = reveal;
  }, [reveal]);

  useEffect(() => {
    const section = sectionRef.current;
    if (!section) return;

    const reduced =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    // 縮小プレビュー(transform: scale した祖先)の中ではScrollTriggerを作らない
    const scaled =
      section.offsetWidth > 0 &&
      Math.abs(section.getBoundingClientRect().width / section.offsetWidth - 1) > 0.02;

    if (reduced || scaled) {
      revealRef.current(false);
      return;
    }

    let cancelled = false;
    // ScrollTriggerが張れない入れ物に置かれても、図が消えたままにならないようにする
    const safety = window.setTimeout(() => revealRef.current(true), 1400);

    (async () => {
      const [{ gsap }, { ScrollTrigger }] = await Promise.all([
        import("gsap"),
        import("gsap/ScrollTrigger"),
      ]);
      if (cancelled) return;
      gsap.registerPlugin(ScrollTrigger);
      gsapRef.current = gsap;
      const st = ScrollTrigger.create({
        trigger: section,
        start: "top 88%",
        once: true,
        onEnter: () => revealRef.current(true),
      });
      killRef.current = () => {
        st.kill();
        const motion = motionRef.current;
        if (motion) gsap.killTweensOf(motion);
      };
    })();

    return () => {
      cancelled = true;
      window.clearTimeout(safety);
      killRef.current?.();
      killRef.current = null;
    };
  }, []);

  useEffect(() => {
    applyLayout(shownRef.current);
  }, [applyLayout]);

  const toggle = (id: string) => {
    const node = byId.get(id)!;
    setSelected(id);
    if (node.childIds.length === 0 || node.depth === 0) return;
    // 開くのは同時に1本だけ。節点の間隔は葉の数で決まるので、2本以上開くと箱どうしが重なる
    setOpen((prev) => (prev[id] ? {} : { [id]: true }));
  };

  /** 選択中の節点から根までの系統(灯す対象) */
  const lit = useMemo(() => {
    const chain = new Set<string>();
    let cursor: string | null = selected;
    while (cursor) {
      chain.add(cursor);
      cursor = byId.get(cursor)!.parent;
    }
    return chain;
  }, [selected]);

  const current = byId.get(selected)!;
  const trail = useMemo(() => {
    const names: string[] = [];
    let cursor: string | null = selected;
    while (cursor) {
      names.unshift(byId.get(cursor)!.name);
      cursor = byId.get(cursor)!.parent;
    }
    return names;
  }, [selected]);

  const openCount = flat.filter((n) => n.depth === 1 && open[n.id]).length;

  return (
    <section
      ref={sectionRef}
      aria-labelledby={headingId}
      className={`${montserrat.className} @container w-full max-w-6xl bg-white px-5 py-10 text-gray-900 sm:px-8 sm:py-14`}
    >
      <div className="flex flex-col gap-5 border-b border-gray-200 pb-6 @3xl:flex-row @3xl:items-end @3xl:justify-between">
        <h2
          id={headingId}
          className="text-[clamp(1.35rem,3.2vw,1.9rem)] font-bold leading-[1.5] tracking-tight"
        >
          どの面が何のために要るのか
          <br />
          節点をひらいて確かめる
        </h2>
        <dl className="flex shrink-0 gap-8">
          <div>
            <dt className="text-[10px] font-bold tracking-wide text-gray-400">総ページ数</dt>
            <dd className="text-[1.75rem] font-semibold tabular-nums leading-none">
              {TOTAL_PAGES}
            </dd>
          </div>
          <div>
            <dt className="text-[10px] font-bold tracking-wide text-gray-400">末端の面</dt>
            <dd className="text-[1.75rem] font-semibold tabular-nums leading-none">
              {LEAF_COUNT}
            </dd>
          </div>
        </dl>
      </div>

      {/* 図(広い器のみ): 左に作図・右に明細 */}
      <div className="mt-8 hidden gap-8 @2xl:block @4xl:grid @4xl:grid-cols-[1fr_244px]">
        <div className="relative" style={{ height: `${STAGE_H}px` }}>
          <svg
            className="absolute inset-0 h-full w-full"
            viewBox={`0 0 1000 ${STAGE_H}`}
            preserveAspectRatio="none"
            aria-hidden="true"
            focusable="false"
          >
            {flat
              .filter((n) => n.parent)
              .map((n) => (
                <path
                  key={n.id}
                  ref={(el) => {
                    edgeRefs.current[n.id] = el;
                  }}
                  d=""
                  fill="none"
                  vectorEffect="non-scaling-stroke"
                  className="transition-colors duration-200 motion-reduce:transition-none"
                  stroke={lit.has(n.id) ? "#d97706" : "#d1d5db"}
                  strokeWidth={lit.has(n.id) ? 1.6 : 1}
                  style={{ opacity: 0 }}
                />
              ))}
          </svg>

          {flat.map((n) => {
            const col = COLUMNS[n.depth];
            const hasChildren = n.childIds.length > 0 && n.depth > 0;
            const isOpen = open[n.id] === true;
            const isLit = lit.has(n.id);
            const isCurrent = selected === n.id;
            return (
              <div
                key={n.id}
                ref={(el) => {
                  nodeRefs.current[n.id] = el;
                }}
                className="absolute top-0 will-change-transform"
                style={{
                  left: `${col.left / 10}%`,
                  width: `${col.width / 10}%`,
                  opacity: 0,
                }}
              >
                <button
                  type="button"
                  onClick={() => toggle(n.id)}
                  aria-expanded={hasChildren ? isOpen : undefined}
                  className={`group/node block w-full -translate-y-1/2 border bg-white px-3 py-2 text-left transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none ${
                    isCurrent
                      ? "border-amber-600 bg-amber-50/60"
                      : isLit
                        ? "border-amber-600/60 hover:border-amber-600"
                        : "border-gray-200 hover:border-gray-400"
                  }`}
                >
                  <span className="flex items-baseline gap-2">
                    <span
                      className={`flex-1 truncate font-bold leading-tight ${n.depth === 0 ? "text-[13px]" : "text-[12px]"}`}
                    >
                      {n.name}
                    </span>
                    <span
                      className={`text-[10px] font-semibold tabular-nums ${isCurrent ? "text-amber-700" : "text-gray-400"}`}
                    >
                      {n.pages}P
                    </span>
                  </span>
                  <span className="mt-1 flex items-center gap-2">
                    <span className="flex-1 truncate text-[10px] leading-tight text-gray-500">
                      {n.role}
                    </span>
                    {hasChildren ? (
                      <span
                        aria-hidden="true"
                        className={`relative block size-2.5 shrink-0 ${isLit ? "text-amber-600" : "text-gray-400 group-hover/node:text-gray-700"}`}
                      >
                        <span className="absolute top-1/2 left-0 block h-px w-full -translate-y-1/2 bg-current" />
                        <span
                          className={`absolute top-0 left-1/2 block h-full w-px -translate-x-1/2 bg-current transition-transform duration-300 motion-reduce:transition-none ${isOpen ? "scale-y-0" : "scale-y-100"}`}
                        />
                      </span>
                    ) : null}
                  </span>
                </button>
              </div>
            );
          })}
        </div>

        {/* 明細: 選んだ節点の中身 */}
        <div className="mt-6 border-t border-gray-200 pt-6 @4xl:mt-0 @4xl:border-t-0 @4xl:border-l @4xl:pt-0 @4xl:pl-6">
          <p className="text-[10px] font-bold tracking-wide text-gray-400">
            第{current.depth + 1}階層
          </p>
          <p className="mt-2 text-[15px] font-bold leading-snug">{current.name}</p>
          <p className="mt-2 text-[12px] leading-[1.9] text-gray-700">{current.role}</p>
          <p className="mt-3 text-[10px] leading-relaxed text-gray-400">{trail.join(" / ")}</p>
          <dl className="mt-5 border-t border-gray-200">
            <div className="flex items-baseline justify-between border-b border-gray-100 py-2.5">
              <dt className="text-[11px] text-gray-500">直下の項目</dt>
              <dd className="text-[13px] font-semibold tabular-nums">
                {current.childIds.length}
              </dd>
            </div>
            <div className="flex items-baseline justify-between border-b border-gray-100 py-2.5">
              <dt className="text-[11px] text-gray-500">配下のページ</dt>
              <dd className="text-[13px] font-semibold tabular-nums">
                {current.pages}
              </dd>
            </div>
            <div className="flex items-baseline justify-between border-b border-gray-100 py-2.5">
              <dt className="text-[11px] text-gray-500">全体に占める割合</dt>
              <dd className="text-[13px] font-semibold tabular-nums">
                {Math.round((current.pages / TOTAL_PAGES) * 100)}%
              </dd>
            </div>
          </dl>
          <p className="mt-5 text-[10px] leading-relaxed text-gray-400">
            開いている枝 {openCount} / {flat.filter((n) => n.depth === 1).length}(同時に開くのは1本)
          </p>
        </div>
      </div>

      {/* 狭い器では同じ配列を入れ子リストへ組み替える(縮小した図は読めないため) */}
      <ul className="mt-6 @2xl:hidden">
        {flat
          .filter((n) => n.depth === 1)
          .map((n) => {
            const isOpen = open[n.id] === true;
            return (
              <li key={n.id} className="border-b border-gray-200">
                <button
                  type="button"
                  onClick={() => toggle(n.id)}
                  aria-expanded={isOpen}
                  className="flex w-full items-center gap-3 py-4 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
                >
                  <span className="flex-1">
                    <span className="block text-[13px] font-bold leading-tight">{n.name}</span>
                    <span className="mt-1 block text-[11px] leading-tight text-gray-500">
                      {n.role}
                    </span>
                  </span>
                  <span className="text-[11px] font-semibold tabular-nums text-gray-400">
                    {n.pages}P
                  </span>
                  <span
                    aria-hidden="true"
                    className="relative block size-3 shrink-0 text-gray-400"
                  >
                    <span className="absolute top-1/2 left-0 block h-px w-full -translate-y-1/2 bg-current" />
                    <span
                      className={`absolute top-0 left-1/2 block h-full w-px -translate-x-1/2 bg-current transition-transform duration-300 motion-reduce:transition-none ${isOpen ? "scale-y-0" : "scale-y-100"}`}
                    />
                  </span>
                </button>
                {isOpen ? (
                  <ul className="border-t border-gray-100 pb-3 pl-4">
                    {n.childIds.map((cid) => {
                      const child = byId.get(cid)!;
                      return (
                        <li
                          key={cid}
                          className="flex items-baseline gap-3 border-l border-amber-600/40 py-2 pl-4"
                        >
                          <span className="flex-1">
                            <span className="block text-[12px] font-bold leading-tight">
                              {child.name}
                            </span>
                            <span className="mt-0.5 block text-[10px] leading-tight text-gray-500">
                              {child.role}
                            </span>
                          </span>
                          <span className="text-[10px] font-semibold tabular-nums text-gray-400">
                            {child.pages}P
                          </span>
                        </li>
                      );
                    })}
                  </ul>
                ) : null}
              </li>
            );
          })}
      </ul>

      <div className="mt-8 flex flex-col gap-3 border-t border-gray-200 pt-4 @3xl:flex-row @3xl:items-center @3xl:justify-between">
        <p className="text-[10px] leading-relaxed text-gray-400">
          ページ数は公開時点の想定値です。節点を押すと直下の項目が開き、図は位置を取り直します(開く枝は1本ずつ)
        </p>
        <a
          href="#"
          className="group/link relative shrink-0 self-start text-[12px] font-bold text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
        >
          構成案の作り方を見る
          <span className="absolute -bottom-1 left-0 block h-px w-full origin-left scale-x-100 bg-gray-900 transition-transform duration-300 group-hover/link:scale-x-0 motion-reduce:transition-none" />
          <span className="absolute -bottom-1 left-0 block h-px w-full origin-right scale-x-0 bg-amber-600 transition-transform delay-150 duration-300 group-hover/link:origin-left group-hover/link:scale-x-100 motion-reduce:transition-none" />
        </a>
      </div>
    </section>
  );
}

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

npx shadcn@latest add https://designs.first-ch.com/r/structure-tree-diagram.json