@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.
Files changed (89) hide show
  1. package/.turbo/turbo-build.log +38 -31
  2. package/CHANGELOG.md +29 -0
  3. package/dist/assets/{basketViewActivator-ZpTYWE3K.js → basketViewActivator-B3CdrLsb.js} +7 -7
  4. package/dist/assets/{bcf-Ctcu_Sc2.js → bcf-QeHK_Aud.js} +1 -1
  5. package/dist/assets/{browser-DXS29_v9.js → browser-BIoDDfBW.js} +1 -1
  6. package/dist/assets/{cesium-BoVuJvTC.js → cesium-CzZn5yVA.js} +319 -319
  7. package/dist/assets/{deflate-Cnx0il6E.js → deflate-B-d0SYQM.js} +1 -1
  8. package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
  9. package/dist/assets/{exporters-DSq76AVM.js → exporters-B4LbZFeT.js} +1422 -1194
  10. package/dist/assets/geometry.worker-BdH-E6NB.js +1 -0
  11. package/dist/assets/{geotiff-A5UjhI6L.js → geotiff-CrVtDRFq.js} +10 -10
  12. package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
  13. package/dist/assets/{ids-DiLcGTer.js → ids-DjsGFN10.js} +4 -4
  14. package/dist/assets/ifc-lite_bg-DsYUIHm3.wasm +0 -0
  15. package/dist/assets/{index-BAH8IJVR.js → index-COYokSKc.js} +38319 -35469
  16. package/dist/assets/index-ajK6D32J.css +1 -0
  17. package/dist/assets/index.es-CY202jA3.js +6866 -0
  18. package/dist/assets/{jpeg-BzSkwo5D.js → jpeg-D4wOkf5h.js} +1 -1
  19. package/dist/assets/jspdf.es.min-DIGb9BHN.js +19571 -0
  20. package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
  21. package/dist/assets/{lerc-Cg2Rz-D5.js → lerc-DmW0_tgf.js} +1 -1
  22. package/dist/assets/{lzw-BBPPLW-0.js → lzw-oWetY-d6.js} +1 -1
  23. package/dist/assets/{maplibre-gl-Do6O5tDc.js → maplibre-gl-BF3Z0idw.js} +1 -1
  24. package/dist/assets/{native-bridge-CPojOeGE.js → native-bridge-BX8_tHXE.js} +1 -1
  25. package/dist/assets/{packbits-yLSpjW-V.js → packbits-F8Nkp4NY.js} +1 -1
  26. package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
  27. package/dist/assets/{parser.worker-8md211IW.js → parser.worker-D591Zu_-.js} +3 -3
  28. package/dist/assets/pdf-Dsh3HPZB.js +135 -0
  29. package/dist/assets/raw-D9iw0tmc.js +1 -0
  30. package/dist/assets/{sandbox-CsRXlgCO.js → sandbox-BAC3a-eN.js} +1735 -1660
  31. package/dist/assets/server-client-Cjwnm7il.js +706 -0
  32. package/dist/assets/{webimage-YafxjjGr.js → webimage-BLV1dgmd.js} +1 -1
  33. package/dist/assets/xlsx-Bc2HTrjC.js +142 -0
  34. package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
  35. package/dist/assets/{zstd-CkSLOiuu.js → zstd-C_1HxVrA.js} +1 -1
  36. package/dist/index.html +8 -8
  37. package/package.json +10 -7
  38. package/src/components/mcp/PlaygroundChat.tsx +1 -0
  39. package/src/components/mcp/data.ts +6 -0
  40. package/src/components/mcp/playground-dispatcher.ts +277 -0
  41. package/src/components/mcp/types.ts +2 -1
  42. package/src/components/ui/combo-input.tsx +163 -0
  43. package/src/components/ui/tabs.tsx +1 -1
  44. package/src/components/viewer/PropertiesPanel.tsx +13 -6
  45. package/src/components/viewer/SearchInline.tsx +62 -2
  46. package/src/components/viewer/SearchModal.filter.builder.tsx +24 -393
  47. package/src/components/viewer/SearchModal.filter.editors.tsx +503 -0
  48. package/src/components/viewer/SearchModal.filter.tsx +64 -1
  49. package/src/components/viewer/SearchModal.tsx +19 -6
  50. package/src/components/viewer/Viewport.tsx +15 -0
  51. package/src/components/viewer/lists/ColumnHeaderMenu.tsx +84 -0
  52. package/src/components/viewer/lists/ListBuilder.tsx +789 -280
  53. package/src/components/viewer/lists/ListGroupingBar.tsx +72 -0
  54. package/src/components/viewer/lists/ListPanel.tsx +49 -5
  55. package/src/components/viewer/lists/ListResultsTable.tsx +270 -176
  56. package/src/components/viewer/lists/list-table-utils.ts +123 -0
  57. package/src/generated/mcp-catalog.json +4 -0
  58. package/src/hooks/source-key.ts +35 -0
  59. package/src/hooks/useAlignmentLines3D.ts +1 -26
  60. package/src/hooks/useGridLines3D.ts +140 -0
  61. package/src/lib/length-unit-scale.ts +41 -0
  62. package/src/lib/lists/adapter.ts +136 -11
  63. package/src/lib/lists/export/csv.ts +47 -0
  64. package/src/lib/lists/export/index.ts +49 -0
  65. package/src/lib/lists/export/model.ts +111 -0
  66. package/src/lib/lists/export/pdf.ts +67 -0
  67. package/src/lib/lists/export/xlsx.ts +83 -0
  68. package/src/lib/lists/index.ts +2 -0
  69. package/src/lib/search/filter-evaluate.test.ts +81 -0
  70. package/src/lib/search/filter-evaluate.ts +59 -87
  71. package/src/lib/search/filter-match.ts +167 -0
  72. package/src/lib/search/filter-rules.test.ts +25 -0
  73. package/src/lib/search/filter-rules.ts +75 -2
  74. package/src/lib/search/filter-schema.ts +0 -0
  75. package/src/lib/slab-edit.test.ts +72 -0
  76. package/src/lib/slab-edit.ts +159 -19
  77. package/src/sdk/adapters/export-adapter.ts +3 -3
  78. package/src/sdk/adapters/query-adapter.ts +3 -3
  79. package/src/store/slices/listSlice.ts +6 -0
  80. package/src/store/slices/mutationSlice.ts +14 -6
  81. package/src/store/slices/searchSlice.ts +29 -3
  82. package/src/utils/nativeSpatialDataStore.ts +6 -0
  83. package/src/utils/serverDataModel.test.ts +6 -0
  84. package/src/utils/serverDataModel.ts +7 -0
  85. package/dist/assets/geometry.worker-0Q9qEa6p.js +0 -1
  86. package/dist/assets/ifc-lite_bg-CEZnhM2e.wasm +0 -0
  87. package/dist/assets/index-B9Ug2EqU.css +0 -1
  88. package/dist/assets/raw-BQrAgxwT.js +0 -1
  89. 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 - Virtualized table displaying list execution results
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: Uses @tanstack/react-virtual for efficient rendering of large result sets.
9
- * Only renders visible rows, supports 100K+ rows smoothly.
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, Palette, Eye, EyeOff, Download } from 'lucide-react';
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, CellValue, ColumnDefinition } from '@ifc-lite/lists';
22
- import { listResultToCSV } from '@ifc-lite/lists';
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
- // Subscribe to visibility state so we re-filter when 3D visibility changes
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
- // Filter rows by 3D visibility
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 visibleRefs) {
65
- visibleSet.add(`${ref.modelId}:${ref.expressId}`);
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
- hiddenEntities, isolatedEntities, classFilter, lensHiddenIds,
76
- selectedStoreys, typeVisibility, hiddenEntitiesByModel,
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
- const sorted = [...filteredRows];
93
- sorted.sort((a, b) => {
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
- const handleHeaderClick = useCallback((colIndex: number) => {
102
- if (sortCol === colIndex) {
103
- setSortDir(prev => prev === 'asc' ? 'desc' : 'asc');
104
- } else {
105
- setSortCol(colIndex);
106
- setSortDir('asc');
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
- }, [sortCol]);
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
- const spec = columnToAutoColor(col);
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 handleExportCSV = useCallback(() => {
118
- const exportResult: ListResult = {
119
- columns: result.columns,
120
- rows: sortedRows,
121
- totalCount: sortedRows.length,
122
- executionTime: result.executionTime,
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
- const csv = listResultToCSV(exportResult);
125
- const blob = new Blob([csv], { type: 'text/csv' });
126
- const url = URL.createObjectURL(blob);
127
- const a = document.createElement('a');
128
- a.href = url;
129
- a.download = 'list-export.csv';
130
- a.click();
131
- setTimeout(() => URL.revokeObjectURL(url), 1000);
132
- }, [result.columns, result.executionTime, sortedRows]);
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
- // Column widths
142
- const columnWidths = useMemo(() => {
143
- return result.columns.map(col => {
144
- const label = col.label ?? col.propertyName;
145
- // Estimate width: min 80px, max 250px, based on header + content
146
- return Math.max(80, Math.min(250, label.length * 8 + 40));
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 bar */}
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 z-10">
211
- {result.columns.map((col, colIdx) => {
212
- const isColoredCol = activeLensId === AUTO_COLOR_FROM_LIST_ID && colorByColIdx === colIdx;
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 border-r border-border/50 shrink-0 group/col',
218
- isColoredCol && 'bg-primary/10',
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="flex items-center gap-1 flex-1 min-w-0 hover:text-foreground"
224
- onClick={() => handleHeaderClick(colIdx)}
225
- >
226
- <span className="truncate">
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
- style={{
255
- height: `${virtualizer.getTotalSize()}px`,
256
- width: '100%',
257
- position: 'relative',
258
- }}
259
- >
260
- {virtualizer.getVirtualItems().map(virtualRow => {
261
- const row = sortedRows[virtualRow.index];
262
- const isSelected = row.entityId === selectedEntityId;
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={virtualRow.key}
267
- data-index={virtualRow.index}
268
- ref={virtualizer.measureElement}
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="px-2 py-1 text-xs truncate border-r border-border/20 shrink-0"
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
- }