/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { Search, Building2, Layers, LayoutTemplate, FileBox, GripHorizontal, Palette, } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { useViewerStore, resolveEntityRef } from '@/store'; import { toGlobalIdFromModels } from '@/store/globalId'; import { useIfc } from '@/hooks/useIfc'; import { Rule, type FilterRule } from '@/lib/search/filter-rules'; import { toast } from '@/components/ui/toast'; import type { TreeNode } from './hierarchy/types'; import { isSpatialContainer } from './hierarchy/types'; import { useHierarchyTree } from './hierarchy/useHierarchyTree'; import { HierarchyNode, SectionHeader } from './hierarchy/HierarchyNode'; import { StoreyDisplayControls } from './hierarchy/StoreyDisplayControls'; export function HierarchyPanel() { const { ifcDataStore, geometryResult, models, activeModelId, setActiveModel, setModelVisibility, setModelCollapsed, removeModel, } = useIfc(); const selectedEntityId = useViewerStore((s) => s.selectedEntityId); const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId); const setSelectedEntityIds = useViewerStore((s) => s.setSelectedEntityIds); const setSelectedEntity = useViewerStore((s) => s.setSelectedEntity); const setSelectedEntities = useViewerStore((s) => s.setSelectedEntities); const toGlobalId = useViewerStore((s) => s.toGlobalId); const setSelectedModelId = useViewerStore((s) => s.setSelectedModelId); const selectedStoreys = useViewerStore((s) => s.selectedStoreys); const setStoreySelection = useViewerStore((s) => s.setStoreySelection); const setStoreysSelection = useViewerStore((s) => s.setStoreysSelection); const clearStoreySelection = useViewerStore((s) => s.clearStoreySelection); const setActiveStorey = useViewerStore((s) => s.setActiveStorey); const setLevelDisplayMode = useViewerStore((s) => s.setLevelDisplayMode); const isolateEntities = useViewerStore((s) => s.isolateEntities); const isolatedEntities = useViewerStore((s) => s.isolatedEntities); const clearIsolation = useViewerStore((s) => s.clearIsolation); const classFilter = useViewerStore((s) => s.classFilter); const setClassFilter = useViewerStore((s) => s.setClassFilter); const addFilterRule = useViewerStore((s) => s.addFilterRule); const updateFilterRule = useViewerStore((s) => s.updateFilterRule); const removeFilterRule = useViewerStore((s) => s.removeFilterRule); const setSearchFilterAutoRunPending = useViewerStore((s) => s.setSearchFilterAutoRunPending); const clearClassFilter = useViewerStore((s) => s.clearClassFilter); const clearAllFilters = useViewerStore((s) => s.clearAllFilters); const setHierarchyBasketSelection = useViewerStore((s) => s.setHierarchyBasketSelection); const hiddenEntities = useViewerStore((s) => s.hiddenEntities); const hideEntities = useViewerStore((s) => s.hideEntities); const showEntities = useViewerStore((s) => s.showEntities); const toggleEntityVisibility = useViewerStore((s) => s.toggleEntityVisibility); const clearSelection = useViewerStore((s) => s.clearSelection); // Derive label for type isolation (from Type tab) by checking mesh ifcType const typeIsolationLabel = useMemo(() => { if (!isolatedEntities || isolatedEntities.size === 0) return null; const sampleId = isolatedEntities.values().next().value!; for (const [, model] of models) { const gr = model.geometryResult; if (!gr?.meshes) continue; const mesh = gr.meshes.find((m: { expressId: number }) => toGlobalIdFromModels(models, model.id, m.expressId) === sampleId, ); if (mesh?.ifcType) return mesh.ifcType; } if (geometryResult?.meshes) { const mesh = geometryResult.meshes.find((m: { expressId: number }) => m.expressId === sampleId); if (mesh?.ifcType) return mesh.ifcType; } return `${isolatedEntities.size} elements`; }, [isolatedEntities, models, geometryResult]); const hasActiveFilters = selectedStoreys.size > 0 || isolatedEntities !== null || classFilter !== null; // Resizable panel split (percentage for storeys section, 0.5 = 50%) const [splitRatio, setSplitRatio] = useState(0.5); const [isDragging, setIsDragging] = useState(false); const containerRef = useRef(null); // Check if we have multiple models loaded const isMultiModel = models.size > 1; // Use extracted hook for tree data management const { searchQuery, setSearchQuery, groupingMode, setGroupingMode, unifiedStoreys, filteredNodes: rawFilteredNodes, storeysNodes: rawStoreysNodes, modelsNodes: rawModelsNodes, toggleExpand, getNodeElements, } = useHierarchyTree({ models, ifcDataStore, isMultiModel, geometryResult }); // Issue #540: when the user has the merge-layers load setting on, // hide `IfcBuildingElementPart` rows from the tree — the Rust layer // suppresses their meshes, so leaving the rows visible would lead // to dead-clicks. Filter at the consumer (this panel) rather than // in `spatialHierarchy.ts` per the agent coordination plan. const mergeLayersHidesParts = useViewerStore((s) => s.mergeLayers); const PART_TYPE_KEY = 'ifcbuildingelementpart'; const stripPartNodes = useCallback( (nodes: TreeNode[]): TreeNode[] => { if (!mergeLayersHidesParts) return nodes; return nodes.filter((node) => { // Only element rows carry an `ifcType` we can compare. Class // grouping ("IfcBuildingElementPart (N)") and ifc-type nodes // also expose an `ifcType`; we strip those too because they // would expand to empty groups after merge. const t = node.ifcType?.toLowerCase(); if (!t) return true; return t !== PART_TYPE_KEY; }); }, [mergeLayersHidesParts], ); const filteredNodes = useMemo(() => stripPartNodes(rawFilteredNodes), [stripPartNodes, rawFilteredNodes]); const storeysNodes = useMemo(() => stripPartNodes(rawStoreysNodes), [stripPartNodes, rawStoreysNodes]); const modelsNodes = useMemo(() => stripPartNodes(rawModelsNodes), [stripPartNodes, rawModelsNodes]); // Refs for both scroll areas const storeysRef = useRef(null); const modelsRef = useRef(null); const parentRef = useRef(null); // Legacy single-model mode // Virtualizers for both sections const storeysVirtualizer = useVirtualizer({ count: storeysNodes.length, getScrollElement: () => storeysRef.current, estimateSize: () => 36, overscan: 10, }); const modelsVirtualizer = useVirtualizer({ count: modelsNodes.length, getScrollElement: () => modelsRef.current, estimateSize: () => 36, overscan: 10, }); // Legacy virtualizer for single-model mode const virtualizer = useVirtualizer({ count: filteredNodes.length, getScrollElement: () => parentRef.current, estimateSize: () => 36, overscan: 10, }); // Resize handler for draggable divider const handleResizeStart = useCallback((e: React.MouseEvent) => { e.preventDefault(); setIsDragging(true); }, []); useEffect(() => { if (!isDragging) return; const handleMouseMove = (e: MouseEvent) => { if (!containerRef.current) return; const containerRect = containerRef.current.getBoundingClientRect(); const relativeY = e.clientY - containerRect.top; // Account for the search header height (~70px) const headerHeight = 70; const availableHeight = containerRect.height - headerHeight; const newRatio = Math.max(0.15, Math.min(0.85, (relativeY - headerHeight) / availableHeight)); setSplitRatio(newRatio); }; const handleMouseUp = () => { setIsDragging(false); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, [isDragging]); // Toggle visibility for a node const handleVisibilityToggle = useCallback((node: TreeNode) => { const elements = getNodeElements(node); if (elements.length === 0) return; // Check if all elements are currently visible (not hidden) const allVisible = elements.every(id => !hiddenEntities.has(id)); if (allVisible) { hideEntities(elements); if (selectedEntityId !== null && elements.includes(selectedEntityId)) { clearSelection(); } } else { showEntities(elements); } }, [getNodeElements, hiddenEntities, hideEntities, showEntities, selectedEntityId, clearSelection]); // Handle model visibility toggle const handleModelVisibilityToggle = useCallback((modelId: string, e: React.MouseEvent) => { e.stopPropagation(); const model = models.get(modelId); if (model) { setModelVisibility(modelId, !model.visible); } }, [models, setModelVisibility]); // Remove model const handleRemoveModel = useCallback((modelId: string, e: React.MouseEvent) => { e.stopPropagation(); removeModel(modelId); }, [removeModel]); // Handle model header click (select model + toggle expand) const handleModelHeaderClick = useCallback((modelId: string, nodeId: string, hasChildren: boolean) => { setSelectedModelId(modelId); if (hasChildren) toggleExpand(nodeId); }, [setSelectedModelId, toggleExpand]); // Mirror a hierarchy selection into the advanced filter as ONE rule per // dimension (issue #1107). Upsert (not append): replace the existing rule of // this kind so the filter tracks the current selection — appending singletons // both leaves stale values behind and, under the default AND combinator, // makes two ifcType rules match nothing. Pass `null` to clear the dimension. const upsertSearchRule = useCallback( (matches: (r: FilterRule) => boolean, rule: FilterRule | null) => { const rules = useViewerStore.getState().searchFilter.rules; const idx = rules.findIndex(matches); if (rule === null) { if (idx < 0) return; // nothing to clear — don't arm an empty run removeFilterRule(idx); } else if (idx >= 0) { updateFilterRule(idx, rule); } else { addFilterRule(rule); } // Arm the Filter to run itself: a hierarchy click shouldn't make the // user open the modal and press Run to see what it matched. The Filter // panel only mounts when the modal is open, so the flag waits there. setSearchFilterAutoRunPending(true); }, [addFilterRule, updateFilterRule, removeFilterRule, setSearchFilterAutoRunPending], ); // Handle node click - for selection/isolation or expand/collapse const handleNodeClick = useCallback((node: TreeNode, e: React.MouseEvent) => { if (node.type === 'model-header' && node.id !== 'models-header') { // Model header click handled by its own onClick (expand/collapse) return; } const hierarchyRefs: Array<{ modelId: string; expressId: number }> = []; for (const globalId of getNodeElements(node)) { const ref = resolveEntityRef(globalId); if (ref) hierarchyRefs.push(ref); } if (hierarchyRefs.length > 0) { setHierarchyBasketSelection(hierarchyRefs); } else if (isSpatialContainer(node.type) && node.expressIds.length > 0) { setHierarchyBasketSelection([{ modelId: node.modelIds[0] || 'legacy', expressId: node.expressIds[0], }]); } // Type group nodes - click to filter/isolate entities, expand via chevron only if (node.type === 'type-group') { const elements = getNodeElements(node); if (elements.length > 0) { // Clear multi-selection highlight setSelectedEntityIds([]); setSelectedEntity(resolveEntityRef(elements[0])); if (groupingMode === 'type') { const className = node.ifcType || node.name; // Class tab → class filter (combinable with storey + type isolation) setClassFilter(elements, className); // Mirror to the advanced filter: sync the single ifcType rule to the // current class (issue #1107). Replace, don't accumulate — the class // tab is single-select, so the rule should be exactly the clicked // class, not a pile-up of earlier clicks. upsertSearchRule((r) => r.kind === 'ifcType' && r.op === 'in', Rule.ifcType([className], 'in')); toast.success(`Filter → ${className}`); } else { // Type tab → type isolation (combinable with storey + class filter) isolateEntities(elements); } } return; } // Material group nodes (Materials tab) - select the material entity for the // totals panel + isolate the elements that use it. if (node.type === 'material-group') { const modelId = node.modelIds[0]; const materialExpressId = node.entityExpressId; // Clear multi-selection first (setSelectedEntityIds([]) resets selectedEntityId) setSelectedEntityIds([]); if (materialExpressId !== undefined) { if (modelId && modelId !== 'legacy') { setSelectedEntityId(toGlobalId(modelId, materialExpressId)); setSelectedEntity({ modelId, expressId: materialExpressId }); setActiveModel(modelId); } else { setSelectedEntityId(materialExpressId); setSelectedEntity({ modelId: 'legacy', expressId: materialExpressId }); } } // Isolate the elements using this material const elements = getNodeElements(node); if (elements.length > 0) { isolateEntities(elements); } // Mirror to the advanced filter (issue #1107). upsertSearchRule((r) => r.kind === 'material', Rule.material('eq', node.name)); toast.success(`Filter → material ${node.name}`); return; } // IFC type entity nodes (e.g. IfcWallType/W01) - select type entity for property panel + isolate instances if (node.type === 'ifc-type') { const modelId = node.modelIds[0]; const typeExpressId = node.entityExpressId; if (!typeExpressId) return; // Clear multi-selection first (before setting new selection, since // setSelectedEntityIds([]) resets selectedEntityId to null) setSelectedEntityIds([]); if (modelId && modelId !== 'legacy') { setSelectedEntityId(toGlobalId(modelId, typeExpressId)); setSelectedEntity({ modelId, expressId: typeExpressId }); setActiveModel(modelId); } else { setSelectedEntityId(typeExpressId); setSelectedEntity({ modelId: 'legacy', expressId: typeExpressId }); } // Isolate instances of this type const elements = getNodeElements(node); if (elements.length > 0) { isolateEntities(elements); } // Toggle expand if (node.hasChildren) { toggleExpand(node.id); } return; } // Spatial container nodes (IfcProject/IfcSite/IfcBuilding) - select for property panel + expand if (isSpatialContainer(node.type)) { const entityId = node.expressIds[0]; const modelId = node.modelIds[0]; if (modelId && modelId !== 'legacy') { // Multi-model: convert to globalId for renderer, set entity for property panel const globalId = toGlobalIdFromModels(models, modelId, entityId); setSelectedEntityId(globalId); setSelectedEntity({ modelId, expressId: entityId }); setActiveModel(modelId); } else if (entityId) { // Legacy single-model setSelectedEntityId(entityId); setSelectedEntity({ modelId: 'legacy', expressId: entityId }); } // Also toggle expand if has children if (node.hasChildren) { toggleExpand(node.id); } return; } if (node.type === 'unified-storey' || node.type === 'IfcBuildingStorey') { // Storey click - select/isolate (unified or single) const unified = node.type === 'unified-storey' ? unifiedStoreys.find(u => `unified-${u.key}` === node.id) : null; const storeyIds = unified ? unified.storeys.map(s => s.storeyId) : node.expressIds; // Update the shared active storey (model-aware) so Space Sketch, the // Solo level-display mode, and the floorplan all follow the storey the // user just clicked. For a multi-model unified storey, pick the first // constituent as the representative. const activeRep = unified && unified.storeys.length > 0 ? { modelId: unified.storeys[0].modelId, expressId: unified.storeys[0].storeyId } : { modelId: node.modelIds[0] || 'legacy', expressId: storeyIds[0] }; if (activeRep.expressId != null) setActiveStorey(activeRep); // Set entity refs for property panel display if (unified && unified.storeys.length > 1) { // Multi-model unified storey: show all storeys combined in property panel const entityRefs = unified.storeys.map(s => ({ modelId: s.modelId, expressId: s.storeyId, })); setSelectedEntities(entityRefs); // Clear single entity selection (property panel will use selectedEntities) setSelectedEntityId(null); } else { // Single storey: show in property panel like any entity const storeyId = storeyIds[0]; const modelId = node.modelIds[0]; if (modelId && modelId !== 'legacy') { const globalId = toGlobalIdFromModels(models, modelId, storeyId); setSelectedEntityId(globalId); setSelectedEntity({ modelId, expressId: storeyId }); } else { setSelectedEntityId(storeyId); setSelectedEntity({ modelId: 'legacy', expressId: storeyId }); } } if (e.ctrlKey || e.metaKey) { // Add to storey filter selection setStoreysSelection([...Array.from(selectedStoreys), ...storeyIds]); // Mirror to the advanced filter — accumulate the storey name (issue #1107). const cur = useViewerStore.getState().searchFilter.rules.find((r) => r.kind === 'storey' && r.op === 'in'); const names = cur && cur.kind === 'storey' ? Array.from(new Set([...cur.values, node.name])) : [node.name]; upsertSearchRule((r) => r.kind === 'storey' && r.op === 'in', Rule.storey(names, 'in')); toast.success(`Filter → storey ${node.name}`); } else { // Single selection - toggle if already selected const allAlreadySelected = storeyIds.length > 0 && storeyIds.every(id => selectedStoreys.has(id)) && selectedStoreys.size === storeyIds.length; if (allAlreadySelected) { // Toggle off - clear selection to show all. The level-display guard // (useLevelDisplayEffect) drops Solo → Stacked when no storey is // isolated, so the mode flag follows. clearStoreySelection(); // Clear the mirrored storey rule too (issue #1107). upsertSearchRule((r) => r.kind === 'storey', null); // Make the "click again to leave Solo" behaviour discoverable (#1265). toast.success('Showing all storeys'); } else { // Select this storey (replaces any existing selection). Isolating a // single storey IS Solo, so reflect that in the level-display mode — // keeps the storey-tab control + in-viewport chip in sync. setStoreysSelection(storeyIds); setLevelDisplayMode('solo'); // Mirror to the advanced filter: one storey rule = this storey (issue #1107). upsertSearchRule((r) => r.kind === 'storey' && r.op === 'in', Rule.storey([node.name], 'in')); // Phrase it as Solo so the storey-row to Solo link is obvious (#1265). toast.success(`Solo: showing only ${node.name}`); } } } else if (node.type === 'IfcSpace') { const spaceId = node.expressIds[0]; const modelId = node.modelIds[0]; const globalId = node.globalIds[0] ?? spaceId; setSelectedEntityIds([]); if (modelId && modelId !== 'legacy') { setSelectedEntityId(globalId); setSelectedEntity({ modelId, expressId: spaceId }); setActiveModel(modelId); } else { setSelectedEntityId(globalId); setSelectedEntity({ modelId: 'legacy', expressId: spaceId }); } if (node.hasChildren) { toggleExpand(node.id); } } else if (node.type === 'element') { // Element click - select it const elementId = node.expressIds[0]; const modelId = node.modelIds[0]; const globalId = node.globalIds[0] ?? elementId; const parts = node.assemblyChildGlobalIds; if (parts && parts.length > 0) { // A decomposing assembly (IfcElementAssembly, IfcStair-as-container, …) // carries no geometry of its own — highlight + frame all its parts in // one click. The assembly goes last so it becomes the primary selection // (setSelectedEntityIds keys selectedEntityId off the final id), which // keeps the assembly row highlighted in the tree (#1133). setSelectedEntityIds([...parts, globalId]); if (modelId !== 'legacy') { setSelectedEntity({ modelId, expressId: elementId }); setActiveModel(modelId); } else { setSelectedEntity(resolveEntityRef(globalId)); } return; } // Clear multi-selection (e.g. from a prior type-group click) so only // this single element is highlighted, matching Viewport pick behavior setSelectedEntityIds([]); if (modelId !== 'legacy') { setSelectedEntityId(globalId); setSelectedEntity({ modelId, expressId: elementId }); setActiveModel(modelId); } else { setSelectedEntityId(globalId); setSelectedEntity(resolveEntityRef(globalId)); } } }, [selectedStoreys, setStoreysSelection, clearStoreySelection, setActiveStorey, setLevelDisplayMode, setSelectedEntityId, setSelectedEntityIds, setSelectedEntity, setSelectedEntities, setActiveModel, toggleExpand, unifiedStoreys, models, isolateEntities, getNodeElements, setHierarchyBasketSelection, toGlobalId, groupingMode, setClassFilter, upsertSearchRule]); // Compute selection and visibility state for a node const computeNodeState = useCallback((node: TreeNode): { isSelected: boolean; nodeHidden: boolean; modelVisible?: boolean } => { // Determine if node is selected // For ifc-type nodes, check if the type entity itself is selected const isSelected = node.type === 'unified-storey' ? node.expressIds.some(id => selectedStoreys.has(id)) : node.type === 'IfcBuildingStorey' ? selectedStoreys.has(node.expressIds[0]) : node.type === 'IfcSpace' || node.type === 'element' ? selectedEntityId === (node.globalIds[0] ?? node.expressIds[0]) : node.type === 'ifc-type' || node.type === 'material-group' ? (() => { const entityExpressId = node.entityExpressId; if (!entityExpressId) return false; const mId = node.modelIds[0]; const gId = mId && mId !== 'legacy' ? toGlobalId(mId, entityExpressId) : entityExpressId; return selectedEntityId === gId; })() : false; // Compute visibility inline - for elements check directly, for storeys use getNodeElements let nodeHidden = false; if (node.type === 'element') { const parts = node.assemblyChildGlobalIds; if (parts && parts.length > 0) { // An assembly reads as hidden only when every part it owns is hidden // (its own geometry-less id never enters hiddenEntities) (#1133). nodeHidden = parts.every((id) => hiddenEntities.has(id)); } else { nodeHidden = hiddenEntities.has(node.globalIds[0] ?? node.expressIds[0]); } } else if (node.type === 'IfcBuildingStorey' || node.type === 'IfcSpace' || node.type === 'unified-storey' || node.type === 'type-group' || node.type === 'ifc-type' || node.type === 'material-group' || (node.type === 'model-header' && node.id.startsWith('contrib-'))) { const elements = getNodeElements(node); nodeHidden = elements.length > 0 && elements.every(id => hiddenEntities.has(id)); } // Model visibility for model-header nodes let modelVisible: boolean | undefined; if (node.type === 'model-header' && node.id.startsWith('model-')) { const model = models.get(node.modelIds[0]); modelVisible = model?.visible; } return { isSelected, nodeHidden, modelVisible }; }, [selectedStoreys, selectedEntityId, hiddenEntities, getNodeElements, models, toGlobalId]); if (!ifcDataStore && models.size === 0) { return (

