First CH Designs
BLK-80Blocks

Expandable Node Tree — Structure Map

A node-and-edge diagram for site or system structure. Pressing a node grows its children out of the parent, re-spaces the remaining nodes and redraws the connecting curves in place — the diagram itself re-forms as you explore it. Only one branch opens at a time — opening another retracts the previous one into its parent — so node spacing never falls below the height of a node box. The selected node lights its whole lineage back to the root, and the panel beside it reports role, direct child count, page count and share of the whole, so the figure doubles as a proposal or handover asset. The SVG uses a 1000-wide viewBox with preserveAspectRatio:none so vertical units stay in real pixels and horizontal units are per-mille of the container, removing any need to measure width. GSAP tweens only plain objects; node transforms and edge path data are written every frame by hand so edges never lag behind their nodes. Reveal runs root → branch → leaf via ScrollTrigger, and narrow containers re-form the same data as a nested disclosure list. No images.

Added:
2026-09-26
Dependencies:
gsap
tags
#diagram #tree #hierarchy #information-architecture #svg #gsap #scrolltrigger #interactive #container-query #section #brand #no-image #reduced-motion

Preview

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

総ページ数
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>
  );
}

Add to your project via shadcn CLI

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