/* 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/. */ /** * SearchModalFilter — chip-based structured-rule filtering. * * Owns the run lifecycle: assembles per-model arguments, folds the * inline search query into a Tier-1/Tier-0 candidate set when present, * runs the path-B evaluator (chunked + cancellable + progress), and * renders the result table. The chip-editing UI lives in * `SearchModalFilterBuilder`; that's a UI-only sibling that reads / * writes the same slice state. * * No DuckDB. No SQL editor. The path-B evaluator handles 4M-entity * models via `selectIterationSource` (byType / byStorey index * prefilter under AND + op:in), cheap-first per-entity rule ordering, * and async chunked yielding — so a single Run button is the whole * story. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { Play, AlertCircle, Download, ListPlus } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { useViewerStore } from '@/store'; import { toGlobalIdFromModels } from '@/store/globalId'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, } from '@/components/ui/dropdown-menu'; import { cn } from '@/lib/utils'; import { evaluateFilterRulesFederated } from '@/lib/search/filter-evaluate'; import { runTier0Scan, type ScanModel } from '@/lib/search/tier0-scan'; import { queryTier1Indexes, type Tier1Index } from '@/lib/search/tier1-index'; import { downloadResult } from '@/lib/search/result-export'; import type { ListDefinition } from '@/lib/lists'; import { SearchModalFilterBuilder } from './SearchModal.filter.builder'; /** Rows per virtualizer page — tuned for the result table row height. */ const RESULT_ROW_HEIGHT = 28; const TEXT_HIT_LIMIT = 50_000; const FILTER_CHUNK_SIZE = 20_000; const DEFAULT_LIMIT = 5_000; /** Columns we treat as "selection keys" — clicking a row routes the * value through the viewer's selection system. */ const SELECTION_COLUMNS = ['express_id', 'entity_id'] as const; export function SearchModalFilter() { const { searchFilter, searchFilterResult, searchFilterRunning, searchFilterError, searchQuery, searchIndexes, setSearchFilterRunning, setSearchFilterResult, setSearchFilterError, models, activeModelId, setSelectedEntity, setSelectedEntityId, cameraCallbacks, setPendingListDraft, setListPanelVisible, setSearchModalOpen, autoRunPending, setAutoRunPending, } = useViewerStore( useShallow((s) => ({ searchFilter: s.searchFilter, searchFilterResult: s.searchFilterResult, searchFilterRunning: s.searchFilterRunning, searchFilterError: s.searchFilterError, searchQuery: s.searchQuery, searchIndexes: s.searchIndexes, setSearchFilterRunning: s.setSearchFilterRunning, setSearchFilterResult: s.setSearchFilterResult, setSearchFilterError: s.setSearchFilterError, models: s.models, activeModelId: s.activeModelId, setSelectedEntity: s.setSelectedEntity, setSelectedEntityId: s.setSelectedEntityId, cameraCallbacks: s.cameraCallbacks, setPendingListDraft: s.setPendingListDraft, setListPanelVisible: s.setListPanelVisible, setSearchModalOpen: s.setSearchModalOpen, autoRunPending: s.searchFilterAutoRunPending, setAutoRunPending: s.setSearchFilterAutoRunPending, })), ); const activeModel = activeModelId ? models.get(activeModelId) : undefined; const activeStore = activeModel?.ifcDataStore ?? null; const multiModel = models.size > 1; // ── Run lifecycle: progress, cancel, limit-hit badge ────────────────── const runController = useRef(null); const [progress, setProgress] = useState<{ scanned: number; total: number } | null>(null); const [limitHit, setLimitHit] = useState(null); const runFilter = useCallback(async () => { if (searchFilterRunning) return; if (searchFilter.rules.length === 0) { setSearchFilterError('Add at least one rule before running.'); return; } runController.current?.abort(); const controller = new AbortController(); runController.current = controller; setSearchFilterRunning(true); setSearchFilterError(null); setLimitHit(null); setProgress({ scanned: 0, total: 0 }); const start = performance.now(); try { const modelArgs: Array<{ id: string; store: typeof activeStore }> = []; for (const m of models.values()) { if (m.ifcDataStore) modelArgs.push({ id: m.id, store: m.ifcDataStore }); } // Fold the inline search query in as a Tier-1/Tier-0 candidate // set when present. Empty query → no narrowing (full scan with // index prefilter applied inside the evaluator). const trimmedQuery = searchQuery.trim(); let candidatesByModel: Map> | undefined; if (trimmedQuery.length > 0) { const t0Models: ScanModel[] = []; const t1Indexes: Tier1Index[] = []; for (const m of modelArgs) { const rec = searchIndexes.get(m.id); if (rec?.status === 'ready' && rec.index) { t1Indexes.push(rec.index); } else { t0Models.push({ id: m.id, ifcDataStore: m.store }); } } const t1Hits = t1Indexes.length > 0 ? queryTier1Indexes(t1Indexes, trimmedQuery, { limit: TEXT_HIT_LIMIT }) : []; const t0Hits = t0Models.length > 0 ? runTier0Scan(t0Models, trimmedQuery, { limit: TEXT_HIT_LIMIT }) : []; const grouped = new Map>(); for (const hit of t1Hits.concat(t0Hits)) { let bucket = grouped.get(hit.modelId); if (!bucket) { bucket = new Set(); grouped.set(hit.modelId, bucket); } bucket.add(hit.expressId); } candidatesByModel = new Map(); for (const [id, set] of grouped) candidatesByModel.set(id, set); for (const m of modelArgs) { // Models with no text hits get an empty candidate so structured // rules can't slip through under intersection semantics. if (!candidatesByModel.has(m.id)) candidatesByModel.set(m.id, []); } } const limit = searchFilter.limit > 0 ? searchFilter.limit : DEFAULT_LIMIT; const matched = await evaluateFilterRulesFederated( modelArgs, searchFilter.rules, searchFilter.combinator, { limit, chunkSize: FILTER_CHUNK_SIZE, candidateExpressIdsByModel: candidatesByModel, signal: controller.signal, onProgress: (scanned, total) => setProgress({ scanned, total }), }, ); const multi = modelArgs.length > 1; const columns = multi ? ['express_id', 'global_id', 'name', 'type', 'model_id'] : ['express_id', 'global_id', 'name', 'type']; const rows: unknown[][] = matched.map((m) => multi ? [m.expressId, m.globalId, m.name, m.ifcType, m.modelId] : [m.expressId, m.globalId, m.name, m.ifcType], ); setSearchFilterResult({ columns, rows, runMs: Math.round(performance.now() - start), }); if (matched.length >= limit) setLimitHit(limit); } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return; setSearchFilterError(err instanceof Error ? err.message : String(err)); } finally { if (runController.current === controller) { runController.current = null; setSearchFilterRunning(false); setProgress(null); } } }, [ models, searchFilter, searchFilterRunning, searchIndexes, searchQuery, setSearchFilterError, setSearchFilterResult, setSearchFilterRunning, ]); const cancelFilter = useCallback(() => { runController.current?.abort(); }, []); // Auto-run when the Filter was populated from outside the modal (a // Hierarchy node click arms `searchFilterAutoRunPending`). Because the // panel only mounts when the modal is open, the flag survives a closed // modal and fires on the next open — so the user sees results without // pressing Run. Clear the flag first so a slow run can't re-trigger. useEffect(() => { if (!autoRunPending) return; setAutoRunPending(false); if (searchFilter.rules.length === 0) { // Hierarchy cleared the last rule — drop the stale table rather than // run an empty filter (which the runner rejects anyway). setSearchFilterResult(null); } else if (!searchFilterRunning) { void runFilter(); } }, [ autoRunPending, searchFilter.rules.length, searchFilterRunning, setAutoRunPending, setSearchFilterResult, runFilter, ]); // Cancel any in-flight run when the modal unmounts so background // chunked work doesn't keep ticking after close. useEffect(() => () => { runController.current?.abort(); }, []); // Locate the model_id column (only present in federated runs) — same // routing rule as before: known column → use that model's id space. const modelIdColumnIndex = useMemo(() => { const cols = searchFilterResult?.columns; if (!cols) return -1; return cols.indexOf('model_id'); }, [searchFilterResult]); const selectionKeyIndex = useMemo(() => { const cols = searchFilterResult?.columns; if (!cols) return -1; for (const candidate of SELECTION_COLUMNS) { const i = cols.indexOf(candidate); if (i >= 0) return i; } return -1; }, [searchFilterResult]); const handleRowClick = useCallback((row: unknown[]) => { if (selectionKeyIndex < 0) return; const rowModelId = modelIdColumnIndex >= 0 && typeof row[modelIdColumnIndex] === 'string' ? (row[modelIdColumnIndex] as string) : activeModelId; if (!rowModelId) return; const raw = row[selectionKeyIndex]; const expressId = typeof raw === 'number' ? raw : typeof raw === 'string' && !Number.isNaN(Number(raw)) ? Number(raw) : null; if (expressId === null || expressId <= 0) return; const globalId = toGlobalIdFromModels(models, rowModelId, expressId); setSelectedEntityId(globalId); setSelectedEntity({ modelId: rowModelId, expressId }); if (cameraCallbacks.frameSelection) { window.setTimeout(() => cameraCallbacks.frameSelection?.(), 50); } }, [activeModelId, cameraCallbacks, models, modelIdColumnIndex, selectionKeyIndex, setSelectedEntity, setSelectedEntityId]); const handleExport = useCallback((format: 'csv' | 'json') => { if (!searchFilterResult || searchFilterResult.rows.length === 0) return; downloadResult(searchFilterResult, format); }, [searchFilterResult]); /** Freeze the current filter result into a new list — a per-model snapshot * of the matched express IDs — and open the list builder to configure * columns. Keyed by model so federated results don't over-select when * local express IDs collide across files. */ const handleCreateList = useCallback(() => { const result = searchFilterResult; if (!result || result.rows.length === 0) return; const idIdx = result.columns.indexOf('express_id'); if (idIdx < 0) return; const modelIdx = result.columns.indexOf('model_id'); // only present for multi-model runs const byModel: Record = {}; const seen = new Set(); for (const row of result.rows) { const id = Number(row[idIdx]); if (!Number.isFinite(id) || id <= 0) continue; const modelId = modelIdx >= 0 && typeof row[modelIdx] === 'string' ? (row[modelIdx] as string) : (activeModelId ?? 'default'); const key = `${modelId}:${id}`; if (seen.has(key)) continue; seen.add(key); (byModel[modelId] ??= []).push(id); } const total = Object.values(byModel).reduce((n, ids) => n + ids.length, 0); if (total === 0) return; const now = Date.now(); const draft: ListDefinition = { id: crypto.randomUUID(), name: 'Filter result', createdAt: now, updatedAt: now, entityTypes: [], expressIdsByModel: byModel, conditions: [], columns: [ { id: 'attr-name', source: 'attribute', propertyName: 'Name', label: 'Name' }, { id: 'attr-class', source: 'attribute', propertyName: 'Class', label: 'Class' }, ], }; setPendingListDraft(draft); setListPanelVisible(true); setSearchModalOpen(false); }, [searchFilterResult, activeModelId, setPendingListDraft, setListPanelVisible, setSearchModalOpen]); if (!activeStore) { return (
Load an IFC file first — the filter runs against the active model's data.
); } const canRun = searchFilter.rules.length > 0; return (
{/* ── Builder (chip palette) ─────────────────────────────────────── */}
{/* ── Run bar: status · run/cancel · export ──────────────────────── */}
{progress && progress.total > 0 && ( {progress.scanned.toLocaleString()} / {progress.total.toLocaleString()} )} {progress && progress.total <= 0 && ( scanned {progress.scanned.toLocaleString()} )} {!searchFilterRunning && limitHit !== null && ( limited to {limitHit.toLocaleString()} )} {searchFilterResult && !searchFilterRunning && ( ⏱ {searchFilterResult.runMs} ms · {searchFilterResult.rows.length.toLocaleString()} rows )}
handleExport('csv')}> Download CSV handleExport('json')}> Download JSON {searchFilterRunning ? ( ) : ( )}
{multiModel && (
Filtering across all {models.size} loaded models. Click any row to select that element in the right model.
)} {/* ── Result area: error stacks above the last good table ────────── */} {searchFilterError && }
); } // ── Sub-components ──────────────────────────────────────────────────── function RuleSummary({ ruleCount, combinator, limit, }: { ruleCount: number; combinator: 'AND' | 'OR'; limit: number; }) { if (ruleCount === 0) { return ( No rules — add one to run. ); } return ( {ruleCount}{' '} rule{ruleCount === 1 ? '' : 's'} · {combinator} · limit{' '} {limit > 0 ? limit.toLocaleString() : '∞'} ); } function FilterErrorBox({ raw }: { raw: string }) { return (
Filter failed
{raw}
); } interface FilterResultTableProps { result: { columns: string[]; rows: unknown[][] } | null; selectionKeyIndex: number; onRowClick: (row: unknown[]) => void; } function FilterResultTable({ result, selectionKeyIndex, onRowClick }: FilterResultTableProps) { const scrollRef = useRef(null); const virtualizer = useVirtualizer({ count: result?.rows.length ?? 0, getScrollElement: () => scrollRef.current, estimateSize: () => RESULT_ROW_HEIGHT, overscan: 20, }); if (!result) { return (
Add rules and click Run.
); } if (result.rows.length === 0) { return (
0 matches — broaden the rules, lower the limit, or try OR.
); } return (
{result.columns.map((c) => (
{c}
))}
{virtualizer.getVirtualItems().map((vRow) => { const row = result.rows[vRow.index]; const clickable = selectionKeyIndex >= 0; return (
clickable && onRowClick(row)} > {result.columns.map((_, i) => (
{formatCell(row[i])}
))}
); })}
); } function formatCell(v: unknown): string { if (v === null || v === undefined) return ''; if (typeof v === 'boolean') return v ? 'true' : 'false'; if (typeof v === 'bigint') return v.toString(); if (typeof v === 'object') return JSON.stringify(v); return String(v); }