/* 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/. */ /** * Lens panel — rule-based 3D filtering and coloring * * Shows saved lens presets and allows activating/deactivating them. * Users can create, edit, and delete custom lenses with full rule editing. * Supports both manual rule-based lenses and auto-color lenses that * automatically color entities by distinct values of any IFC data column. * When a lens is active, a color legend displays the matched rules/values. * Unmatched entities are ghosted (semi-transparent) for visual context. * * All dropdowns are populated dynamically from the loaded model data * via discoveredLensData (IFC types, property sets, quantity sets, * classification systems, materials). No hardcoded IFC class lists. */ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { X, EyeOff, Palette, Check, Plus, Trash2, Pencil, Save, Download, Upload, Sparkles, Search, ChevronDown, ArrowUpDown } from 'lucide-react'; import { discoverDataSources } from '@ifc-lite/lens'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { useViewerStore } from '@/store'; import { useLens } from '@/hooks/useLens'; import { createLensDataProvider } from '@/lib/lens'; import type { Lens, LensRule, LensCriteria, AutoColorSpec, AutoColorLegendEntry, DiscoveredLensData } from '@/store/slices/lensSlice'; import { LENS_PALETTE, ENTITY_ATTRIBUTE_NAMES, AUTO_COLOR_SOURCES, } from '@/store/slices/lensSlice'; /** Format large counts compactly: 1234 → "1.2k" */ function formatCount(n: number): string { if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`; return String(n); } /** Human-readable label for source / criteria types (shared) */ const TYPE_LABELS: Record = { ifcType: 'IFC Class', attribute: 'Attribute', property: 'Property', quantity: 'Quantity', classification: 'Classification', material: 'Material', model: 'Model', group: 'Zone / Group', }; interface LensPanelProps { onClose?: () => void; } // ─── Searchable dropdown (for large dynamic lists) ────────────────────────── function SearchableSelect({ value, options, onChange, placeholder, className, displayFn, }: { value: string; options: readonly string[]; onChange: (value: string) => void; placeholder?: string; className?: string; displayFn?: (v: string) => string; }) { const [open, setOpen] = useState(false); const [filter, setFilter] = useState(''); const containerRef = useRef(null); const inputRef = useRef(null); const filtered = useMemo(() => { if (!filter) return options; const q = filter.toLowerCase(); return options.filter(o => o.toLowerCase().includes(q)); }, [options, filter]); // Close on outside click useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setOpen(false); setFilter(''); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [open]); const display = displayFn ?? ((v: string) => v); return (
{open && (
{options.length > 8 && (
setFilter(e.target.value)} placeholder="Search..." className="flex-1 text-xs bg-transparent border-0 outline-none text-zinc-900 dark:text-zinc-100 placeholder:text-zinc-400" />
)}
{filtered.length === 0 && (
No matches
)} {filtered.map(opt => ( ))}
)}
); } // ─── Rule display (read-only, clickable for isolation) ────────────────────── const RuleRow = memo(function RuleRow({ rule, count, isIsolated, onClick, }: { rule: LensRule; count: number; isIsolated?: boolean; onClick?: () => void; }) { const isEmpty = count === 0; const isClickable = !!onClick && !isEmpty; return (
{ if (isClickable) { e.stopPropagation(); onClick(); } }} onKeyDown={(e) => { if (isClickable && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onClick(); } }} title={isClickable ? 'Click to isolate / show only this group' : isEmpty ? 'No matching entities' : undefined} >
{rule.name} {isIsolated && ( isolated )} {isEmpty ? '—' : formatCount(count)}
); }); // ─── Auto-color legend row (read-only, clickable for isolation) ───────────── const AutoColorRow = memo(function AutoColorRow({ entry, isIsolated, onClick, }: { entry: AutoColorLegendEntry; isIsolated?: boolean; onClick?: () => void; }) { const isEmpty = entry.count === 0; const isClickable = !!onClick && !isEmpty; return (
{ if (isClickable) { e.stopPropagation(); onClick(); } }} onKeyDown={(e) => { if (isClickable && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onClick(); } }} title={isClickable ? 'Click to isolate / show only this value' : undefined} >
{entry.name} {isIsolated && ( isolated )} {formatCount(entry.count)}
); }); // ─── Rule editor (inline editing with criteria type selector) ──────────────── function RuleEditor({ rule, onChange, onRemove, discovered, onRequestDiscovery, }: { rule: LensRule; onChange: (patch: Partial) => void; onRemove: () => void; discovered: DiscoveredLensData | null; onRequestDiscovery: (categories: { properties?: boolean; quantities?: boolean; classifications?: boolean; materials?: boolean }) => void; }) { const criteriaType = rule.criteria.type; const loadedModels = useViewerStore((s) => s.models); const modelOptions = useMemo( () => Array.from(loadedModels.values()).sort((a, b) => a.name.localeCompare(b.name)), [loadedModels], ); // Trigger lazy discovery when user selects a criteria type that needs it useEffect(() => { if (!discovered) return; if (criteriaType === 'property' && !discovered.propertySets) { onRequestDiscovery({ properties: true }); } else if (criteriaType === 'quantity' && !discovered.quantitySets) { onRequestDiscovery({ quantities: true }); } else if (criteriaType === 'classification' && !discovered.classificationSystems) { onRequestDiscovery({ classifications: true }); } else if (criteriaType === 'material' && !discovered.materials) { onRequestDiscovery({ materials: true }); } }, [criteriaType, discovered, onRequestDiscovery]); // Auto-populate the single available model so the selector-hidden branch // doesn't leave a model rule permanently invalid. useEffect(() => { if (criteriaType !== 'model') return; if (modelOptions.length !== 1) return; if (rule.criteria.modelId) return; const updated = { ...rule.criteria, modelId: modelOptions[0].id }; onChange({ criteria: updated, name: deriveRuleName(updated) }); // deriveRuleName is stable for this render; depending on rule.criteria/onChange is enough. // eslint-disable-next-line react-hooks/exhaustive-deps }, [criteriaType, modelOptions, rule.criteria, onChange]); // Derived lists from discovered data const ifcClasses = useMemo(() => discovered?.classes ?? [], [discovered]); const psetNames = useMemo((): string[] => { if (!discovered?.propertySets) return []; return Array.from(discovered.propertySets.keys()).sort(); }, [discovered]); const selectedPsetProps = useMemo(() => { if (!discovered?.propertySets || !rule.criteria.propertySet) return []; return discovered.propertySets.get(rule.criteria.propertySet) ?? []; }, [discovered, rule.criteria.propertySet]); const qsetNames = useMemo((): string[] => { if (!discovered?.quantitySets) return []; return Array.from(discovered.quantitySets.keys()).sort(); }, [discovered]); const selectedQsetQuants = useMemo(() => { if (!discovered?.quantitySets || !rule.criteria.quantitySet) return []; return discovered.quantitySets.get(rule.criteria.quantitySet) ?? []; }, [discovered, rule.criteria.quantitySet]); const classificationSystems = useMemo(() => discovered?.classificationSystems ?? [], [discovered]); const materialNames = useMemo(() => discovered?.materials ?? [], [discovered]); const handleCriteriaTypeChange = (newType: LensCriteria['type']) => { const base: LensCriteria = { type: newType }; switch (newType) { case 'ifcType': base.ifcType = ''; break; case 'attribute': base.attributeName = 'Name'; base.operator = 'contains'; base.attributeValue = ''; break; case 'property': base.propertySet = ''; base.propertyName = ''; base.operator = 'contains'; base.propertyValue = ''; break; case 'quantity': base.quantitySet = ''; base.quantityName = ''; base.operator = 'exists'; break; case 'classification': base.classificationSystem = ''; base.classificationCode = ''; break; case 'material': base.materialName = ''; break; case 'model': base.modelId = modelOptions.length === 1 ? modelOptions[0].id : ''; break; case 'group': base.groupName = ''; break; } onChange({ criteria: base, name: rule.name === 'New Rule' ? TYPE_LABELS[newType] : rule.name }); }; /** Derive a human-readable name from the criteria */ const deriveRuleName = (criteria: LensCriteria): string => { switch (criteria.type) { case 'ifcType': return criteria.ifcType ? criteria.ifcType.replace('Ifc', '') : 'New Rule'; case 'attribute': return criteria.attributeValue || criteria.attributeName || 'Attribute'; case 'property': return criteria.propertyName || 'Property'; case 'quantity': return criteria.quantityName || 'Quantity'; case 'classification': return criteria.classificationCode || criteria.classificationSystem || 'Classification'; case 'material': return criteria.materialName || 'Material'; case 'model': { const selected = modelOptions.find(m => m.id === criteria.modelId); return selected?.name || 'Model'; } case 'group': return criteria.groupName || 'Zone'; default: return 'Rule'; } }; const selectClass = 'text-xs px-1.5 py-1 bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-600 text-zinc-900 dark:text-zinc-100 rounded-sm'; const inputClass = 'text-xs px-1.5 py-1 bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-600 text-zinc-900 dark:text-zinc-100 rounded-sm'; return (
onChange({ color: e.target.value })} className="w-6 h-6 cursor-pointer border-0 p-0 bg-transparent flex-shrink-0 rounded" /> {/* Criteria type selector */} {/* IFC Class: searchable dropdown from discovered classes */} {criteriaType === 'ifcType' && ( { onChange({ criteria: { ...rule.criteria, ifcType }, name: ifcType ? ifcType.replace('Ifc', '') : rule.name, }); }} placeholder="Class..." className="flex-1 min-w-0" displayFn={(v) => v.replace('Ifc', '')} /> )} {/* Attribute: dropdown for name, text input for value */} {criteriaType === 'attribute' && ( <> { const updated = { ...rule.criteria, attributeValue: e.target.value }; onChange({ criteria: updated, name: deriveRuleName(updated) }); }} placeholder="value..." className={cn(inputClass, 'flex-1 min-w-0')} /> )} {/* Property: searchable dropdowns for pset + property name */} {criteriaType === 'property' && ( <> onChange({ criteria: { ...rule.criteria, propertySet: pset, propertyName: '' } })} placeholder="Pset..." className="w-[100px]" /> { const updated = { ...rule.criteria, propertyName: prop }; onChange({ criteria: updated, name: deriveRuleName(updated) }); }} placeholder="Prop..." className="flex-1 min-w-0" /> )} {/* Quantity: searchable dropdowns for qset + quantity name */} {criteriaType === 'quantity' && ( <> onChange({ criteria: { ...rule.criteria, quantitySet: qset, quantityName: '' } })} placeholder="Qset..." className="w-[100px]" /> { const updated = { ...rule.criteria, quantityName: qty }; onChange({ criteria: updated, name: deriveRuleName(updated) }); }} placeholder="Qty..." className="flex-1 min-w-0" /> )} {/* Classification: searchable dropdown for system, text input for code */} {criteriaType === 'classification' && ( <> onChange({ criteria: { ...rule.criteria, classificationSystem: sys } })} placeholder="System..." className="w-[100px]" /> { const updated = { ...rule.criteria, classificationCode: e.target.value }; onChange({ criteria: updated, name: deriveRuleName(updated) }); }} placeholder="Code..." className={cn(inputClass, 'flex-1 min-w-0')} /> )} {/* Material: searchable dropdown from discovered materials */} {criteriaType === 'material' && ( { const updated = { ...rule.criteria, materialName: mat }; onChange({ criteria: updated, name: deriveRuleName(updated) }); }} placeholder="Material..." className="flex-1 min-w-0" /> )} {/* Model: dropdown from loaded federated models */} {criteriaType === 'model' && ( modelOptions.length <= 1 ? ( {modelOptions.length === 0 ? 'No models loaded' : modelOptions[0]?.name ?? 'Model'} ) : ( ) )} {/* Zone / Group: substring match on the zone name (blank = any zone) */} {criteriaType === 'group' && ( { const updated = { ...rule.criteria, groupName: e.target.value }; onChange({ criteria: updated, name: deriveRuleName(updated) }); }} placeholder="Zone / group name (blank = any)" className={cn(inputClass, 'flex-1 min-w-0')} /> )}
{/* Second row: operator + value for property/quantity/attribute */} {(criteriaType === 'property' || criteriaType === 'quantity') && (
{rule.criteria.operator && rule.criteria.operator !== 'exists' && ( { const key = criteriaType === 'property' ? 'propertyValue' : 'quantityValue'; onChange({ criteria: { ...rule.criteria, [key]: e.target.value } }); }} placeholder="Value..." className={cn(inputClass, 'flex-1 min-w-0')} /> )}
)} {/* Action selector for simple types */} {criteriaType !== 'property' && criteriaType !== 'quantity' && (
{criteriaType === 'attribute' && ( )}
)}
); } // ─── Lens editor (create/edit mode) ───────────────────────────────────────── function LensEditor({ initial, onSave, onCancel, discovered, onRequestDiscovery, }: { initial: Lens; onSave: (lens: Lens) => void; onCancel: () => void; discovered: DiscoveredLensData | null; onRequestDiscovery: (categories: { properties?: boolean; quantities?: boolean; classifications?: boolean; materials?: boolean }) => void; }) { const [name, setName] = useState(initial.name); const [rules, setRules] = useState(() => initial.rules.map(r => ({ ...r })), ); const addRule = () => { const colorIndex = rules.length % LENS_PALETTE.length; setRules([...rules, { id: `rule-${Date.now()}-${rules.length}`, name: 'New Rule', enabled: true, criteria: { type: 'ifcType', ifcType: '' }, action: 'colorize', color: LENS_PALETTE[colorIndex], }]); }; const updateRule = (index: number, patch: Partial) => { setRules(rules.map((r, i) => i === index ? { ...r, ...patch } : r)); }; const removeRule = (index: number) => { setRules(rules.filter((_, i) => i !== index)); }; /** Check if a rule has sufficient criteria to be valid */ const isRuleValid = (r: LensRule): boolean => { const c = r.criteria; switch (c.type) { case 'ifcType': return !!c.ifcType; case 'attribute': return !!c.attributeName; case 'property': return !!c.propertySet && !!c.propertyName; case 'quantity': return !!c.quantitySet && !!c.quantityName; case 'classification': return !!c.classificationSystem || !!c.classificationCode; case 'material': return !!c.materialName; case 'model': return !!c.modelId; // A blank group name is valid — it matches any entity assigned to a zone. case 'group': return true; default: return false; } }; const handleSave = () => { const validRules = rules.filter(isRuleValid); if (!name.trim() || validRules.length === 0) return; onSave({ ...initial, name: name.trim(), rules: validRules }); }; const canSave = name.trim().length > 0 && rules.some(isRuleValid); return (
{/* Name input */}
setName(e.target.value)} placeholder="Lens name..." className="w-full px-2 py-1.5 text-xs font-bold uppercase tracking-wider bg-zinc-50 dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-600 text-zinc-900 dark:text-zinc-100 rounded-sm placeholder:normal-case placeholder:font-normal placeholder:text-zinc-400 dark:placeholder:text-zinc-500" autoFocus />
{/* Rules */}
{rules.map((rule, i) => ( updateRule(i, patch)} onRemove={() => removeRule(i)} discovered={discovered} onRequestDiscovery={onRequestDiscovery} /> ))}
{/* Actions */}
); } // ─── Auto-color lens editor ───────────────────────────────────────────────── function AutoColorEditor({ initial, onSave, onCancel, discovered, onRequestDiscovery, }: { initial: { name: string; autoColor: AutoColorSpec }; onSave: (lens: Lens) => void; onCancel: () => void; discovered: DiscoveredLensData | null; onRequestDiscovery: (categories: { properties?: boolean; quantities?: boolean; classifications?: boolean; materials?: boolean }) => void; }) { const [name, setName] = useState(initial.name); const [source, setSource] = useState(initial.autoColor.source); const [psetName, setPsetName] = useState(initial.autoColor.psetName ?? ''); const [propertyName, setPropertyName] = useState(initial.autoColor.propertyName ?? ''); const needsPset = source === 'property' || source === 'quantity' || source === 'classification'; const needsPropertyName = source === 'attribute' || source === 'property' || source === 'quantity'; // Trigger lazy discovery when source changes to a category that needs it useEffect(() => { if (!discovered) return; if (source === 'property' && !discovered.propertySets) { onRequestDiscovery({ properties: true }); } else if (source === 'quantity' && !discovered.quantitySets) { onRequestDiscovery({ quantities: true }); } else if (source === 'material' && !discovered.materials) { onRequestDiscovery({ materials: true }); } else if (source === 'classification' && !discovered.classificationSystems) { onRequestDiscovery({ classifications: true }); } }, [source, discovered, onRequestDiscovery]); // Dynamic options from discovered data const psetOptions = useMemo(() => { if (!discovered) return []; if (source === 'quantity') return discovered.quantitySets ? Array.from(discovered.quantitySets.keys()).sort() : []; if (source === 'classification') return discovered.classificationSystems ?? []; return discovered.propertySets ? Array.from(discovered.propertySets.keys()).sort() : []; }, [discovered, source]); const propertyOptions = useMemo(() => { if (!discovered) return []; if (source === 'property') return discovered.propertySets?.get(psetName) ?? []; if (source === 'quantity') return discovered.quantitySets?.get(psetName) ?? []; return []; }, [discovered, source, psetName]); const handleSave = () => { if (!name.trim()) return; if (needsPset && !psetName.trim()) return; if (needsPropertyName && !propertyName.trim()) return; const autoColor: AutoColorSpec = { source }; if (needsPset) autoColor.psetName = psetName.trim(); if (needsPropertyName) autoColor.propertyName = propertyName.trim(); onSave({ id: `lens-auto-${Date.now()}`, name: name.trim(), rules: [], autoColor, }); }; const canSave = name.trim().length > 0 && (!needsPset || psetName.trim().length > 0) && (!needsPropertyName || propertyName.trim().length > 0); const selectClass = 'text-xs px-1.5 py-1 bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-600 text-zinc-900 dark:text-zinc-100 rounded-sm'; return (
setName(e.target.value)} placeholder="Auto-color lens name..." className="w-full px-2 py-1.5 text-xs font-bold uppercase tracking-wider bg-zinc-50 dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-600 text-zinc-900 dark:text-zinc-100 rounded-sm placeholder:normal-case placeholder:font-normal placeholder:text-zinc-400 dark:placeholder:text-zinc-500" autoFocus />
Auto-color by distinct values
{needsPset && (
{ setPsetName(v); setPropertyName(''); }} placeholder={source === 'property' ? 'Select property set...' : source === 'classification' ? 'Select system...' : 'Select quantity set...'} className="flex-1" />
)} {needsPropertyName && (
{source === 'attribute' ? ( ) : ( )}
)}
); } // ─── Lens card (read-only display) ────────────────────────────────────────── function LensCard({ lens, isActive, onToggle, onEdit, onDelete, isolatedRuleId, onIsolateRule, ruleCounts, autoColorLegend, }: { lens: Lens; isActive: boolean; onToggle: (id: string) => void; onEdit?: (lens: Lens) => void; onDelete?: (id: string) => void; isolatedRuleId?: string | null; onIsolateRule?: (ruleId: string) => void; ruleCounts?: Map; autoColorLegend?: AutoColorLegendEntry[]; }) { const isAutoColor = !!lens.autoColor; const enabledRuleCount = lens.rules.filter(r => r.enabled).length; const [legendSort, setLegendSort] = useState<'count' | 'name-asc' | 'name-desc'>('count'); const legendToShow = useMemo(() => { if (!isAutoColor || !autoColorLegend) return undefined; if (legendSort === 'count') return autoColorLegend; // already sorted by count desc from engine const sorted = [...autoColorLegend]; if (legendSort === 'name-asc') sorted.sort((a, b) => a.name.localeCompare(b.name)); else sorted.sort((a, b) => b.name.localeCompare(a.name)); return sorted; }, [isAutoColor, autoColorLegend, legendSort]); const cycleLegendSort = useCallback((e: React.MouseEvent) => { e.stopPropagation(); setLegendSort(prev => prev === 'count' ? 'name-asc' : prev === 'name-asc' ? 'name-desc' : 'count'); }, []); const sortLabel = legendSort === 'count' ? 'Count' : legendSort === 'name-asc' ? 'A→Z' : 'Z→A'; return (
onToggle(lens.id)} > {/* Header */}
{isActive ? ( ) : isAutoColor ? ( ) : ( )} {lens.name}
{onEdit && !isAutoColor && ( )} {!lens.builtin && onDelete && ( )} {isAutoColor ? TYPE_LABELS[lens.autoColor!.source] : `${enabledRuleCount} rules`}
{/* Auto-color legend (shown when active + auto-color lens) */} {isActive && legendToShow && legendToShow.length > 0 && (
{legendToShow.length} values
{legendToShow.map(entry => ( onIsolateRule(entry.id) : undefined} /> ))}
)} {/* Rule-based color legend (shown when active + rule lens) */} {isActive && !isAutoColor && (
{lens.rules.map(rule => { const count = ruleCounts?.get(rule.id) ?? 0; return ( onIsolateRule(rule.id) : undefined} /> ); })}
)}
); } // ─── Main panel ───────────────────────────────────────────────────────────── export function LensPanel({ onClose }: LensPanelProps) { const { activeLensId, savedLenses } = useLens(); const setActiveLens = useViewerStore((s) => s.setActiveLens); const createLens = useViewerStore((s) => s.createLens); const updateLens = useViewerStore((s) => s.updateLens); const deleteLens = useViewerStore((s) => s.deleteLens); const importLenses = useViewerStore((s) => s.importLenses); const exportLenses = useViewerStore((s) => s.exportLenses); const hideEntities = useViewerStore((s) => s.hideEntities); const showAll = useViewerStore((s) => s.showAll); const isolateEntities = useViewerStore((s) => s.isolateEntities); const clearIsolation = useViewerStore((s) => s.clearIsolation); // For footer stats — cheap primitive subscriptions const lensColorMapSize = useViewerStore((s) => s.lensColorMap.size); const lensHiddenIdsSize = useViewerStore((s) => s.lensHiddenIds.size); const lensRuleCounts = useViewerStore((s) => s.lensRuleCounts); const lensAutoColorLegend = useViewerStore((s) => s.lensAutoColorLegend); // Discovered data from loaded models (classes = instant, rest = lazy) const discoveredLensData = useViewerStore((s) => s.discoveredLensData); const mergeDiscoveredData = useViewerStore((s) => s.mergeDiscoveredData); // Track which categories are currently being discovered (prevent double-fire) const discoveringRef = useRef(new Set()); // Reset discovery flags when discoveredLensData changes (e.g. new model loaded) useEffect(() => { if (!discoveredLensData) { discoveringRef.current.clear(); } }, [discoveredLensData]); /** Trigger lazy discovery for expensive data categories (psets, quantities, etc.) */ const handleRequestDiscovery = useCallback((categories: { properties?: boolean; quantities?: boolean; classifications?: boolean; materials?: boolean }) => { // Skip categories already discovered or in-flight const toDiscover: typeof categories = {}; const current = useViewerStore.getState().discoveredLensData; if (!current) return; if (categories.properties && !current.propertySets && !discoveringRef.current.has('properties')) { toDiscover.properties = true; discoveringRef.current.add('properties'); } if (categories.quantities && !current.quantitySets && !discoveringRef.current.has('quantities')) { toDiscover.quantities = true; discoveringRef.current.add('quantities'); } if (categories.classifications && !current.classificationSystems && !discoveringRef.current.has('classifications')) { toDiscover.classifications = true; discoveringRef.current.add('classifications'); } if (categories.materials && !current.materials && !discoveringRef.current.has('materials')) { toDiscover.materials = true; discoveringRef.current.add('materials'); } if (Object.keys(toDiscover).length === 0) return; // Run discovery async to not block the UI setTimeout(() => { const { models, ifcDataStore } = useViewerStore.getState(); if (models.size === 0 && !ifcDataStore) return; const provider = createLensDataProvider(models, ifcDataStore); const result = discoverDataSources(provider, toDiscover); mergeDiscoveredData(result); }, 0); }, [mergeDiscoveredData]); // Editor state: null = not editing, Lens object = editing/creating const [editingLens, setEditingLens] = useState(null); const [creatingAutoColor, setCreatingAutoColor] = useState(false); const [isolatedRuleId, setIsolatedRuleId] = useState(null); const fileInputRef = useRef(null); const handleToggle = useCallback((id: string) => { setIsolatedRuleId(null); if (activeLensId === id) { setActiveLens(null); showAll(); } else { setActiveLens(id); } }, [activeLensId, setActiveLens, showAll]); /** Click a rule/value row in the active lens to isolate matching entities */ const handleIsolateRule = useCallback((ruleId: string) => { // Toggle off if clicking the already-isolated rule if (isolatedRuleId === ruleId) { setIsolatedRuleId(null); clearIsolation(); return; } // Look up entities matched by this specific rule/value const matchingIds = useViewerStore.getState().lensRuleEntityIds.get(ruleId); if (!matchingIds || matchingIds.length === 0) return; setIsolatedRuleId(ruleId); isolateEntities(matchingIds); }, [isolatedRuleId, isolateEntities, clearIsolation]); const handleNewLens = useCallback(() => { setCreatingAutoColor(false); setEditingLens({ id: `lens-${Date.now()}`, name: '', rules: [], }); }, []); const handleNewAutoColorLens = useCallback(() => { setEditingLens(null); setCreatingAutoColor(true); }, []); const handleEditLens = useCallback((lens: Lens) => { setEditingLens({ ...lens, rules: lens.rules.map(r => ({ ...r })) }); }, []); const handleSaveLens = useCallback((lens: Lens) => { const exists = savedLenses.some(l => l.id === lens.id); if (exists) { updateLens(lens.id, { name: lens.name, rules: lens.rules, autoColor: lens.autoColor }); } else { createLens(lens); } setEditingLens(null); setCreatingAutoColor(false); }, [savedLenses, createLens, updateLens]); const handleDeleteLens = useCallback((id: string) => { if (activeLensId === id) { setActiveLens(null); showAll(); } deleteLens(id); }, [activeLensId, setActiveLens, showAll, deleteLens]); // Apply hidden entities when lens hidden IDs change useEffect(() => { if (lensHiddenIdsSize > 0 && activeLensId) { const ids = useViewerStore.getState().lensHiddenIds; hideEntities(Array.from(ids)); } }, [activeLensId, lensHiddenIdsSize, hideEntities]); const handleExport = useCallback(() => { const data = exportLenses(); const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'lenses.json'; a.click(); URL.revokeObjectURL(url); }, [exportLenses]); const handleImport = useCallback((e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const parsed = JSON.parse(reader.result as string); const arr: unknown[] = Array.isArray(parsed) ? parsed : [parsed]; const valid = arr.filter((item): item is Lens => { if (item === null || typeof item !== 'object') return false; const obj = item as Record; return typeof obj.id === 'string' && obj.id.length > 0 && typeof obj.name === 'string' && obj.name.length > 0 && Array.isArray(obj.rules); }); if (valid.length > 0) { importLenses(valid); } } catch { // invalid JSON — silently ignore } }; reader.readAsText(file); e.target.value = ''; }, [importLenses]); return (
{/* Header */}

Lens

{activeLensId && ( )} {onClose && ( )}
{/* Lens list + editor */}
{savedLenses.map(lens => ( editingLens?.id === lens.id ? ( setEditingLens(null)} discovered={discoveredLensData} onRequestDiscovery={handleRequestDiscovery} /> ) : ( ) ))} {/* New lens editor (when creating rule-based lens) */} {editingLens && !savedLenses.some(l => l.id === editingLens.id) && ( setEditingLens(null)} discovered={discoveredLensData} onRequestDiscovery={handleRequestDiscovery} /> )} {/* Auto-color editor (when creating auto-color lens) */} {creatingAutoColor && ( setCreatingAutoColor(false)} discovered={discoveredLensData} onRequestDiscovery={handleRequestDiscovery} /> )} {/* New lens buttons */} {!editingLens && !creatingAutoColor && (
)}
{/* Status footer */}
{activeLensId ? `Active · ${lensColorMapSize} colored · ${lensHiddenIdsSize > 0 ? `${lensHiddenIdsSize} hidden` : 'ghosted'}` : 'Click a lens to activate'}
); }