@ifc-lite/viewer 1.26.0 → 1.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +38 -31
- package/CHANGELOG.md +29 -0
- package/dist/assets/{basketViewActivator-ZpTYWE3K.js → basketViewActivator-B3CdrLsb.js} +7 -7
- package/dist/assets/{bcf-Ctcu_Sc2.js → bcf-QeHK_Aud.js} +1 -1
- package/dist/assets/{browser-DXS29_v9.js → browser-BIoDDfBW.js} +1 -1
- package/dist/assets/{cesium-BoVuJvTC.js → cesium-CzZn5yVA.js} +319 -319
- package/dist/assets/{deflate-Cnx0il6E.js → deflate-B-d0SYQM.js} +1 -1
- package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
- package/dist/assets/{exporters-DSq76AVM.js → exporters-B4LbZFeT.js} +1422 -1194
- package/dist/assets/geometry.worker-BdH-E6NB.js +1 -0
- package/dist/assets/{geotiff-A5UjhI6L.js → geotiff-CrVtDRFq.js} +10 -10
- package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
- package/dist/assets/{ids-DiLcGTer.js → ids-DjsGFN10.js} +4 -4
- package/dist/assets/ifc-lite_bg-DsYUIHm3.wasm +0 -0
- package/dist/assets/{index-BAH8IJVR.js → index-COYokSKc.js} +38319 -35469
- package/dist/assets/index-ajK6D32J.css +1 -0
- package/dist/assets/index.es-CY202jA3.js +6866 -0
- package/dist/assets/{jpeg-BzSkwo5D.js → jpeg-D4wOkf5h.js} +1 -1
- package/dist/assets/jspdf.es.min-DIGb9BHN.js +19571 -0
- package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
- package/dist/assets/{lerc-Cg2Rz-D5.js → lerc-DmW0_tgf.js} +1 -1
- package/dist/assets/{lzw-BBPPLW-0.js → lzw-oWetY-d6.js} +1 -1
- package/dist/assets/{maplibre-gl-Do6O5tDc.js → maplibre-gl-BF3Z0idw.js} +1 -1
- package/dist/assets/{native-bridge-CPojOeGE.js → native-bridge-BX8_tHXE.js} +1 -1
- package/dist/assets/{packbits-yLSpjW-V.js → packbits-F8Nkp4NY.js} +1 -1
- package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
- package/dist/assets/{parser.worker-8md211IW.js → parser.worker-D591Zu_-.js} +3 -3
- package/dist/assets/pdf-Dsh3HPZB.js +135 -0
- package/dist/assets/raw-D9iw0tmc.js +1 -0
- package/dist/assets/{sandbox-CsRXlgCO.js → sandbox-BAC3a-eN.js} +1735 -1660
- package/dist/assets/server-client-Cjwnm7il.js +706 -0
- package/dist/assets/{webimage-YafxjjGr.js → webimage-BLV1dgmd.js} +1 -1
- package/dist/assets/xlsx-Bc2HTrjC.js +142 -0
- package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
- package/dist/assets/{zstd-CkSLOiuu.js → zstd-C_1HxVrA.js} +1 -1
- package/dist/index.html +8 -8
- package/package.json +10 -7
- package/src/components/mcp/PlaygroundChat.tsx +1 -0
- package/src/components/mcp/data.ts +6 -0
- package/src/components/mcp/playground-dispatcher.ts +277 -0
- package/src/components/mcp/types.ts +2 -1
- package/src/components/ui/combo-input.tsx +163 -0
- package/src/components/ui/tabs.tsx +1 -1
- package/src/components/viewer/PropertiesPanel.tsx +13 -6
- package/src/components/viewer/SearchInline.tsx +62 -2
- package/src/components/viewer/SearchModal.filter.builder.tsx +24 -393
- package/src/components/viewer/SearchModal.filter.editors.tsx +503 -0
- package/src/components/viewer/SearchModal.filter.tsx +64 -1
- package/src/components/viewer/SearchModal.tsx +19 -6
- package/src/components/viewer/Viewport.tsx +15 -0
- package/src/components/viewer/lists/ColumnHeaderMenu.tsx +84 -0
- package/src/components/viewer/lists/ListBuilder.tsx +789 -280
- package/src/components/viewer/lists/ListGroupingBar.tsx +72 -0
- package/src/components/viewer/lists/ListPanel.tsx +49 -5
- package/src/components/viewer/lists/ListResultsTable.tsx +270 -176
- package/src/components/viewer/lists/list-table-utils.ts +123 -0
- package/src/generated/mcp-catalog.json +4 -0
- package/src/hooks/source-key.ts +35 -0
- package/src/hooks/useAlignmentLines3D.ts +1 -26
- package/src/hooks/useGridLines3D.ts +140 -0
- package/src/lib/length-unit-scale.ts +41 -0
- package/src/lib/lists/adapter.ts +136 -11
- package/src/lib/lists/export/csv.ts +47 -0
- package/src/lib/lists/export/index.ts +49 -0
- package/src/lib/lists/export/model.ts +111 -0
- package/src/lib/lists/export/pdf.ts +67 -0
- package/src/lib/lists/export/xlsx.ts +83 -0
- package/src/lib/lists/index.ts +2 -0
- package/src/lib/search/filter-evaluate.test.ts +81 -0
- package/src/lib/search/filter-evaluate.ts +59 -87
- package/src/lib/search/filter-match.ts +167 -0
- package/src/lib/search/filter-rules.test.ts +25 -0
- package/src/lib/search/filter-rules.ts +75 -2
- package/src/lib/search/filter-schema.ts +0 -0
- package/src/lib/slab-edit.test.ts +72 -0
- package/src/lib/slab-edit.ts +159 -19
- package/src/sdk/adapters/export-adapter.ts +3 -3
- package/src/sdk/adapters/query-adapter.ts +3 -3
- package/src/store/slices/listSlice.ts +6 -0
- package/src/store/slices/mutationSlice.ts +14 -6
- package/src/store/slices/searchSlice.ts +29 -3
- package/src/utils/nativeSpatialDataStore.ts +6 -0
- package/src/utils/serverDataModel.test.ts +6 -0
- package/src/utils/serverDataModel.ts +7 -0
- package/dist/assets/geometry.worker-0Q9qEa6p.js +0 -1
- package/dist/assets/ifc-lite_bg-CEZnhM2e.wasm +0 -0
- package/dist/assets/index-B9Ug2EqU.css +0 -1
- package/dist/assets/raw-BQrAgxwT.js +0 -1
- package/dist/assets/server-client-Bk4c1CPO.js +0 -626
|
@@ -3,46 +3,63 @@
|
|
|
3
3
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* ListResultsTable
|
|
6
|
+
* ListResultsTable — virtualized results grid with in-table grouping &
|
|
7
|
+
* aggregation. The column header is the control surface (sort · group · sum
|
|
8
|
+
* · colour) and every action writes back to the ListDefinition, so the table
|
|
9
|
+
* and the list settings stay in sync. Columns are drag-resizable.
|
|
7
10
|
*
|
|
8
|
-
* PERF:
|
|
9
|
-
*
|
|
10
|
-
* Clicking a row selects the entity in the 3D viewer.
|
|
11
|
+
* PERF: @tanstack/react-virtual renders only visible items (group headers +
|
|
12
|
+
* rows), so 100K+ rows stay smooth.
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
|
14
16
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
15
|
-
import { ArrowUp, ArrowDown, Search,
|
|
17
|
+
import { ArrowUp, ArrowDown, Search, Eye, EyeOff, Download, ChevronRight, ChevronDown, FileText, FileSpreadsheet, FileType } from 'lucide-react';
|
|
16
18
|
import { Input } from '@/components/ui/input';
|
|
17
19
|
import { Button } from '@/components/ui/button';
|
|
18
20
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
21
|
+
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
|
19
22
|
import { useViewerStore } from '@/store';
|
|
20
23
|
import { getVisibleBasketEntityRefsFromStore } from '@/store/basketVisibleSet';
|
|
21
|
-
import type { ListResult, ListRow,
|
|
22
|
-
import {
|
|
24
|
+
import type { ListResult, ListRow, ColumnDefinition, ListGrouping } from '@ifc-lite/lists';
|
|
25
|
+
import { exportList, buildExportModel, EXPORT_LABELS, type ExportFormat } from '@/lib/lists/export';
|
|
23
26
|
import { cn } from '@/lib/utils';
|
|
24
27
|
import { columnToAutoColor } from '@/lib/lists/columnToAutoColor';
|
|
25
28
|
import { AUTO_COLOR_FROM_LIST_ID } from '@/store/slices/lensSlice';
|
|
29
|
+
import { ColumnHeaderMenu } from './ColumnHeaderMenu';
|
|
30
|
+
import { ListGroupingBar } from './ListGroupingBar';
|
|
31
|
+
import {
|
|
32
|
+
formatCellValue, compareCells, detectNumericColumns, autoColumnWidth,
|
|
33
|
+
buildGroupedView, flatTotals, type DisplayItem, type Totals,
|
|
34
|
+
} from './list-table-utils';
|
|
26
35
|
|
|
27
36
|
interface ListResultsTableProps {
|
|
28
37
|
result: ListResult;
|
|
38
|
+
/** List name — used as the export title / filename. */
|
|
39
|
+
listName?: string;
|
|
40
|
+
/** Active grouping from the executed definition (table ↔ settings sync). */
|
|
41
|
+
grouping?: ListGrouping;
|
|
42
|
+
/** Persist a grouping change made from the table back to the definition. */
|
|
43
|
+
onGroupingChange?: (grouping: ListGrouping | undefined) => void;
|
|
29
44
|
}
|
|
30
45
|
|
|
31
|
-
export function ListResultsTable({ result }: ListResultsTableProps) {
|
|
46
|
+
export function ListResultsTable({ result, listName, grouping, onGroupingChange }: ListResultsTableProps) {
|
|
32
47
|
const parentRef = useRef<HTMLDivElement>(null);
|
|
33
48
|
const [searchQuery, setSearchQuery] = useState('');
|
|
34
49
|
const [sortCol, setSortCol] = useState<number | null>(null);
|
|
35
50
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
|
36
51
|
const [filterByVisibility, setFilterByVisibility] = useState(true);
|
|
52
|
+
const [colorByColIdx, setColorByColIdx] = useState<number | null>(null);
|
|
53
|
+
const [widthOverrides, setWidthOverrides] = useState<Record<string, number>>({});
|
|
54
|
+
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
|
37
55
|
|
|
38
56
|
const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
|
|
39
57
|
const setSelectedEntity = useViewerStore((s) => s.setSelectedEntity);
|
|
40
58
|
const selectedEntityId = useViewerStore((s) => s.selectedEntityId);
|
|
41
59
|
const activateAutoColorFromColumn = useViewerStore((s) => s.activateAutoColorFromColumn);
|
|
42
60
|
const activeLensId = useViewerStore((s) => s.activeLensId);
|
|
43
|
-
const [colorByColIdx, setColorByColIdx] = useState<number | null>(null);
|
|
44
61
|
|
|
45
|
-
//
|
|
62
|
+
// Visibility state — re-filter when 3D visibility changes.
|
|
46
63
|
const hiddenEntities = useViewerStore((s) => s.hiddenEntities);
|
|
47
64
|
const isolatedEntities = useViewerStore((s) => s.isolatedEntities);
|
|
48
65
|
const classFilter = useViewerStore((s) => s.classFilter);
|
|
@@ -55,116 +72,164 @@ export function ListResultsTable({ result }: ListResultsTableProps) {
|
|
|
55
72
|
const activeBasketViewId = useViewerStore((s) => s.activeBasketViewId);
|
|
56
73
|
const geometryResult = useViewerStore((s) => s.geometryResult);
|
|
57
74
|
|
|
58
|
-
|
|
75
|
+
const columns = result.columns;
|
|
76
|
+
const numericCols = useMemo(() => detectNumericColumns(columns, result.rows), [columns, result.rows]);
|
|
77
|
+
|
|
59
78
|
const visibilityFilteredRows = useMemo(() => {
|
|
60
79
|
if (!filterByVisibility) return result.rows;
|
|
61
|
-
|
|
62
|
-
const visibleRefs = getVisibleBasketEntityRefsFromStore();
|
|
63
80
|
const visibleSet = new Set<string>();
|
|
64
|
-
for (const ref of
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return result.rows.filter(row => {
|
|
69
|
-
// List uses 'default' for single-model, visibility uses 'legacy'
|
|
81
|
+
for (const ref of getVisibleBasketEntityRefsFromStore()) visibleSet.add(`${ref.modelId}:${ref.expressId}`);
|
|
82
|
+
return result.rows.filter((row) => {
|
|
70
83
|
const modelId = row.modelId === 'default' ? 'legacy' : row.modelId;
|
|
71
84
|
return visibleSet.has(`${modelId}:${row.entityId}`);
|
|
72
85
|
});
|
|
73
86
|
}, [
|
|
74
|
-
result.rows, filterByVisibility,
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
isolatedEntitiesByModel, models, activeBasketViewId, geometryResult,
|
|
87
|
+
result.rows, filterByVisibility, hiddenEntities, isolatedEntities, classFilter, lensHiddenIds,
|
|
88
|
+
selectedStoreys, typeVisibility, hiddenEntitiesByModel, isolatedEntitiesByModel, models,
|
|
89
|
+
activeBasketViewId, geometryResult,
|
|
78
90
|
]);
|
|
79
91
|
|
|
80
|
-
// Filter rows by search query
|
|
81
92
|
const filteredRows = useMemo(() => {
|
|
82
93
|
if (!searchQuery) return visibilityFilteredRows;
|
|
83
94
|
const q = searchQuery.toLowerCase();
|
|
84
|
-
return visibilityFilteredRows.filter(row =>
|
|
85
|
-
row.values.some(v => v !== null && String(v).toLowerCase().includes(q))
|
|
86
|
-
);
|
|
95
|
+
return visibilityFilteredRows.filter((row) =>
|
|
96
|
+
row.values.some((v) => v !== null && String(v).toLowerCase().includes(q)));
|
|
87
97
|
}, [visibilityFilteredRows, searchQuery]);
|
|
88
98
|
|
|
89
|
-
// Sort rows
|
|
90
99
|
const sortedRows = useMemo(() => {
|
|
91
100
|
if (sortCol === null) return filteredRows;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const va = a.values[sortCol];
|
|
95
|
-
const vb = b.values[sortCol];
|
|
96
|
-
return compareCells(va, vb) * (sortDir === 'asc' ? 1 : -1);
|
|
97
|
-
});
|
|
98
|
-
return sorted;
|
|
101
|
+
return [...filteredRows].sort((a, b) =>
|
|
102
|
+
compareCells(a.values[sortCol], b.values[sortCol]) * (sortDir === 'asc' ? 1 : -1));
|
|
99
103
|
}, [filteredRows, sortCol, sortDir]);
|
|
100
104
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
// ── Grouping / aggregation derived from the definition ──
|
|
106
|
+
const groupByColumnId = grouping?.columnId ?? '';
|
|
107
|
+
const sumColumnIds = useMemo(() => grouping?.sumColumnIds ?? [], [grouping]);
|
|
108
|
+
const isGrouped = groupByColumnId !== '' && columns.some((c) => c.id === groupByColumnId);
|
|
109
|
+
const groupColLabel = useMemo(() => {
|
|
110
|
+
const c = columns.find((c) => c.id === groupByColumnId);
|
|
111
|
+
return c ? (c.label ?? c.propertyName) : null;
|
|
112
|
+
}, [columns, groupByColumnId]);
|
|
113
|
+
|
|
114
|
+
const { items, groupCount, totals, groupKeys } = useMemo<{
|
|
115
|
+
items: DisplayItem[]; groupCount: number; totals: Totals; groupKeys: string[];
|
|
116
|
+
}>(() => {
|
|
117
|
+
if (isGrouped) {
|
|
118
|
+
const view = buildGroupedView(sortedRows, columns, { columnId: groupByColumnId, sumColumnIds }, expandedGroups);
|
|
119
|
+
return {
|
|
120
|
+
items: view.items, groupCount: view.groupCount, totals: view.totals,
|
|
121
|
+
groupKeys: view.items.filter((i) => i.kind === 'group').map((i) => (i as { key: string }).key),
|
|
122
|
+
};
|
|
107
123
|
}
|
|
108
|
-
|
|
124
|
+
return {
|
|
125
|
+
items: sortedRows.map((row): DisplayItem => ({ kind: 'row', row })),
|
|
126
|
+
groupCount: 0,
|
|
127
|
+
totals: flatTotals(sortedRows, columns, sumColumnIds),
|
|
128
|
+
groupKeys: [],
|
|
129
|
+
};
|
|
130
|
+
}, [isGrouped, sortedRows, columns, groupByColumnId, sumColumnIds, expandedGroups]);
|
|
131
|
+
|
|
132
|
+
const columnWidths = useMemo(
|
|
133
|
+
() => columns.map((c, i) => widthOverrides[c.id] ?? autoColumnWidth(c.label ?? c.propertyName, result.rows, i)),
|
|
134
|
+
[columns, widthOverrides, result.rows]);
|
|
135
|
+
const totalWidth = useMemo(() => columnWidths.reduce((a, b) => a + b, 0), [columnWidths]);
|
|
136
|
+
|
|
137
|
+
const virtualizer = useVirtualizer({
|
|
138
|
+
count: items.length,
|
|
139
|
+
getScrollElement: () => parentRef.current,
|
|
140
|
+
estimateSize: (i) => (items[i]?.kind === 'group' ? 30 : 28),
|
|
141
|
+
overscan: 18,
|
|
142
|
+
getItemKey: (i) => {
|
|
143
|
+
const it = items[i];
|
|
144
|
+
if (it?.kind === 'group') return `g:${it.key}`;
|
|
145
|
+
const r = (it as { row: ListRow }).row;
|
|
146
|
+
return `r:${r.modelId}:${r.entityId}:${i}`;
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ── Handlers ──
|
|
151
|
+
const handleHeaderClick = useCallback((colIndex: number) => {
|
|
152
|
+
setSortCol((prev) => {
|
|
153
|
+
if (prev === colIndex) { setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); return prev; }
|
|
154
|
+
setSortDir('asc'); return colIndex;
|
|
155
|
+
});
|
|
156
|
+
}, []);
|
|
109
157
|
|
|
110
158
|
const handleColorByColumn = useCallback((col: ColumnDefinition, colIdx: number) => {
|
|
111
|
-
|
|
112
|
-
const label = col.label ?? col.propertyName;
|
|
113
|
-
activateAutoColorFromColumn(spec, label);
|
|
159
|
+
activateAutoColorFromColumn(columnToAutoColor(col), col.label ?? col.propertyName);
|
|
114
160
|
setColorByColIdx(colIdx);
|
|
115
161
|
}, [activateAutoColorFromColumn]);
|
|
116
162
|
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
163
|
+
const toggleGroupBy = useCallback((colId: string) => {
|
|
164
|
+
if (!onGroupingChange) return;
|
|
165
|
+
if (groupByColumnId === colId) onGroupingChange(sumColumnIds.length ? { columnId: '', sumColumnIds } : undefined);
|
|
166
|
+
else onGroupingChange({ columnId: colId, sumColumnIds });
|
|
167
|
+
}, [onGroupingChange, groupByColumnId, sumColumnIds]);
|
|
168
|
+
|
|
169
|
+
const toggleSum = useCallback((colId: string) => {
|
|
170
|
+
if (!onGroupingChange) return;
|
|
171
|
+
const next = sumColumnIds.includes(colId) ? sumColumnIds.filter((x) => x !== colId) : [...sumColumnIds, colId];
|
|
172
|
+
onGroupingChange((groupByColumnId || next.length) ? { columnId: groupByColumnId, sumColumnIds: next } : undefined);
|
|
173
|
+
}, [onGroupingChange, groupByColumnId, sumColumnIds]);
|
|
174
|
+
|
|
175
|
+
const toggleGroupExpand = useCallback((key: string) => {
|
|
176
|
+
setExpandedGroups((prev) => { const n = new Set(prev); if (n.has(key)) n.delete(key); else n.add(key); return n; });
|
|
177
|
+
}, []);
|
|
178
|
+
const allExpanded = groupKeys.length > 0 && groupKeys.every((k) => expandedGroups.has(k));
|
|
179
|
+
const toggleExpandAll = useCallback(() => {
|
|
180
|
+
setExpandedGroups(allExpanded ? new Set() : new Set(groupKeys));
|
|
181
|
+
}, [allExpanded, groupKeys]);
|
|
182
|
+
|
|
183
|
+
const startResize = useCallback((e: React.MouseEvent, colId: string, colIdx: number) => {
|
|
184
|
+
e.preventDefault(); e.stopPropagation();
|
|
185
|
+
const startX = e.clientX;
|
|
186
|
+
const startWidth = columnWidths[colIdx];
|
|
187
|
+
const onMove = (ev: MouseEvent) => setWidthOverrides((p) => ({ ...p, [colId]: Math.max(56, startWidth + (ev.clientX - startX)) }));
|
|
188
|
+
const onUp = () => {
|
|
189
|
+
window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp);
|
|
190
|
+
document.body.style.cursor = ''; document.body.style.userSelect = '';
|
|
123
191
|
};
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
192
|
+
window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp);
|
|
193
|
+
document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none';
|
|
194
|
+
}, [columnWidths]);
|
|
195
|
+
|
|
196
|
+
// Export honours the on-screen view: configured columns, the active
|
|
197
|
+
// grouping (sections + per-group count/sums), and the grand totals.
|
|
198
|
+
const handleExport = useCallback((format: ExportFormat) => {
|
|
199
|
+
const model = buildExportModel({
|
|
200
|
+
title: listName?.trim() || 'List',
|
|
201
|
+
columns,
|
|
202
|
+
rows: sortedRows,
|
|
203
|
+
grouping,
|
|
204
|
+
numericCols,
|
|
205
|
+
columnWidths,
|
|
206
|
+
generatedAt: new Date().toLocaleString(),
|
|
207
|
+
});
|
|
208
|
+
void exportList(format, model);
|
|
209
|
+
}, [listName, columns, sortedRows, grouping, numericCols, columnWidths]);
|
|
133
210
|
|
|
134
211
|
const handleRowClick = useCallback((row: ListRow) => {
|
|
135
212
|
setSelectedEntity({ modelId: row.modelId, expressId: row.entityId });
|
|
136
|
-
// For single-model, selectedEntityId is the expressId
|
|
137
|
-
// For multi-model, we'd need the global ID, but we set expressId for now
|
|
138
213
|
setSelectedEntityId(row.entityId);
|
|
139
214
|
}, [setSelectedEntity, setSelectedEntityId]);
|
|
140
215
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}, [result.columns]);
|
|
149
|
-
|
|
150
|
-
const totalWidth = useMemo(() => columnWidths.reduce((a, b) => a + b, 0), [columnWidths]);
|
|
151
|
-
|
|
152
|
-
const virtualizer = useVirtualizer({
|
|
153
|
-
count: sortedRows.length,
|
|
154
|
-
getScrollElement: () => parentRef.current,
|
|
155
|
-
estimateSize: () => 28,
|
|
156
|
-
overscan: 20,
|
|
157
|
-
});
|
|
216
|
+
const sumChips = useMemo(
|
|
217
|
+
() => sumColumnIds.map((id) => {
|
|
218
|
+
const c = columns.find((c) => c.id === id);
|
|
219
|
+
return { id, label: c ? (c.label ?? c.propertyName) : id };
|
|
220
|
+
}),
|
|
221
|
+
[sumColumnIds, columns]);
|
|
222
|
+
const showSumRow = sumColumnIds.length > 0;
|
|
158
223
|
|
|
159
224
|
return (
|
|
160
225
|
<div className="flex-1 flex flex-col min-h-0">
|
|
161
|
-
{/* Search
|
|
226
|
+
{/* Search / actions */}
|
|
162
227
|
<div className="flex items-center gap-2 px-3 py-1.5 border-b">
|
|
163
228
|
<Search className="h-3.5 w-3.5 text-muted-foreground" />
|
|
164
229
|
<Input
|
|
165
230
|
placeholder="Filter results..."
|
|
166
231
|
value={searchQuery}
|
|
167
|
-
onChange={e => setSearchQuery(e.target.value)}
|
|
232
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
168
233
|
className="h-7 text-xs border-0 shadow-none focus-visible:ring-0 px-0"
|
|
169
234
|
/>
|
|
170
235
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
|
@@ -172,113 +237,147 @@ export function ListResultsTable({ result }: ListResultsTableProps) {
|
|
|
172
237
|
</span>
|
|
173
238
|
<Tooltip>
|
|
174
239
|
<TooltipTrigger asChild>
|
|
175
|
-
<Button
|
|
176
|
-
variant="ghost"
|
|
177
|
-
size="icon-sm"
|
|
178
|
-
className={cn(
|
|
179
|
-
'h-6 w-6 shrink-0',
|
|
180
|
-
filterByVisibility && 'text-primary',
|
|
181
|
-
)}
|
|
182
|
-
onClick={() => setFilterByVisibility(prev => !prev)}
|
|
183
|
-
>
|
|
240
|
+
<Button variant="ghost" size="icon-sm" className={cn('h-6 w-6 shrink-0', filterByVisibility && 'text-primary')} onClick={() => setFilterByVisibility((p) => !p)}>
|
|
184
241
|
{filterByVisibility ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
|
|
185
242
|
</Button>
|
|
186
243
|
</TooltipTrigger>
|
|
187
|
-
<TooltipContent>
|
|
188
|
-
{filterByVisibility ? 'Showing visible objects only' : 'Showing all objects'}
|
|
189
|
-
</TooltipContent>
|
|
190
|
-
</Tooltip>
|
|
191
|
-
<Tooltip>
|
|
192
|
-
<TooltipTrigger asChild>
|
|
193
|
-
<Button
|
|
194
|
-
variant="ghost"
|
|
195
|
-
size="icon-sm"
|
|
196
|
-
className="h-6 w-6 shrink-0"
|
|
197
|
-
onClick={handleExportCSV}
|
|
198
|
-
>
|
|
199
|
-
<Download className="h-3.5 w-3.5" />
|
|
200
|
-
</Button>
|
|
201
|
-
</TooltipTrigger>
|
|
202
|
-
<TooltipContent>Export CSV</TooltipContent>
|
|
244
|
+
<TooltipContent>{filterByVisibility ? 'Showing visible objects only' : 'Showing all objects'}</TooltipContent>
|
|
203
245
|
</Tooltip>
|
|
246
|
+
<DropdownMenu>
|
|
247
|
+
<Tooltip>
|
|
248
|
+
<TooltipTrigger asChild>
|
|
249
|
+
<DropdownMenuTrigger asChild>
|
|
250
|
+
<Button variant="ghost" size="icon-sm" className="h-6 w-6 shrink-0">
|
|
251
|
+
<Download className="h-3.5 w-3.5" />
|
|
252
|
+
</Button>
|
|
253
|
+
</DropdownMenuTrigger>
|
|
254
|
+
</TooltipTrigger>
|
|
255
|
+
<TooltipContent>Export…</TooltipContent>
|
|
256
|
+
</Tooltip>
|
|
257
|
+
<DropdownMenuContent align="end" className="w-44">
|
|
258
|
+
<DropdownMenuItem className="gap-2 text-xs" onClick={() => handleExport('csv')}>
|
|
259
|
+
<FileText className="h-3.5 w-3.5" /> {EXPORT_LABELS.csv}
|
|
260
|
+
</DropdownMenuItem>
|
|
261
|
+
<DropdownMenuItem className="gap-2 text-xs" onClick={() => handleExport('xlsx')}>
|
|
262
|
+
<FileSpreadsheet className="h-3.5 w-3.5" /> {EXPORT_LABELS.xlsx}
|
|
263
|
+
</DropdownMenuItem>
|
|
264
|
+
<DropdownMenuItem className="gap-2 text-xs" onClick={() => handleExport('pdf')}>
|
|
265
|
+
<FileType className="h-3.5 w-3.5" /> {EXPORT_LABELS.pdf}
|
|
266
|
+
</DropdownMenuItem>
|
|
267
|
+
</DropdownMenuContent>
|
|
268
|
+
</DropdownMenu>
|
|
204
269
|
</div>
|
|
205
270
|
|
|
271
|
+
{/* Grouping / totals control strip */}
|
|
272
|
+
{(isGrouped || showSumRow) && onGroupingChange && (
|
|
273
|
+
<ListGroupingBar
|
|
274
|
+
groupLabel={isGrouped ? groupColLabel : null}
|
|
275
|
+
sums={sumChips}
|
|
276
|
+
groupCount={groupCount}
|
|
277
|
+
count={totals.count}
|
|
278
|
+
allExpanded={allExpanded}
|
|
279
|
+
onClearGroup={() => onGroupingChange(sumColumnIds.length ? { columnId: '', sumColumnIds } : undefined)}
|
|
280
|
+
onRemoveSum={(id) => toggleSum(id)}
|
|
281
|
+
onToggleExpandAll={toggleExpandAll}
|
|
282
|
+
/>
|
|
283
|
+
)}
|
|
284
|
+
|
|
206
285
|
{/* Table */}
|
|
207
286
|
<div ref={parentRef} className="flex-1 overflow-auto min-h-0">
|
|
208
287
|
<div style={{ minWidth: totalWidth }}>
|
|
209
288
|
{/* Header */}
|
|
210
|
-
<div className="flex sticky top-0 bg-muted/80 backdrop-blur-sm border-b
|
|
211
|
-
{
|
|
212
|
-
const
|
|
289
|
+
<div className="flex sticky top-0 z-10 bg-muted/80 backdrop-blur-sm border-b">
|
|
290
|
+
{columns.map((col, colIdx) => {
|
|
291
|
+
const colored = activeLensId === AUTO_COLOR_FROM_LIST_ID && colorByColIdx === colIdx;
|
|
292
|
+
const groupedBy = groupByColumnId === col.id;
|
|
293
|
+
const summed = sumColumnIds.includes(col.id);
|
|
213
294
|
return (
|
|
214
295
|
<div
|
|
215
296
|
key={col.id}
|
|
216
297
|
className={cn(
|
|
217
|
-
'flex items-center gap-0.5 px-2 py-1.5 text-xs font-medium text-muted-foreground
|
|
218
|
-
|
|
298
|
+
'group/col relative flex items-center gap-0.5 border-r border-border/50 px-2 py-1.5 text-xs font-medium text-muted-foreground shrink-0',
|
|
299
|
+
colored && 'bg-primary/10',
|
|
300
|
+
(groupedBy || summed) && 'text-foreground',
|
|
219
301
|
)}
|
|
220
302
|
style={{ width: columnWidths[colIdx] }}
|
|
221
303
|
>
|
|
222
|
-
<button
|
|
223
|
-
className="
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
<
|
|
227
|
-
{col.label ?? col.propertyName}
|
|
228
|
-
</span>
|
|
229
|
-
{sortCol === colIdx && (
|
|
230
|
-
sortDir === 'asc'
|
|
231
|
-
? <ArrowUp className="h-3 w-3 shrink-0" />
|
|
232
|
-
: <ArrowDown className="h-3 w-3 shrink-0" />
|
|
233
|
-
)}
|
|
234
|
-
</button>
|
|
235
|
-
<button
|
|
236
|
-
className={cn(
|
|
237
|
-
'shrink-0 p-0.5 rounded-sm transition-opacity',
|
|
238
|
-
isColoredCol
|
|
239
|
-
? 'text-primary opacity-100'
|
|
240
|
-
: 'opacity-0 group-hover/col:opacity-100 text-muted-foreground hover:text-primary',
|
|
241
|
-
)}
|
|
242
|
-
onClick={(e) => { e.stopPropagation(); handleColorByColumn(col, colIdx); }}
|
|
243
|
-
title={`Color by ${col.label ?? col.propertyName}`}
|
|
244
|
-
>
|
|
245
|
-
<Palette className="h-3 w-3" />
|
|
304
|
+
<button className="flex min-w-0 flex-1 items-center gap-1 hover:text-foreground" onClick={() => handleHeaderClick(colIdx)}>
|
|
305
|
+
{groupedBy && <ChevronDown className="h-3 w-3 shrink-0 text-primary" aria-label="grouped" />}
|
|
306
|
+
<span className="truncate">{col.label ?? col.propertyName}</span>
|
|
307
|
+
{summed && <span className="text-primary">Σ</span>}
|
|
308
|
+
{sortCol === colIdx && (sortDir === 'asc' ? <ArrowUp className="h-3 w-3 shrink-0" /> : <ArrowDown className="h-3 w-3 shrink-0" />)}
|
|
246
309
|
</button>
|
|
310
|
+
{onGroupingChange && (
|
|
311
|
+
<ColumnHeaderMenu
|
|
312
|
+
isNumeric={numericCols[colIdx]}
|
|
313
|
+
isGroupedBy={groupedBy}
|
|
314
|
+
isSummed={summed}
|
|
315
|
+
active={groupedBy || summed || colored}
|
|
316
|
+
onSort={(dir) => { setSortCol(colIdx); setSortDir(dir); }}
|
|
317
|
+
onToggleGroup={() => toggleGroupBy(col.id)}
|
|
318
|
+
onToggleSum={() => toggleSum(col.id)}
|
|
319
|
+
onColorBy={() => handleColorByColumn(col, colIdx)}
|
|
320
|
+
/>
|
|
321
|
+
)}
|
|
322
|
+
<div
|
|
323
|
+
onMouseDown={(e) => startResize(e, col.id, colIdx)}
|
|
324
|
+
onClick={(e) => e.stopPropagation()}
|
|
325
|
+
onDoubleClick={() => setWidthOverrides((p) => { const n = { ...p }; delete n[col.id]; return n; })}
|
|
326
|
+
className="absolute right-0 top-0 h-full w-1.5 cursor-col-resize hover:bg-primary/40"
|
|
327
|
+
title="Drag to resize · double-click to auto-fit"
|
|
328
|
+
/>
|
|
247
329
|
</div>
|
|
248
330
|
);
|
|
249
331
|
})}
|
|
250
332
|
</div>
|
|
251
333
|
|
|
252
|
-
{/* Virtualized rows */}
|
|
253
|
-
<div
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
334
|
+
{/* Virtualized rows / group headers */}
|
|
335
|
+
<div style={{ height: `${virtualizer.getTotalSize()}px`, width: '100%', position: 'relative' }}>
|
|
336
|
+
{virtualizer.getVirtualItems().map((vRow) => {
|
|
337
|
+
const item = items[vRow.index];
|
|
338
|
+
if (!item) return null;
|
|
339
|
+
const transform = `translateY(${vRow.start}px)`;
|
|
340
|
+
|
|
341
|
+
if (item.kind === 'group') {
|
|
342
|
+
const expanded = expandedGroups.has(item.key);
|
|
343
|
+
return (
|
|
344
|
+
<div
|
|
345
|
+
key={vRow.key}
|
|
346
|
+
className="absolute left-0 top-0 flex w-full cursor-pointer border-b border-border/40 bg-muted/50 hover:bg-muted/70"
|
|
347
|
+
style={{ transform }}
|
|
348
|
+
onClick={() => toggleGroupExpand(item.key)}
|
|
349
|
+
>
|
|
350
|
+
{columns.map((col, colIdx) => (
|
|
351
|
+
<div key={col.id} className="flex items-center gap-1 border-r border-border/20 px-2 py-1 text-xs font-medium shrink-0" style={{ width: columnWidths[colIdx] }}>
|
|
352
|
+
{colIdx === 0 && (
|
|
353
|
+
<>
|
|
354
|
+
{expanded ? <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
|
|
355
|
+
<span className="truncate" title={item.label}>{item.label}</span>
|
|
356
|
+
<span className="ml-1 shrink-0 rounded-full bg-foreground/10 px-1.5 text-[10px] tabular-nums text-muted-foreground">{item.count.toLocaleString()}</span>
|
|
357
|
+
</>
|
|
358
|
+
)}
|
|
359
|
+
{sumColumnIds.includes(col.id) && (
|
|
360
|
+
<span className="ml-auto font-mono tabular-nums">{formatCellValue(item.sums[col.id])}</span>
|
|
361
|
+
)}
|
|
362
|
+
</div>
|
|
363
|
+
))}
|
|
364
|
+
</div>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
263
367
|
|
|
368
|
+
const row = item.row;
|
|
369
|
+
const isSelected = row.entityId === selectedEntityId;
|
|
264
370
|
return (
|
|
265
371
|
<div
|
|
266
|
-
key={
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
className={cn(
|
|
270
|
-
'flex absolute top-0 left-0 w-full border-b border-border/30 cursor-pointer hover:bg-muted/40',
|
|
271
|
-
isSelected && 'bg-primary/10'
|
|
272
|
-
)}
|
|
273
|
-
style={{
|
|
274
|
-
transform: `translateY(${virtualRow.start}px)`,
|
|
275
|
-
}}
|
|
372
|
+
key={vRow.key}
|
|
373
|
+
className={cn('absolute left-0 top-0 flex w-full cursor-pointer border-b border-border/30 hover:bg-muted/40', isSelected && 'bg-primary/10')}
|
|
374
|
+
style={{ transform }}
|
|
276
375
|
onClick={() => handleRowClick(row)}
|
|
277
376
|
>
|
|
278
377
|
{row.values.map((value, colIdx) => (
|
|
279
378
|
<div
|
|
280
379
|
key={colIdx}
|
|
281
|
-
className=
|
|
380
|
+
className={cn('border-r border-border/20 px-2 py-1 text-xs truncate shrink-0', numericCols[colIdx] && 'text-right font-mono tabular-nums', isGrouped && colIdx === 0 && 'pl-6')}
|
|
282
381
|
style={{ width: columnWidths[colIdx] }}
|
|
283
382
|
title={value !== null ? String(value) : ''}
|
|
284
383
|
>
|
|
@@ -289,27 +388,22 @@ export function ListResultsTable({ result }: ListResultsTableProps) {
|
|
|
289
388
|
);
|
|
290
389
|
})}
|
|
291
390
|
</div>
|
|
391
|
+
|
|
392
|
+
{/* Grand-totals footer (sticky, aligned under columns) */}
|
|
393
|
+
{showSumRow && (
|
|
394
|
+
<div className="flex sticky bottom-0 z-10 border-t-2 border-border bg-muted/90 backdrop-blur-sm">
|
|
395
|
+
{columns.map((col, colIdx) => (
|
|
396
|
+
<div key={col.id} className="flex items-center border-r border-border/30 px-2 py-1 text-xs font-semibold shrink-0" style={{ width: columnWidths[colIdx] }}>
|
|
397
|
+
{colIdx === 0 && <span className="text-muted-foreground">Total · {totals.count.toLocaleString()}</span>}
|
|
398
|
+
{sumColumnIds.includes(col.id) && (
|
|
399
|
+
<span className="ml-auto font-mono tabular-nums text-foreground">{formatCellValue(totals.sums[col.id])}</span>
|
|
400
|
+
)}
|
|
401
|
+
</div>
|
|
402
|
+
))}
|
|
403
|
+
</div>
|
|
404
|
+
)}
|
|
292
405
|
</div>
|
|
293
406
|
</div>
|
|
294
407
|
</div>
|
|
295
408
|
);
|
|
296
409
|
}
|
|
297
|
-
|
|
298
|
-
function formatCellValue(value: CellValue): string {
|
|
299
|
-
if (value === null || value === undefined) return '';
|
|
300
|
-
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
|
301
|
-
if (typeof value === 'number') {
|
|
302
|
-
// Format numbers: integers as-is, decimals with up to 4 decimal places
|
|
303
|
-
if (Number.isInteger(value)) return String(value);
|
|
304
|
-
return value.toFixed(4).replace(/\.?0+$/, '');
|
|
305
|
-
}
|
|
306
|
-
return String(value);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function compareCells(a: CellValue, b: CellValue): number {
|
|
310
|
-
if (a === null && b === null) return 0;
|
|
311
|
-
if (a === null) return -1;
|
|
312
|
-
if (b === null) return 1;
|
|
313
|
-
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
|
314
|
-
return String(a).localeCompare(String(b));
|
|
315
|
-
}
|