Hierarchy

No Model

Structure will appear here when loaded

); } const singleModel = models.size === 1 ? Array.from(models.values())[0] : null; if (!ifcDataStore && singleModel) { const metadataState = singleModel.metadataLoadState; const message = metadataState === 'error' ? (singleModel.loadError || 'Native metadata failed to load.') : metadataState === 'bootstrapping' ? 'Native spatial metadata is loading.' : 'Spatial metadata will appear once bootstrap completes.'; return (

Hierarchy

{message}
); } // Helper to render a node via the extracted HierarchyNode component const renderNode = (node: TreeNode, virtualRow: { index: number; size: number; start: number }) => { const { isSelected, nodeHidden, modelVisible } = computeNodeState(node); return ( ); }; // Multi-model layout with resizable split // Grouping mode toggle component (shared by both layouts) const groupingToggle = (
); // In type/ifc-type grouping mode, always use flat tree layout (even for multi-model) if (isMultiModel && groupingMode === 'spatial') { return (
{/* Search Header */}
setSearchQuery(e.target.value)} leftIcon={} className="h-9 text-sm rounded-none border-2 border-zinc-200 dark:border-zinc-800 focus:border-primary focus:ring-0 bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 placeholder:text-zinc-400 dark:placeholder:text-zinc-600" /> {groupingToggle}
{/* Resizable content area */}
{/* Storeys Section */}
{storeysVirtualizer.getVirtualItems().map((virtualRow) => { const node = storeysNodes[virtualRow.index]; return renderNode(node, virtualRow); })}
{/* Resizable Divider */}
{/* Models Section */}
{modelsVirtualizer.getVirtualItems().map((virtualRow) => { const node = modelsNodes[virtualRow.index]; return renderNode(node, virtualRow); })}
{/* Footer status */} {hasActiveFilters ? (
{selectedStoreys.size > 0 && ( {selectedStoreys.size} {selectedStoreys.size === 1 ? 'Storey' : 'Storeys'} )} {classFilter !== null && ( <> {selectedStoreys.size > 0 && +} {classFilter.label} )} {isolatedEntities !== null && ( <> {(selectedStoreys.size > 0 || classFilter !== null) && +} {typeIsolationLabel} )}
ESC
) : (
{models.size} models · Drag divider to resize
)}
); } // Single model layout return (
{/* Header */}
setSearchQuery(e.target.value)} leftIcon={} className="h-9 text-sm rounded-none border-2 border-zinc-200 dark:border-zinc-800 focus:border-primary focus:ring-0 bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 placeholder:text-zinc-400 dark:placeholder:text-zinc-600" /> {groupingToggle}
{/* Section Header */} {/* Level display (Stacked / Exploded / Solo) + floorplan — only in the spatial view where storeys are the organising concept. */} {groupingMode === 'spatial' && } {/* Tree */}
{virtualizer.getVirtualItems().map((virtualRow) => { const node = filteredNodes[virtualRow.index]; return renderNode(node, virtualRow); })}
{/* Footer status */} {hasActiveFilters ? (
{selectedStoreys.size > 0 && ( {selectedStoreys.size} {selectedStoreys.size === 1 ? 'Storey' : 'Storeys'} )} {classFilter !== null && ( <> {selectedStoreys.size > 0 && +} {classFilter.label} )} {isolatedEntities !== null && ( <> {(selectedStoreys.size > 0 || classFilter !== null) && +} {typeIsolationLabel} )}
ESC
) : (
Click to filter · Ctrl toggle
)}
); }