diff --git a/apps/web/src/components/ObjectTree.tsx b/apps/web/src/components/ObjectTree.tsx index daa8e6f..407736e 100644 --- a/apps/web/src/components/ObjectTree.tsx +++ b/apps/web/src/components/ObjectTree.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { Icon } from '@iconify/react'; import type { ObjectTreeNode } from '@tts/extract'; import { iconsForObject } from './objectIcons'; @@ -10,19 +10,78 @@ interface Props { } export default function ObjectTree({ nodes, selectedPath, onSelect }: Props) { + const [highlighted, setHighlighted] = useState>(() => new Set()); + + const types = useMemo(() => collectTypes(nodes), [nodes]); + + // When nothing is highlighted, every type is shown. Otherwise only the + // highlighted types are visible. + const visibleNodes = useMemo( + () => filterTree(nodes, highlighted), + [nodes, highlighted], + ); + + const toggleType = (name: string) => + setHighlighted((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + + const clearAll = () => setHighlighted(new Set()); + return ( - +
+ {types.length > 0 && ( +
+ {types.map(({ name, count }) => { + const active = highlighted.has(name); + return ( + + ); + })} + {highlighted.size > 0 && ( + + )} +
+ )} + +
); } @@ -102,4 +161,51 @@ function TreeNode({ )} ); +} + +/** Distinct object types present in the tree, with a count of each. */ +function collectTypes(nodes: ObjectTreeNode[]): { name: string; count: number }[] { + const counts = new Map(); + const visit = (node: ObjectTreeNode) => { + counts.set(node.object.Name, (counts.get(node.object.Name) ?? 0) + 1); + node.children.forEach(visit); + }; + nodes.forEach(visit); + return [...counts.entries()] + .map(([name, count]) => ({ name, count })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Keep a node when its type is highlighted or any descendant survives, so the + * containment hierarchy is preserved and highlighted parents still lead to + * highlighted children. With nothing highlighted, every node is kept. + * + * Each result carries the node's original index path into the full (unfiltered) + * tree, so selection stays stable regardless of filtering. + */ +function filterTree( + nodes: ObjectTreeNode[], + highlighted: Set, + prefix = '', +): { node: ObjectTreeNode; path: string }[] { + const result: { node: ObjectTreeNode; path: string }[] = []; + nodes.forEach((node, index) => { + const path = prefix ? `${prefix}-${index}` : `${index}`; + const children = filterTree(node.children, highlighted, path); + const visible = + highlighted.size === 0 || + highlighted.has(node.object.Name) || + children.length > 0; + if (visible) { + result.push({ + node: + children.length > 0 + ? { ...node, children: children.map((c) => c.node) } + : node, + path, + }); + } + }); + return result; } \ No newline at end of file