concept-collection / numbl-figure-viewer
numbl-figure-viewer / src / ObjectTree.tsx
189 lines · 4.8 KBBlameHistoryRaw
1import { useState } from "react";
2import type { TreeNode, NodeIcon } from "./tree";
3import {
4 type ViewState,
5 isHidden,
6 isEffectivelyHidden,
7} from "./viewState";
9const ICONS: Record<NodeIcon, string> = {
10 figure: "▦",
11 axes: "▢",
12 trace: "∿",
13 dataset: "⋯",
14};
16function EyeIcon({ open }: { open: boolean }) {
17 return (
18 <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
19 <path
20 d="M8 3.2C4.4 3.2 1.9 6 1.2 8c.7 2 3.2 4.8 6.8 4.8S14.1 10 14.8 8C14.1 6 11.6 3.2 8 3.2z"
21 fill="none"
22 stroke="currentColor"
23 strokeWidth="1.2"
24 />
25 <circle cx="8" cy="8" r="2" fill="currentColor" />
26 {!open && (
27 <line x1="2.5" y1="13.5" x2="13.5" y2="2.5" stroke="currentColor" strokeWidth="1.2" />
28 )}
29 </svg>
30 );
33function SoloIcon() {
34 return (
35 <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
36 <circle cx="8" cy="8" r="5" fill="none" stroke="currentColor" strokeWidth="1.2" />
37 <circle cx="8" cy="8" r="1.8" fill="currentColor" />
38 </svg>
39 );
42function NodeRow({
43 node,
44 depth,
45 expanded,
46 toggle,
47 selectedId,
48 onSelect,
49 viewState,
50 onToggleHidden,
51 onIsolate,
52}: {
53 node: TreeNode;
54 depth: number;
55 expanded: Set<string>;
56 toggle: (id: string) => void;
57 selectedId: string | null;
58 onSelect: (id: string) => void;
59 viewState: ViewState;
60 onToggleHidden: (id: string) => void;
61 onIsolate: (id: string) => void;
62}) {
63 const hasChildren = node.children.length > 0;
64 const isOpen = expanded.has(node.id);
65 const isSelected = selectedId === node.id;
66 const hasControls = node.icon === "axes" || node.icon === "trace";
67 const ownHidden = isHidden(viewState, node.id);
68 const dimmed = isEffectivelyHidden(viewState, node.id);
70 const cls = [
71 "tree-row",
72 isSelected ? "selected" : "",
73 dimmed ? "dimmed" : "",
74 ownHidden ? "el-hidden" : "",
75 ]
76 .filter(Boolean)
77 .join(" ");
79 return (
80 <>
81 <div
82 className={cls}
83 style={{ paddingLeft: 4 + depth * 14 }}
84 onClick={() => onSelect(node.id)}
85 role="treeitem"
86 aria-selected={isSelected}
87 aria-expanded={hasChildren ? isOpen : undefined}
88 >
89 <span
90 className={"twisty" + (hasChildren ? "" : " leaf")}
91 onClick={e => {
92 e.stopPropagation();
93 if (hasChildren) toggle(node.id);
94 }}
95 >
96 {hasChildren ? (isOpen ? "▾" : "▸") : ""}
97 </span>
98 <span className={"node-icon icon-" + node.icon}>{ICONS[node.icon]}</span>
99 <span className="node-label">{node.label}</span>
100 {hasControls && (
101 <span className="row-actions">
102 <button
103 className="act-btn"
104 title="Show only this"
105 onClick={e => {
106 e.stopPropagation();
107 onIsolate(node.id);
108 }}
109 >
110 <SoloIcon />
111 </button>
112 <button
113 className={"act-btn" + (ownHidden ? " active" : "")}
114 title={ownHidden ? "Show" : "Hide"}
115 onClick={e => {
116 e.stopPropagation();
117 onToggleHidden(node.id);
118 }}
119 >
120 <EyeIcon open={!ownHidden} />
121 </button>
122 </span>
123 )}
124 </div>
125 {hasChildren &&
126 isOpen &&
127 node.children.map(child => (
128 <NodeRow
129 key={child.id}
130 node={child}
131 depth={depth + 1}
132 expanded={expanded}
133 toggle={toggle}
134 selectedId={selectedId}
135 onSelect={onSelect}
136 viewState={viewState}
137 onToggleHidden={onToggleHidden}
138 onIsolate={onIsolate}
139 />
140 ))}
141 </>
142 );
145export function ObjectTree({
146 root,
147 selectedId,
148 onSelect,
149 viewState,
150 onToggleHidden,
151 onIsolate,
152}: {
153 root: TreeNode;
154 selectedId: string | null;
155 onSelect: (id: string) => void;
156 viewState: ViewState;
157 onToggleHidden: (id: string) => void;
158 onIsolate: (id: string) => void;
159}) {
160 // Expand the figure and its axes by default.
161 const [expanded, setExpanded] = useState<Set<string>>(() => {
162 const s = new Set<string>([root.id]);
163 for (const axes of root.children) s.add(axes.id);
164 return s;
165 });
166 const toggle = (id: string) =>
167 setExpanded(prev => {
168 const next = new Set(prev);
169 if (next.has(id)) next.delete(id);
170 else next.add(id);
171 return next;
172 });
174 return (
175 <div className="tree" role="tree">
176 <NodeRow
177 node={root}
178 depth={0}
179 expanded={expanded}
180 toggle={toggle}
181 selectedId={selectedId}
182 onSelect={onSelect}
183 viewState={viewState}
184 onToggleHidden={onToggleHidden}
185 onIsolate={onIsolate}
186 />
187 </div>
188 );