@thatopen/services 0.1.3 → 0.3.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 (120) hide show
  1. package/README.md +59 -46
  2. package/dist/built-in/index.d.ts +486 -563
  3. package/dist/cli/lib/npmrc.d.ts +28 -0
  4. package/dist/cli/lib/npmrc.test.d.ts +1 -0
  5. package/dist/cli.js +91 -7
  6. package/dist/core/client.d.ts +10 -0
  7. package/dist/index.cjs.js +2 -2
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.es.js +42 -32
  10. package/dist/types/npm.d.ts +14 -0
  11. package/dist/vite-env.d.ts +1 -1
  12. package/docs/builtin/paths.json +74 -70
  13. package/docs/cli/create.md +28 -28
  14. package/docs/cli/local-server.md +38 -38
  15. package/docs/cli/login.md +32 -32
  16. package/docs/cli/publish.md +31 -31
  17. package/docs/cli/run.md +26 -26
  18. package/docs/cli/serve.md +43 -43
  19. package/docs/cli/swap.md +30 -30
  20. package/package.json +100 -100
  21. package/resources/AGENTS.md +40 -40
  22. package/src/built-in/CDEManager/example.ts +214 -214
  23. package/src/built-in/CDEManager/examples/FileEditing/example.ts +226 -226
  24. package/src/built-in/CDEManager/examples/FileMoves/example.ts +186 -186
  25. package/src/built-in/CDEManager/examples/FileOperations/example.ts +295 -295
  26. package/src/built-in/CDEManager/examples/FileTransfers/example.ts +210 -210
  27. package/src/built-in/CDEManager/examples/FolderManagement/example.ts +230 -230
  28. package/src/built-in/CDEManager/examples/MetadataSchema/example.ts +230 -230
  29. package/src/built-in/CDEManager/examples/MetadataViews/example.ts +166 -166
  30. package/src/built-in/CDEManager/examples/ProjectConfig/example.ts +177 -177
  31. package/src/built-in/UIManager/src/app/example.ts +99 -99
  32. package/src/built-in/UIManager/src/app/examples/AppState/example.ts +142 -142
  33. package/src/built-in/UIManager/src/app/examples/Banner/example.ts +161 -0
  34. package/src/built-in/UIManager/src/app/examples/Contexts/example.ts +125 -125
  35. package/src/built-in/UIManager/src/app/examples/FileDrop/example.ts +126 -0
  36. package/src/built-in/UIManager/src/app/examples/FloatingLayout/example.ts +92 -92
  37. package/src/built-in/UIManager/src/app/examples/Modal/example.ts +165 -0
  38. package/src/built-in/UIManager/src/app/examples/Notifications/example.ts +135 -161
  39. package/src/built-in/UIManager/src/app/examples/Sidebar/example.ts +104 -104
  40. package/src/built-in/index.ts +649 -755
  41. package/src/cli/templates/app/README.md +241 -241
  42. package/src/cli/templates/app/index.html +35 -27
  43. package/src/cli/templates/app/package.json +31 -27
  44. package/src/cli/templates/app/src/app.ts +37 -0
  45. package/src/cli/templates/app/src/assets/tool-placeholder.ts +50 -0
  46. package/src/cli/templates/app/src/bim-components/CloudRunner/index.ts +95 -87
  47. package/src/cli/templates/app/src/bim-components/CloudRunner/src/index.ts +1 -1
  48. package/src/cli/templates/app/src/bim-components/CloudRunner/src/types.ts +5 -5
  49. package/src/cli/templates/app/src/bim-components/index.ts +1 -1
  50. package/src/cli/templates/app/src/globals.ts +1 -0
  51. package/src/cli/templates/app/src/main.ts +340 -56
  52. package/src/cli/templates/app/src/setups/active-tool-hud.ts +69 -0
  53. package/src/cli/templates/app/src/setups/camera-tools.ts +63 -0
  54. package/src/cli/templates/app/src/setups/camera-views.ts +201 -0
  55. package/src/cli/templates/app/src/setups/card-header.ts +35 -0
  56. package/src/cli/templates/app/src/setups/clipper-panel.ts +167 -0
  57. package/src/cli/templates/app/src/setups/clipper-tool.ts +754 -0
  58. package/src/cli/templates/app/src/setups/clipper.ts +75 -0
  59. package/src/cli/templates/app/src/setups/cloud-runner.ts +13 -9
  60. package/src/cli/templates/app/src/setups/commands-panel.ts +239 -0
  61. package/src/cli/templates/app/src/setups/data-table-panel.ts +2224 -0
  62. package/src/cli/templates/app/src/setups/exploded-view.ts +246 -0
  63. package/src/cli/templates/app/src/setups/file-format-icons.ts +95 -0
  64. package/src/cli/templates/app/src/setups/files-panel.ts +1503 -0
  65. package/src/cli/templates/app/src/setups/fps-indicator.ts +63 -0
  66. package/src/cli/templates/app/src/setups/graphics-panel.ts +490 -0
  67. package/src/cli/templates/app/src/setups/helper-panel.ts +88 -0
  68. package/src/cli/templates/app/src/setups/hider.ts +98 -0
  69. package/src/cli/templates/app/src/setups/index.ts +33 -3
  70. package/src/cli/templates/app/src/setups/inspection.ts +136 -0
  71. package/src/cli/templates/app/src/setups/measurement-panel.ts +188 -0
  72. package/src/cli/templates/app/src/setups/measurement-settings-panel.ts +161 -0
  73. package/src/cli/templates/app/src/setups/measurement-tool.ts +570 -0
  74. package/src/cli/templates/app/src/setups/measurements.ts +133 -0
  75. package/src/cli/templates/app/src/setups/model-tree.ts +797 -0
  76. package/src/cli/templates/app/src/setups/navigation-gizmo.ts +316 -0
  77. package/src/cli/templates/app/src/setups/objects-panel.ts +127 -0
  78. package/src/cli/templates/app/src/setups/plans-panel.ts +619 -0
  79. package/src/cli/templates/app/src/setups/properties-panel.ts +1048 -0
  80. package/src/cli/templates/app/src/setups/reality-capture/lib/hidden-tiles-plugin.ts +88 -0
  81. package/src/cli/templates/app/src/setups/reality-capture/lib/point-tile-plugin.ts +166 -0
  82. package/src/cli/templates/app/src/setups/reality-capture/lib/render-frame.ts +31 -0
  83. package/src/cli/templates/app/src/setups/reality-capture/lib/splat-tile-plugin.ts +51 -0
  84. package/src/cli/templates/app/src/setups/reality-capture-viewer.ts +1383 -0
  85. package/src/cli/templates/app/src/setups/right-sidebar.ts +178 -0
  86. package/src/cli/templates/app/src/setups/settings-panel.ts +94 -0
  87. package/src/cli/templates/app/src/setups/styles-panel.ts +110 -0
  88. package/src/cli/templates/app/src/setups/styles.ts +375 -0
  89. package/src/cli/templates/app/src/setups/tool-mode-manager.ts +150 -0
  90. package/src/cli/templates/app/src/setups/tool-mode.ts +23 -0
  91. package/src/cli/templates/app/src/setups/toolbar.ts +229 -0
  92. package/src/cli/templates/app/src/setups/ui-manager.ts +27 -0
  93. package/src/cli/templates/app/src/setups/visibility-toolbar.ts +498 -0
  94. package/src/cli/templates/app/src/setups/walkthrough.ts +528 -0
  95. package/src/cli/templates/app/src/ui-components/app-info-section/index.ts +26 -0
  96. package/src/cli/templates/app/src/ui-components/app-info-section/src/index.ts +1 -0
  97. package/src/cli/templates/app/src/ui-components/app-info-section/src/types.ts +15 -0
  98. package/src/cli/templates/app/src/ui-components/cloud-runner-section/index.ts +37 -0
  99. package/src/cli/templates/app/src/ui-components/cloud-runner-section/src/index.ts +1 -0
  100. package/src/cli/templates/app/src/ui-components/cloud-runner-section/src/types.ts +14 -0
  101. package/src/cli/templates/app/src/ui-components/index.ts +2 -1
  102. package/src/cli/templates/app/tsconfig.json +16 -17
  103. package/src/cli/templates/app/vite.config.js +45 -42
  104. package/src/cli/templates/cloud-component/README.md +205 -205
  105. package/src/cli/templates/cloud-component/_thatopen +5 -5
  106. package/src/cli/templates/cloud-component/declarations.json +4 -4
  107. package/src/cli/templates/cloud-component/package.json +21 -21
  108. package/src/cli/templates/cloud-component/src/main.ts +70 -70
  109. package/src/cli/templates/cloud-component/tsconfig.json +16 -16
  110. package/src/cli/templates/cloud-component/vite.config.js +46 -46
  111. package/src/cli/templates/shared/AGENTS.md +8 -8
  112. package/src/cli/templates/shared/CLAUDE.md +1 -1
  113. package/src/cli/templates/shared/_gitignore +5 -4
  114. package/src/built-in/AppManager/example.ts +0 -86
  115. package/src/built-in/FileList/example.ts +0 -107
  116. package/src/built-in/TabbedNavigation/example.ts +0 -115
  117. package/src/built-in/ViewportsManager/example.ts +0 -106
  118. package/src/cli/templates/app/src/setups/fragments.ts +0 -18
  119. package/src/cli/templates/app/src/setups/ifc-loader.ts +0 -12
  120. package/src/cli/templates/app/src/ui-components/AppPanel/index.ts +0 -160
@@ -0,0 +1,2224 @@
1
+ import * as OBC from "@thatopen/components";
2
+ import * as OBF from "@thatopen/components-front";
3
+ import * as FRAGS from "@thatopen/fragments";
4
+ import * as BUI from "@thatopen/ui";
5
+ import { cardHeader } from "./card-header";
6
+ import { getAppManager } from "../app";
7
+
8
+ /**
9
+ * ELEMENT DATA TABLE (Trimble-style) — a VIRTUALIZED (windowed) tabular browser.
10
+ *
11
+ * Rows = elements; columns = attributes + property-set values + quantities. The
12
+ * table is sortable (click a header), filterable (text box, all visible columns),
13
+ * groupable (None / Category / Storey, with collapsible group bands), exports
14
+ * CSV, and is two-way bound to the 3D selection (click a row → highlight in 3D;
15
+ * a 3D/tree selection reflects back as selected rows). A COLUMN PICKER popover
16
+ * lets the user choose which of the discovered (heterogeneous) attribute / pset /
17
+ * quantity columns are shown — and assign each a roll-up function. Per-group
18
+ * roll-up cells (sum/avg/min/max/count/distinct, or a shared-value/"varies"
19
+ * sentinel for non-aggregated columns) and a footer grand total present the
20
+ * aggregation (quantity-takeoff style). Filtering is a structured AND-of-ORs
21
+ * query (typed per-column operators) plus a quick contains box.
22
+ *
23
+ * WHY VIRTUALIZED: bim-table renders every row as real DOM over the WebGL canvas
24
+ * (per-frame compositing cost) — see model-tree.ts' header. This hand-rolls the
25
+ * same windowed list: fixed-height rows, a scroll viewport + sizer, and only the
26
+ * rows in the scroll window are in the DOM (recycled on scroll). Horizontal
27
+ * layout is fixed-width cells; one sticky header row scrolls with the body.
28
+ *
29
+ * DATA LAYER (interned columnar): the worker returns a COLUMNAR + INTERNED table
30
+ * — a shared string dictionary plus, per column, an Int32Array of dictionary
31
+ * indices (text) and (numeric columns) a Float64Array (num). We keep a master
32
+ * dictionary + per-column typed arrays aligned by rowIdx; a "row" is just a
33
+ * rowIdx and a cell resolves via cellText/cellNum (no per-row objects). FIRST
34
+ * PAINT is attributes-only — `model.getTableData({ mode: "attributes" })`, ~sub-
35
+ * second — and pset/quantity columns are filled lazily in the BACKGROUND
36
+ * (`model.getTablePsets`, batched per model, merged progressively) so the panel
37
+ * is interactive immediately and pset-column ops light up as they land. Each
38
+ * payload carries its own dictionary, remapped into the master one on merge.
39
+ * Type-level psets are merged worker-side (includeTypePsets, instance wins). The
40
+ * store is bounded (MAX_ROWS) so footer aggregation is a plain columnar scan.
41
+ * Storey is enriched client-side (cheap spatial-tree walk) — the worker table has
42
+ * no storey column.
43
+ *
44
+ * @param components engine components
45
+ */
46
+
47
+ const ROW_H = 26; // px, fixed row height (data + group rows) → simple windowing
48
+ const HEAD_H = 30; // px, sticky header row height
49
+ const INDENT = 14; // px per group-nesting level (multi-level grouping indent)
50
+ const CELL_PAD = 8; // px base left padding of a cell (≈ the .dt-c 0.5rem)
51
+ const MIN_COL = 72; // px floor on rendered column width → stays readable in the narrow panel
52
+ const BUFFER = 8; // extra rows above/below the viewport
53
+ // Hard cap on TOTAL indexed elements across ALL loaded models (surfaced via the
54
+ // note). Generous because the interned columnar store is ~6-8x lighter than the
55
+ // old array-of-objects, so a single large model can't starve a second model's
56
+ // rows out of the budget (the multi-model case).
57
+ const MAX_ROWS = 100000;
58
+
59
+ // Spatial containers are not table rows (they're structure, not elements).
60
+ const CONTAINER_CATEGORIES = new Set([
61
+ "IFCPROJECT",
62
+ "IFCSITE",
63
+ "IFCBUILDING",
64
+ "IFCBUILDINGSTOREY",
65
+ ]);
66
+
67
+ const prettyCategory = (category: string) => {
68
+ const base = (category || "").replace(/^IFC/i, "");
69
+ if (!base) return category || "";
70
+ return base.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (c) => c.toUpperCase());
71
+ };
72
+
73
+ const esc = (s: string) =>
74
+ s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
75
+
76
+ // Strict numeric parse: only a bare integer/decimal counts as a number (so "C25/30"
77
+ // or "true" stay text). Returns NaN when the value is not purely numeric.
78
+ const asNumber = (v: string): number => {
79
+ const t = v.trim();
80
+ if (!t || !/^-?\d+(\.\d+)?$/.test(t)) return NaN;
81
+ return Number(t);
82
+ };
83
+
84
+ const fmtNum = (n: number): string =>
85
+ Number.isInteger(n) ? n.toLocaleString() : n.toLocaleString(undefined, { maximumFractionDigits: 3 });
86
+
87
+ // ── Filter model (grimoire: filter-operator-set + query-expression-form) ──────
88
+ // Normalized-string-compare: case- AND diacritic-insensitive comparison key, so
89
+ // "Café" matches "cafe". Shared by the filter and the quick search.
90
+ const normText = (s: string) =>
91
+ s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim();
92
+ // Precision-bounded-numeric-compare: round both operands before comparing so
93
+ // float error (2.4000001 vs 2.4) doesn't flip an equality / boundary test.
94
+ const roundC = (n: number) => Math.round(n * 1e6) / 1e6;
95
+
96
+ // Per-column-type operator catalogue (filter-operator-set). Presence operators
97
+ // (set/not-set/empty) are shared; the rest are type-gated. Each operator has a
98
+ // negated form where relevant, so per-operator negation covers what a NOT node
99
+ // would (serializable-normal-form: no standalone NOT combinator).
100
+ type Op =
101
+ | "contains" | "ncontains" | "eq" | "neq" | "starts" // text
102
+ | "eqn" | "neqn" | "lt" | "le" | "gt" | "ge" | "between" // number
103
+ | "empty" | "set" | "unset"; // presence (any type)
104
+ const TEXT_OPS: { op: Op; label: string }[] = [
105
+ { op: "contains", label: "contains" },
106
+ { op: "ncontains", label: "not contains" },
107
+ { op: "eq", label: "equals" },
108
+ { op: "neq", label: "not equals" },
109
+ { op: "starts", label: "starts with" },
110
+ { op: "empty", label: "is empty" },
111
+ { op: "set", label: "is set" },
112
+ { op: "unset", label: "not set" },
113
+ ];
114
+ const NUM_OPS: { op: Op; label: string }[] = [
115
+ { op: "eqn", label: "=" },
116
+ { op: "neqn", label: "≠" },
117
+ { op: "lt", label: "<" },
118
+ { op: "le", label: "≤" },
119
+ { op: "gt", label: ">" },
120
+ { op: "ge", label: "≥" },
121
+ { op: "between", label: "between" },
122
+ { op: "set", label: "is set" },
123
+ { op: "unset", label: "not set" },
124
+ ];
125
+ const PRESENCE_OPS = new Set<Op>(["empty", "set", "unset"]);
126
+
127
+ // One filter condition. The list compiles to AND-of-ORs: an `OR`-tagged
128
+ // condition joins the previous disjunctive group; an `AND`-tagged one starts a
129
+ // new group; the groups are conjoined (serializable-normal-form).
130
+ interface Condition {
131
+ id: number;
132
+ col: string;
133
+ op: Op;
134
+ v: string; // operand
135
+ v2: string; // second operand (between)
136
+ conj: "AND" | "OR";
137
+ }
138
+
139
+ // ── Aggregation model (grimoire: aggregation-presentation) ────────────────────
140
+ // per-column-function-selection: each column may carry a roll-up function. sum/
141
+ // avg/min/max apply to numeric columns; count/distinct apply to any.
142
+ type AggFn = "sum" | "avg" | "min" | "max" | "count" | "distinct";
143
+ const NUM_FNS: AggFn[] = ["sum", "avg", "min", "max", "count", "distinct"];
144
+ const ANY_FNS: AggFn[] = ["count", "distinct"];
145
+ const fnsFor = (kind: "text" | "number") => (kind === "number" ? NUM_FNS : ANY_FNS);
146
+
147
+ // (computeAgg + groupShared live inside the panel closure — they read the
148
+ // interned columnar store via the cell accessors.)
149
+
150
+ // ── Saved views (grimoire: saved-configuration) ───────────────────────────────
151
+ // The MEANINGFUL config of the table — visible columns, aggregations, the AND-of-
152
+ // ORs filter (+ quick text), multi-key sort, and group levels. Incidental state
153
+ // (scroll, hover, selection) is excluded so dirty-detection doesn't fire on noise.
154
+ interface ViewConfig {
155
+ visibleCols: string[];
156
+ aggs: [string, AggFn][]; // sorted by key for canonical serialization
157
+ conditions: Omit<Condition, "id">[]; // volatile `id` dropped (canonical)
158
+ query: string;
159
+ sortSpec: { col: string; dir: 1 | -1 }[];
160
+ groupCols: string[];
161
+ }
162
+ interface SavedView {
163
+ name: string; // personal scope (no elevated-permission path needed)
164
+ config: ViewConfig;
165
+ }
166
+
167
+ // ── Column model (INTERNED COLUMNAR) ───────────────────────────────
168
+ // Mirrors the worker's TableData column: UI metadata (key/group/label/kind/
169
+ // width) PLUS the columnar data. `text[rowIdx]` is an index into the panel's
170
+ // master `strings` dictionary (-1 = no value); `num[rowIdx]` (numeric columns
171
+ // only) is the canonical value for sort/aggregate (NaN = no value). Arrays are
172
+ // length === rowCount, aligned by rowIdx. There is NO per-row object: a "row"
173
+ // is just a rowIdx, and a cell resolves via cellText/cellNum (see below).
174
+ interface Col {
175
+ key: string;
176
+ group: string; // "Attributes" | "Spatial" | pset name | quantity-set name
177
+ label: string;
178
+ kind: "text" | "number";
179
+ width: number; // px
180
+ text: Int32Array; // dictionary index per row (-1 = no value)
181
+ num: Float64Array | null; // numeric per row (NaN = no value); null for text columns
182
+ }
183
+
184
+ // Fixed attribute columns offered by default / in the picker.
185
+ const ATTR_COLUMNS: { attr: string; key: string; label: string; width: number }[] = [
186
+ { attr: "_category", key: "attr:_category", label: "Category", width: 150 },
187
+ { attr: "Name", key: "attr:Name", label: "Name", width: 220 },
188
+ { attr: "_localId", key: "attr:_localId", label: "LocalId", width: 90 },
189
+ { attr: "_guid", key: "attr:_guid", label: "GUID", width: 200 },
190
+ { attr: "ObjectType", key: "attr:ObjectType", label: "Object Type", width: 160 },
191
+ { attr: "PredefinedType", key: "attr:PredefinedType", label: "Predefined Type", width: 150 },
192
+ { attr: "Tag", key: "attr:Tag", label: "Tag", width: 110 },
193
+ { attr: "Description", key: "attr:Description", label: "Description", width: 200 },
194
+ ];
195
+ const STOREY_KEY = "meta:storey";
196
+ const DEFAULT_VISIBLE = ["attr:_category", "attr:Name", STOREY_KEY];
197
+
198
+ export const dataTablePanel = (components: OBC.Components) => {
199
+ const fragments = components.get(OBC.FragmentsManager);
200
+ const highlighter = components.get(OBF.Highlighter);
201
+ const selectName = highlighter.config.selectName;
202
+
203
+ // ── Interned columnar store ────────────────────────────────────
204
+ // The master string dictionary + per-column typed arrays, aligned by rowIdx.
205
+ // A "row" is a rowIdx in [0, rowCount); cells resolve through cellText/cellNum.
206
+ let strings: string[] = []; // master dictionary (text cells index into this)
207
+ let intern = new Map<string, number>(); // string -> dictionary index (dedupe)
208
+ let rowCount = 0;
209
+ let rowLocalId = new Uint32Array(0); // rowIdx -> element localId
210
+ let rowModelIdx = new Uint16Array(0); // rowIdx -> index into modelIdsOrder
211
+ let modelIdsOrder: string[] = []; // model id per rowModelIdx slot
212
+ const columns = new Map<string, Col>(); // discovered RAW columns (per-pset, all)
213
+ let visibleCols: string[] = [...DEFAULT_VISIBLE]; // ordered visible column keys
214
+
215
+ // ── Name-collapse layer (default ON) ───────────────────────────
216
+ // The pset/quantity columns are keyed by (set, prop), so the same property
217
+ // (e.g. "GrossArea") appears once per pset. By default the COLUMN UNIVERSE the
218
+ // user sees (picker / filter / group / sort / agg) collapses those to ONE entry
219
+ // per property NAME, unioning values across psets; "Show by pset" flips back to
220
+ // the raw provenance keys. nameMembers maps a universe key -> its raw member
221
+ // keys; collapsedMeta holds the synthetic column metadata.
222
+ type ColMeta = { key: string; label: string; group: string; kind: "text" | "number"; width: number; conflict?: boolean };
223
+ let collapseByName = true;
224
+ const nameMembers = new Map<string, string[]>();
225
+ const collapsedMeta = new Map<string, ColMeta>();
226
+ const rebuildNameIndex = () => {
227
+ nameMembers.clear();
228
+ collapsedMeta.clear();
229
+ const byName = new Map<string, Col[]>();
230
+ for (const c of columns.values()) {
231
+ const isProp = c.group !== "Attributes" && c.group !== "Spatial";
232
+ if (!isProp) {
233
+ // attr / storey columns are already unique → carry through as themselves.
234
+ nameMembers.set(c.key, [c.key]);
235
+ collapsedMeta.set(c.key, { key: c.key, label: c.label, group: c.group, kind: c.kind, width: c.width });
236
+ } else {
237
+ let arr = byName.get(c.label);
238
+ if (!arr) {
239
+ arr = [];
240
+ byName.set(c.label, arr);
241
+ }
242
+ arr.push(c);
243
+ }
244
+ }
245
+ for (const [name, cols] of byName) {
246
+ const key = `name:${name}`;
247
+ const allNum = cols.every((c) => c.kind === "number");
248
+ const anyNum = cols.some((c) => c.kind === "number");
249
+ collapsedMeta.set(key, {
250
+ key,
251
+ label: name,
252
+ group: "Properties",
253
+ kind: allNum ? "number" : "text",
254
+ width: Math.max(...cols.map((c) => c.width)),
255
+ conflict: anyNum && !allNum, // mixed type across psets → flagged, not coerced
256
+ });
257
+ nameMembers.set(key, cols.map((c) => c.key));
258
+ }
259
+ };
260
+ // Active-universe column metadata for a key (collapsed or raw, per mode).
261
+ const colMeta = (key: string): ColMeta | Col | undefined =>
262
+ collapseByName ? collapsedMeta.get(key) : columns.get(key);
263
+ // The active column universe (what the picker / filter / group dropdowns list).
264
+ const universe = (): ColMeta[] | Col[] =>
265
+ collapseByName ? [...collapsedMeta.values()] : [...columns.values()];
266
+ // Whether a key exists in the ACTIVE universe (mode-aware existence check).
267
+ const inUniverse = (key: string): boolean =>
268
+ collapseByName ? collapsedMeta.has(key) : columns.has(key);
269
+
270
+ // ── Cell accessors (the ONLY way to read the columnar store) ───
271
+ const internStr = (s: string): number => {
272
+ let i = intern.get(s);
273
+ if (i === undefined) {
274
+ i = strings.length;
275
+ strings.push(s);
276
+ intern.set(s, i);
277
+ }
278
+ return i;
279
+ };
280
+ const rowKeyOf = (r: number): string => `${modelIdsOrder[rowModelIdx[r]]}:${rowLocalId[r]}`;
281
+ // Raw single-column resolution (operates on the per-pset store directly).
282
+ const rawText = (r: number, key: string): string => {
283
+ const c = columns.get(key);
284
+ if (!c) return "";
285
+ const i = c.text[r];
286
+ return i < 0 ? "" : strings[i];
287
+ };
288
+ const rawNum = (r: number, key: string): number => {
289
+ const c = columns.get(key);
290
+ if (!c) return NaN;
291
+ if (c.num) return c.num[r];
292
+ const i = c.text[r];
293
+ return i < 0 ? NaN : asNumber(strings[i]);
294
+ };
295
+ const rawHas = (r: number, key: string): boolean => {
296
+ const c = columns.get(key);
297
+ return !!c && c.text[r] >= 0;
298
+ };
299
+ // Collapse-aware resolution (the ONLY accessors the feature code uses). In
300
+ // by-pset mode they're 1:1 with raw. In collapse mode a property key unions its
301
+ // member psets: present iff ANY member has it; agreed value if all present
302
+ // members match, else the "Varies" sentinel; numeric "varies"/missing → NaN
303
+ // (excluded from numeric compare/agg — conflicting elements are skipped, not
304
+ // zeroed).
305
+ const cellText = (r: number, key: string): string => {
306
+ if (!collapseByName) return rawText(r, key);
307
+ const members = nameMembers.get(key);
308
+ if (!members) return "";
309
+ if (members.length === 1) return rawText(r, members[0]);
310
+ let val: string | undefined;
311
+ let has = false;
312
+ let varies = false;
313
+ for (const m of members) {
314
+ if (!rawHas(r, m)) continue;
315
+ const v = rawText(r, m);
316
+ if (!has) {
317
+ val = v;
318
+ has = true;
319
+ } else if (v !== val) {
320
+ varies = true;
321
+ }
322
+ }
323
+ return !has ? "" : varies ? "Varies" : (val ?? "");
324
+ };
325
+ const cellNum = (r: number, key: string): number => {
326
+ if (!collapseByName) return rawNum(r, key);
327
+ const members = nameMembers.get(key);
328
+ if (!members) return NaN;
329
+ if (members.length === 1) return rawNum(r, members[0]);
330
+ let val = NaN;
331
+ let has = false;
332
+ let varies = false;
333
+ for (const m of members) {
334
+ if (!rawHas(r, m)) continue;
335
+ const n = rawNum(r, m);
336
+ if (!has) {
337
+ val = n;
338
+ has = true;
339
+ } else if (n !== val) {
340
+ varies = true;
341
+ }
342
+ }
343
+ return !has || varies ? NaN : val;
344
+ };
345
+ const cellHas = (r: number, key: string): boolean => {
346
+ if (!collapseByName) return rawHas(r, key);
347
+ const members = nameMembers.get(key);
348
+ if (!members) return false;
349
+ return members.some((m) => rawHas(r, m));
350
+ };
351
+
352
+ // Retained store size (bytes): dictionary chars (UTF-16 + per-string overhead)
353
+ // + every column's typed arrays + the row arrays. The typed-array term is
354
+ // O(rows × cols) and DENSE regardless of sparsity (a column allocates a full
355
+ // text[rowCount], and num[rowCount] if numeric), so it scales linearly with
356
+ // rows and with the number of discovered columns — independent of how many
357
+ // cells actually have values. Interning makes the dictionary sub-linear (a
358
+ // category/type string is stored once no matter how many rows repeat it).
359
+ const storeBytes = () => {
360
+ let dict = 0;
361
+ for (const s of strings) dict += s.length * 2 + 16; // UTF-16 + ~obj overhead
362
+ let arrays = 0;
363
+ for (const c of columns.values()) arrays += c.text.byteLength + (c.num?.byteLength ?? 0);
364
+ const rowsB = rowLocalId.byteLength + rowModelIdx.byteLength;
365
+ return { dict, arrays, rowsB, total: dict + arrays + rowsB };
366
+ };
367
+ const logStoreBytes = (phase: string) => {
368
+ const b = storeBytes();
369
+ const mb = (n: number) => (n / 1048576).toFixed(2);
370
+ console.log(
371
+ `[data-table] retained store (${phase}): ${mb(b.total)} MB ` +
372
+ `[dict ${mb(b.dict)} + typed-arrays ${mb(b.arrays)} + rows ${mb(b.rowsB)}] ` +
373
+ `· ${rowCount.toLocaleString()} rows × ${columns.size} cols · dict ${strings.length.toLocaleString()} strings`,
374
+ );
375
+ };
376
+
377
+ // Register/return a column, allocating its rowCount-length arrays. preferNumber
378
+ // upgrades a text column to numeric (heterogeneous psets may mix).
379
+ const ensureColumn = (
380
+ key: string,
381
+ group: string,
382
+ label: string,
383
+ kind: "text" | "number",
384
+ width: number,
385
+ preferNumber = false,
386
+ ): Col => {
387
+ let col = columns.get(key);
388
+ if (!col) {
389
+ col = {
390
+ key,
391
+ group,
392
+ label,
393
+ kind,
394
+ width,
395
+ text: new Int32Array(rowCount).fill(-1),
396
+ num: kind === "number" ? new Float64Array(rowCount).fill(NaN) : null,
397
+ };
398
+ columns.set(key, col);
399
+ } else if (preferNumber && col.kind === "text") {
400
+ col.kind = "number";
401
+ if (!col.num) col.num = new Float64Array(rowCount).fill(NaN);
402
+ }
403
+ return col;
404
+ };
405
+
406
+ // ── Aggregation over a set of rowIdx (reads the columnar store) ─
407
+ // Numeric reduces use cellNum (skip NaN/"no value"); count = present values;
408
+ // distinct = distinct present display strings. Returns "" when nothing to show.
409
+ const computeAgg = (arr: number[], col: string, fn: AggFn): string => {
410
+ if (fn === "count") {
411
+ let n = 0;
412
+ for (const r of arr) if (cellHas(r, col)) n++;
413
+ return n.toLocaleString();
414
+ }
415
+ if (fn === "distinct") {
416
+ const s = new Set<string>();
417
+ for (const r of arr) {
418
+ if (cellHas(r, col)) s.add(cellText(r, col));
419
+ }
420
+ return s.size.toLocaleString();
421
+ }
422
+ let acc = 0;
423
+ let n = 0;
424
+ let mn = Infinity;
425
+ let mx = -Infinity;
426
+ for (const r of arr) {
427
+ const x = cellNum(r, col);
428
+ if (Number.isNaN(x)) continue; // "no value" excluded from numeric reduce
429
+ acc += x;
430
+ n++;
431
+ if (x < mn) mn = x;
432
+ if (x > mx) mx = x;
433
+ }
434
+ if (n === 0) return "";
435
+ switch (fn) {
436
+ case "sum": return fmtNum(acc);
437
+ case "avg": return fmtNum(acc / n);
438
+ case "min": return fmtNum(mn);
439
+ case "max": return fmtNum(mx);
440
+ default: return "";
441
+ }
442
+ };
443
+
444
+ // mixed-value-sentinel: a non-aggregated column's group cell shows the shared
445
+ // value if all members agree, "Varies" if they differ, or empty if all members
446
+ // have "no value" (distinct from "varies").
447
+ const groupShared = (arr: number[], col: string): { text: string; varies: boolean } => {
448
+ let first: string | undefined;
449
+ let has = false;
450
+ for (const r of arr) {
451
+ if (!cellHas(r, col)) continue;
452
+ const v = cellText(r, col);
453
+ if (!has) {
454
+ first = v;
455
+ has = true;
456
+ } else if (v !== first) {
457
+ return { text: "Varies", varies: true };
458
+ }
459
+ }
460
+ return has ? { text: first ?? "", varies: false } : { text: "", varies: false };
461
+ };
462
+ let query = ""; // quick filter (global contains across visible columns)
463
+ let conditions: Condition[] = []; // structured filter (AND-of-ORs)
464
+ let condId = 0;
465
+ // value-faceting (scan-on-demand-distinct): distinct values per column for the
466
+ // operand suggestions, cached; invalidated on each index rebuild.
467
+ const facetCache = new Map<string, string[]>();
468
+ // multi-key-stable-sort: ordered (column, direction) keys; the base JS sort is
469
+ // stable, so layering keys composes (first non-equal key decides, ties fall to
470
+ // the next, final ties keep prior order).
471
+ let sortSpec: { col: string; dir: 1 | -1 }[] = [];
472
+ // grouping-key/property-value-key: ordered list of columns to group by (one
473
+ // group level per entry, nested); empty = ungrouped.
474
+ let groupCols: string[] = [];
475
+ // per-column-function-selection: columnKey → roll-up function (footer + group).
476
+ const aggs = new Map<string, AggFn>();
477
+ const collapsedGroups = new Set<string>();
478
+ const selectedRowKeys = new Set<string>();
479
+
480
+ // ── Saved views (persisted to the hidden __project_data/bim-viewer/ folder) ──
481
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
482
+ let client: any = null;
483
+ let projectId: string | undefined;
484
+ try {
485
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
486
+ const app = getAppManager(components) as any;
487
+ client = app?.client ?? null;
488
+ projectId = client?.context?.projectId;
489
+ } catch {
490
+ /* no AppManager (e.g. standalone dev harness) → views just don't persist */
491
+ }
492
+ let savedViews: SavedView[] = [];
493
+ let activeViewName: string | null = null;
494
+ let savingNew = false; // the "Save as…" inline name input is open
495
+ let newViewName = "";
496
+ // Which on-demand editor is open below the compact toolbar (one at a time).
497
+ let openEditor: "" | "filter" | "group" | "views" = "";
498
+
499
+ // Filtered+sorted data rows, then the flattened visible list (group bands +
500
+ // data rows) the virtualizer renders.
501
+ let filtered: number[] = []; // rowIdx of rows passing the filter, in sort order
502
+ type VRow =
503
+ | {
504
+ t: "group";
505
+ gkey: string; // full path key (unique across levels) for collapse state
506
+ label: string;
507
+ count: number;
508
+ level: number; // 0-based nesting depth (multi-level grouping)
509
+ // per-column group roll-up display (aggregate or shared/"varies" value)
510
+ cells: Record<string, string>;
511
+ varies: Set<string>; // columns whose group cell is the "varies" sentinel
512
+ }
513
+ | { t: "data"; row: number; level: number }; // row = rowIdx; level = indent depth
514
+ let visible: VRow[] = [];
515
+
516
+ let rebuildToken = 0;
517
+ let searchTimer: number | undefined;
518
+ // Guard against the select→table→select feedback loop: raised around a
519
+ // table-initiated highlight drive, consumed by the echoing onHighlight.
520
+ let pendingInternal = 0;
521
+
522
+ // ── Virtualization DOM (created once, managed imperatively) ─────
523
+ const viewport = document.createElement("div");
524
+ viewport.className = "dt-vp";
525
+ const head = document.createElement("div");
526
+ head.className = "dt-head";
527
+ const body = document.createElement("div");
528
+ body.className = "dt-body";
529
+ body.style.position = "relative";
530
+ const content = document.createElement("div");
531
+ content.style.position = "absolute";
532
+ content.style.top = "0";
533
+ content.style.left = "0";
534
+ content.appendChild(document.createElement("div")); // placeholder, replaced
535
+ content.firstElementChild?.remove();
536
+ body.appendChild(content);
537
+ viewport.appendChild(head);
538
+ viewport.appendChild(body);
539
+
540
+ // Re-fill width on panel resize (recompute the last-column flex + repaint).
541
+ let resizeRaf = 0;
542
+ const resizeObserver = new ResizeObserver(() => {
543
+ if (resizeRaf) return;
544
+ resizeRaf = requestAnimationFrame(() => {
545
+ resizeRaf = 0;
546
+ recomputeFlex();
547
+ renderHead();
548
+ render(true);
549
+ });
550
+ });
551
+ resizeObserver.observe(viewport);
552
+
553
+ // User-dragged column widths, keyed by the active-universe column key (collapsed
554
+ // name: key or raw pset key per mode) so they survive re-renders + the by-pset
555
+ // toggle. In-memory only for now (persisting across sessions is task #34).
556
+ const userWidths = new Map<string, number>();
557
+ // Rendered column width: the user's dragged width if any, else the column's
558
+ // declared width — floored at MIN_COL so a skinny column still reads.
559
+ const colW = (k: string) => Math.max(MIN_COL, userWidths.get(k) ?? colMeta(k)?.width ?? 150);
560
+
561
+ // Extra px added to the LAST visible column so the table FILLS the panel width
562
+ // (no dead gutter) when the columns don't already overflow. Recomputed in
563
+ // refreshList from the viewport's client width; 0 when columns overflow (then
564
+ // the body scrolls horizontally as normal).
565
+ let lastColExtra = 0;
566
+ const recomputeFlex = () => {
567
+ const avail = viewport.clientWidth || 0;
568
+ const base = visibleCols.reduce((w, k) => w + colW(k), 0);
569
+ lastColExtra = avail > 0 ? Math.max(0, avail - base) : 0;
570
+ };
571
+ // Effective rendered width: colW plus the flex remainder on the last column —
572
+ // but a USER-RESIZED last column keeps its dragged width (auto-fill yields to an
573
+ // explicit width); the resize of any non-last column still leaves the last one
574
+ // filling the gap.
575
+ const effColW = (k: string) => {
576
+ const isLast = k === visibleCols[visibleCols.length - 1];
577
+ return colW(k) + (isLast && !userWidths.has(k) ? lastColExtra : 0);
578
+ };
579
+
580
+ const totalWidth = () => visibleCols.reduce((w, k) => w + effColW(k), 0);
581
+
582
+ // ── Header row ─────────────────────────────────────────────────
583
+ const renderHead = () => {
584
+ const tw = totalWidth();
585
+ head.style.width = `${tw}px`;
586
+ head.innerHTML = visibleCols
587
+ .map((k) => {
588
+ const c = colMeta(k);
589
+ if (!c) return "";
590
+ const si = sortSpec.findIndex((s) => s.col === k);
591
+ const active = si >= 0;
592
+ const arrow = active ? (sortSpec[si].dir === 1 ? "mdi:menu-up" : "mdi:menu-down") : "";
593
+ // Show the 1-based sort order only when more than one key is active.
594
+ const ord = active && sortSpec.length > 1 ? `<span class="dt-hc-ord">${si + 1}</span>` : "";
595
+ return (
596
+ `<div class="dt-hc${active ? " sorted" : ""}" data-col="${esc(k)}" style="width:${effColW(k)}px" title="${esc(c.label)} — click to sort, shift-click to add a secondary key">` +
597
+ `<span class="dt-hc-lbl">${esc(c.label)}</span>` +
598
+ (arrow ? `<bim-icon class="dt-hc-arr" icon="${arrow}"></bim-icon>` : "") +
599
+ ord +
600
+ `<span class="dt-hc-rz" data-col="${esc(k)}" title="Drag to resize"></span>` +
601
+ `</div>`
602
+ );
603
+ })
604
+ .join("");
605
+ };
606
+
607
+ // ── Body rows ──────────────────────────────────────────────────
608
+ const dataRowHtml = (r: number, level: number) => {
609
+ const cells = visibleCols
610
+ .map((k, idx) => {
611
+ const c = colMeta(k);
612
+ const w = effColW(k);
613
+ const v = cellText(r, k);
614
+ const num = c?.kind === "number";
615
+ // Indent the first cell by the nesting depth so grouped rows step in.
616
+ const pad = idx === 0 ? `padding-left:${CELL_PAD + level * INDENT}px;` : "";
617
+ // Value lives in an inner span: text-overflow:ellipsis needs a block-ish
618
+ // child, it never applies to a bare text node in a flex cell (would clip
619
+ // with no "…"). The span is the ellipsizing element.
620
+ return `<div class="dt-c${num ? " num" : ""}" style="width:${w}px;${pad}" title="${esc(v)}"><span class="dt-c-tx">${esc(v)}</span></div>`;
621
+ })
622
+ .join("");
623
+ const rk = rowKeyOf(r);
624
+ return (
625
+ `<div class="dt-row${selectedRowKeys.has(rk) ? " sel" : ""}" data-rk="${esc(rk)}" ` +
626
+ `style="height:${ROW_H}px;width:${totalWidth()}px">${cells}</div>`
627
+ );
628
+ };
629
+
630
+ const groupRowHtml = (g: Extract<VRow, { t: "group" }>) => {
631
+ const collapsed = collapsedGroups.has(g.gkey);
632
+ // Per-column cells: the first visible column carries the group identity
633
+ // (caret + label + count); the rest show the column's roll-up (aggregate, or
634
+ // shared value / "varies" sentinel) aligned under its header.
635
+ const cells = visibleCols
636
+ .map((k, idx) => {
637
+ const col = colMeta(k);
638
+ const w = effColW(k);
639
+ const num = col?.kind === "number";
640
+ if (idx === 0) {
641
+ return (
642
+ `<div class="dt-c dt-gc-id" style="width:${w}px;padding-left:${CELL_PAD + g.level * INDENT}px">` +
643
+ `<bim-icon class="dt-g-car" icon="${collapsed ? "mdi:chevron-right" : "mdi:chevron-down"}"></bim-icon>` +
644
+ `<span class="dt-g-lbl">${esc(g.label)}</span>` +
645
+ `<span class="dt-g-cnt">${g.count.toLocaleString()}</span>` +
646
+ `</div>`
647
+ );
648
+ }
649
+ const txt = g.cells[k] ?? "";
650
+ const isVaries = g.varies.has(k);
651
+ return `<div class="dt-c${num ? " num" : ""}${isVaries ? " varies" : ""}" style="width:${w}px" title="${esc(txt)}"><span class="dt-c-tx">${esc(txt)}</span></div>`;
652
+ })
653
+ .join("");
654
+ return (
655
+ `<div class="dt-group" data-gkey="${esc(g.gkey)}" style="height:${ROW_H}px;width:${totalWidth()}px">` +
656
+ cells +
657
+ `</div>`
658
+ );
659
+ };
660
+
661
+ const vRowHtml = (v: VRow) => (v.t === "group" ? groupRowHtml(v) : dataRowHtml(v.row, v.level));
662
+
663
+ const buildRow = (i: number): HTMLElement => {
664
+ const tmp = document.createElement("div");
665
+ tmp.innerHTML = vRowHtml(visible[i]);
666
+ return tmp.firstElementChild as HTMLElement;
667
+ };
668
+
669
+ // Index→element recycler (fixed height): rows staying in the window keep their
670
+ // exact DOM (and rendered bim-icons) across scroll; only entering rows are
671
+ // built, leaving rows removed. A forced rebuild drops all (data/filter/sort
672
+ // changed) so the window is rebuilt once off the scroll path.
673
+ const mounted = new Map<number, HTMLElement>();
674
+ let lastStart = -1;
675
+ let lastEnd = -1;
676
+
677
+ const render = (force = false) => {
678
+ const total = visible.length;
679
+ body.style.height = `${total * ROW_H}px`;
680
+ body.style.width = `${totalWidth()}px`;
681
+ const top = viewport.scrollTop;
682
+ const vh = (viewport.clientHeight || ROW_H) - HEAD_H;
683
+ const start = Math.max(0, Math.floor(top / ROW_H) - BUFFER);
684
+ const end = Math.min(total, Math.ceil((top + vh) / ROW_H) + BUFFER);
685
+ if (!force && start === lastStart && end === lastEnd) return;
686
+ if (force) {
687
+ mounted.clear();
688
+ content.textContent = "";
689
+ }
690
+ lastStart = start;
691
+ lastEnd = end;
692
+ content.style.transform = `translateY(${start * ROW_H}px)`;
693
+ for (const [i, el] of mounted) {
694
+ if (i < start || i >= end) {
695
+ el.remove();
696
+ mounted.delete(i);
697
+ }
698
+ }
699
+ let cursor = content.firstElementChild as HTMLElement | null;
700
+ for (let i = start; i < end; i++) {
701
+ const existing = mounted.get(i);
702
+ if (existing) {
703
+ cursor = existing.nextElementSibling as HTMLElement | null;
704
+ continue;
705
+ }
706
+ const el = buildRow(i);
707
+ content.insertBefore(el, cursor);
708
+ mounted.set(i, el);
709
+ }
710
+ };
711
+
712
+ // ── Filter → sort → group → flatten ────────────────────────────
713
+ // Quick filter: normalized contains across visible columns (the convenience
714
+ // bar, ANDed with the structured query below).
715
+ const quickMatch = (r: number, q: string) => {
716
+ if (!q) return true;
717
+ for (const k of visibleCols) {
718
+ if (normText(cellText(r, k)).includes(q)) return true;
719
+ }
720
+ return false;
721
+ };
722
+
723
+ // Evaluate one typed predicate against a row (filter-operator-set). "No value"
724
+ // (absent column) satisfies only `unset`; value operators exclude it. An
725
+ // incomplete value-operator (blank operand) is ignored (treated as pass) so
726
+ // the table isn't emptied while the user is still typing the operand.
727
+ const evalCondition = (r: number, c: Condition): boolean => {
728
+ const col = colMeta(c.col);
729
+ if (!col) return true; // stale column → ignore
730
+ const present = cellHas(r, c.col);
731
+ const raw = cellText(r, c.col);
732
+ if (c.op === "unset") return !present;
733
+ if (c.op === "set") return present;
734
+ if (c.op === "empty") return present && raw === "";
735
+ if (!PRESENCE_OPS.has(c.op) && c.v.trim() === "") return true; // incomplete → ignore
736
+ if (col.kind === "number") {
737
+ const n = present ? cellNum(r, c.col) : NaN;
738
+ if (Number.isNaN(n)) return false; // "no value" satisfies no numeric compare
739
+ const x = roundC(n);
740
+ const a = roundC(asNumber(c.v));
741
+ switch (c.op) {
742
+ case "eqn": return x === a;
743
+ case "neqn": return x !== a;
744
+ case "lt": return x < a;
745
+ case "le": return x <= a;
746
+ case "gt": return x > a;
747
+ case "ge": return x >= a;
748
+ case "between": {
749
+ const b = roundC(asNumber(c.v2));
750
+ return x >= Math.min(a, b) && x <= Math.max(a, b);
751
+ }
752
+ default: return true;
753
+ }
754
+ }
755
+ if (!present) return false;
756
+ const hv = normText(raw);
757
+ const nv = normText(c.v);
758
+ switch (c.op) {
759
+ case "contains": return hv.includes(nv);
760
+ case "ncontains": return !hv.includes(nv);
761
+ case "eq": return hv === nv;
762
+ case "neq": return hv !== nv;
763
+ case "starts": return hv.startsWith(nv);
764
+ default: return true;
765
+ }
766
+ };
767
+
768
+ // Compile the flat condition list to AND-of-ORs and test a row: walk the list,
769
+ // a run of OR-tagged conditions forms a disjunctive group, AND-tagged starts a
770
+ // new group; the row passes iff EVERY group has at least one true condition.
771
+ const structuredMatch = (r: number): boolean => {
772
+ if (conditions.length === 0) return true;
773
+ let i = 0;
774
+ while (i < conditions.length) {
775
+ let groupOk = evalCondition(r, conditions[i]);
776
+ let j = i + 1;
777
+ while (j < conditions.length && conditions[j].conj === "OR") {
778
+ groupOk = groupOk || evalCondition(r, conditions[j]);
779
+ j++;
780
+ }
781
+ if (!groupOk) return false; // a conjunctive group failed
782
+ i = j;
783
+ }
784
+ return true;
785
+ };
786
+
787
+ // value-faceting (scan-on-demand-distinct): distinct, present values of a
788
+ // column for operand suggestions. Cached; bounded so a huge column can't flood.
789
+ const distinctValues = (col: string): string[] => {
790
+ const hit = facetCache.get(col);
791
+ if (hit) return hit;
792
+ const seen = new Set<string>();
793
+ for (let r = 0; r < rowCount; r++) {
794
+ if (cellHas(r, col)) seen.add(cellText(r, col));
795
+ if (seen.size > 400) break;
796
+ }
797
+ const out = [...seen]
798
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
799
+ .slice(0, 100);
800
+ facetCache.set(col, out);
801
+ return out;
802
+ };
803
+
804
+ // The group-key VALUE for a row on a column (property-value-key). "No value"
805
+ // is its own deterministic group; the category column is prettified to match
806
+ // how it reads elsewhere.
807
+ const groupValueOf = (r: number, col: string): string => {
808
+ if (!cellHas(r, col)) return "(no value)";
809
+ const v = cellText(r, col);
810
+ if (v === "") return "(no value)";
811
+ return col === "attr:_category" ? prettyCategory(v) || v : v;
812
+ };
813
+
814
+ // Type-aware, null-last comparison of two rows on one column for a direction.
815
+ // "No value" always sinks last regardless of `dir` (deterministic-null-
816
+ // ordering); present values use natural-numeric collation / canonical numeric.
817
+ const compareByCol = (a: number, b: number, col: string, dir: 1 | -1): number => {
818
+ if (colMeta(col)?.kind === "number") {
819
+ const an = cellNum(a, col);
820
+ const bn = cellNum(b, col);
821
+ const am = Number.isNaN(an);
822
+ const bm = Number.isNaN(bn);
823
+ if (am && bm) return 0;
824
+ if (am) return 1;
825
+ if (bm) return -1;
826
+ return (an - bn) * dir;
827
+ }
828
+ const av = cellText(a, col);
829
+ const bv = cellText(b, col);
830
+ const am = av === "";
831
+ const bm = bv === "";
832
+ if (am && bm) return 0;
833
+ if (am) return 1;
834
+ if (bm) return -1;
835
+ return av.localeCompare(bv, undefined, { numeric: true }) * dir;
836
+ };
837
+
838
+ // Per-column group roll-up cells (skip the first visible column — it carries
839
+ // the group identity): a selected aggregate, else shared-value / "varies".
840
+ const groupRollup = (arr: number[]) => {
841
+ const cells: Record<string, string> = {};
842
+ const varies = new Set<string>();
843
+ visibleCols.forEach((k, idx) => {
844
+ if (idx === 0) return;
845
+ const fn = aggs.get(k);
846
+ if (fn) {
847
+ cells[k] = computeAgg(arr, k, fn);
848
+ } else {
849
+ const s = groupShared(arr, k);
850
+ cells[k] = s.text;
851
+ if (s.varies) varies.add(k);
852
+ }
853
+ });
854
+ return { cells, varies };
855
+ };
856
+
857
+ const rebuildVisible = () => {
858
+ const q = normText(query);
859
+ filtered = [];
860
+ for (let r = 0; r < rowCount; r++) {
861
+ if (quickMatch(r, q) && structuredMatch(r)) filtered.push(r);
862
+ }
863
+
864
+ // multi-key-stable-sort: first non-equal key decides; ties fall to the next;
865
+ // final ties keep prior order (stable sort).
866
+ if (sortSpec.length) {
867
+ filtered.sort((a, b) => {
868
+ for (const { col, dir } of sortSpec) {
869
+ const c = compareByCol(a, b, col, dir);
870
+ if (c !== 0) return c;
871
+ }
872
+ return 0;
873
+ });
874
+ }
875
+
876
+ const out: VRow[] = [];
877
+ if (groupCols.length === 0) {
878
+ for (const r of filtered) out.push({ t: "data", row: r, level: 0 });
879
+ } else {
880
+ // grouping-materialization/eager-full-tree: build the nested group tree in
881
+ // one pass (bounded store), flattening to visible rows honoring collapse.
882
+ const build = (arr: number[], level: number, prefix: string) => {
883
+ if (level >= groupCols.length) {
884
+ for (const r of arr) out.push({ t: "data", row: r, level });
885
+ return;
886
+ }
887
+ const col = groupCols[level];
888
+ const buckets = new Map<string, number[]>();
889
+ for (const r of arr) {
890
+ const gv = groupValueOf(r, col);
891
+ (buckets.get(gv) ?? buckets.set(gv, []).get(gv)!).push(r);
892
+ }
893
+ const keys = [...buckets.keys()].sort((a, b) =>
894
+ a.localeCompare(b, undefined, { numeric: true }),
895
+ );
896
+ for (const gv of keys) {
897
+ const sub = buckets.get(gv)!;
898
+ const path = `${prefix}${level}:${gv}`; // unique across levels
899
+ const { cells, varies } = groupRollup(sub);
900
+ out.push({ t: "group", gkey: path, label: gv, count: sub.length, level, cells, varies });
901
+ if (!collapsedGroups.has(path)) build(sub, level + 1, path);
902
+ }
903
+ };
904
+ build(filtered, 0, "");
905
+ }
906
+ visible = out;
907
+ };
908
+
909
+ // Footer aggregation: count of filtered DATA rows + the grand roll-up of every
910
+ // column that has a selected function, over the whole filtered set. Plain
911
+ // columnar scan over the bounded store.
912
+ const footer = () => {
913
+ const count = filtered.length;
914
+ const parts: { label: string; value: string }[] = [];
915
+ for (const [k, fn] of aggs) {
916
+ const col = colMeta(k);
917
+ if (!col) continue;
918
+ const v = computeAgg(filtered, k, fn);
919
+ if (v !== "") parts.push({ label: `${fn} ${col.label}`, value: v });
920
+ }
921
+ return { count, parts };
922
+ };
923
+
924
+ const refreshList = (force = true) => {
925
+ rebuildNameIndex(); // refresh the name-collapse universe (cheap; columns grow during pset fill)
926
+ rebuildVisible();
927
+ recomputeFlex(); // stretch the last column to fill the panel width (no gutter)
928
+ lastStart = lastEnd = -1;
929
+ renderHead();
930
+ render(force);
931
+ syncFooter();
932
+ };
933
+
934
+ // ── 3D selection sync ──────────────────────────────────────────
935
+ const rowMapFromKeys = (keys: Iterable<string>): OBC.ModelIdMap => {
936
+ const map: OBC.ModelIdMap = {};
937
+ for (const rk of keys) {
938
+ const idx = rk.lastIndexOf(":");
939
+ const modelId = rk.slice(0, idx);
940
+ const localId = Number(rk.slice(idx + 1));
941
+ if (!Number.isFinite(localId)) continue;
942
+ (map[modelId] ??= new Set<number>()).add(localId);
943
+ }
944
+ return map;
945
+ };
946
+
947
+ const driveHighlight = async (keys: Set<string>) => {
948
+ pendingInternal++;
949
+ try {
950
+ if (keys.size === 0) {
951
+ await highlighter.clear(selectName);
952
+ } else {
953
+ await highlighter.highlightByID(selectName, rowMapFromKeys(keys), true, false);
954
+ }
955
+ } catch (error) {
956
+ console.warn("[data-table] highlight failed", error);
957
+ } finally {
958
+ Promise.resolve().then(() => {
959
+ if (pendingInternal > 0) pendingInternal--;
960
+ });
961
+ }
962
+ };
963
+
964
+ const onRowClick = (rowKey: string, additive: boolean) => {
965
+ if (additive) {
966
+ if (selectedRowKeys.has(rowKey)) selectedRowKeys.delete(rowKey);
967
+ else selectedRowKeys.add(rowKey);
968
+ } else {
969
+ selectedRowKeys.clear();
970
+ selectedRowKeys.add(rowKey);
971
+ }
972
+ void driveHighlight(selectedRowKeys);
973
+ render(true);
974
+ };
975
+
976
+ // Scroll the first selected visible row into view (used when 3D drives us).
977
+ const scrollSelectionIntoView = () => {
978
+ if (selectedRowKeys.size === 0) return;
979
+ const idx = visible.findIndex((v) => v.t === "data" && selectedRowKeys.has(rowKeyOf(v.row)));
980
+ if (idx < 0) return;
981
+ const rowTop = idx * ROW_H;
982
+ const vh = (viewport.clientHeight || ROW_H) - HEAD_H;
983
+ const cur = viewport.scrollTop;
984
+ if (rowTop < cur || rowTop + ROW_H > cur + vh) {
985
+ viewport.scrollTop = Math.max(0, rowTop - vh / 2);
986
+ }
987
+ };
988
+
989
+ // ── Delegated interaction ──────────────────────────────────────
990
+ // ── Column resize (drag a header's right edge) ─────────────────
991
+ let rzCol: string | null = null;
992
+ let rzStartX = 0;
993
+ let rzStartW = 0;
994
+ let rzRaf = 0;
995
+ let rzMoved = false; // suppress the sort-click that fires after a drag
996
+ const onRzMove = (e: PointerEvent) => {
997
+ if (rzCol === null) return;
998
+ rzMoved = true;
999
+ const w = Math.max(MIN_COL, rzStartW + (e.clientX - rzStartX));
1000
+ userWidths.set(rzCol, w);
1001
+ if (rzRaf) return;
1002
+ rzRaf = requestAnimationFrame(() => {
1003
+ rzRaf = 0;
1004
+ recomputeFlex();
1005
+ renderHead();
1006
+ render(true); // only the ~window of virtualized rows re-emits widths
1007
+ });
1008
+ };
1009
+ const onRzUp = () => {
1010
+ rzCol = null;
1011
+ document.body.style.cursor = "";
1012
+ window.removeEventListener("pointermove", onRzMove);
1013
+ // Let the trailing click fire, then clear the suppression flag.
1014
+ setTimeout(() => {
1015
+ rzMoved = false;
1016
+ }, 0);
1017
+ };
1018
+ head.addEventListener("pointerdown", (e) => {
1019
+ const handle = (e.target as HTMLElement).closest<HTMLElement>(".dt-hc-rz");
1020
+ if (!handle?.dataset.col) return;
1021
+ e.preventDefault();
1022
+ e.stopPropagation();
1023
+ rzCol = handle.dataset.col;
1024
+ rzStartX = e.clientX;
1025
+ rzStartW = colW(rzCol);
1026
+ rzMoved = false;
1027
+ document.body.style.cursor = "col-resize";
1028
+ window.addEventListener("pointermove", onRzMove);
1029
+ window.addEventListener("pointerup", onRzUp, { once: true });
1030
+ });
1031
+
1032
+ head.addEventListener("click", (e) => {
1033
+ // Ignore clicks on the resize handle and the click that ends a resize drag.
1034
+ if ((e.target as HTMLElement).closest(".dt-hc-rz") || rzMoved) return;
1035
+ const hc = (e.target as HTMLElement).closest<HTMLElement>(".dt-hc");
1036
+ if (!hc?.dataset.col) return;
1037
+ const k = hc.dataset.col;
1038
+ const shift = (e as MouseEvent).shiftKey;
1039
+ const idx = sortSpec.findIndex((s) => s.col === k);
1040
+ if (shift) {
1041
+ // Add a secondary key, then cycle asc → desc → remove on repeat shift-clicks.
1042
+ if (idx < 0) sortSpec.push({ col: k, dir: 1 });
1043
+ else if (sortSpec[idx].dir === 1) sortSpec[idx].dir = -1;
1044
+ else sortSpec.splice(idx, 1);
1045
+ } else if (sortSpec.length === 1 && idx === 0) {
1046
+ sortSpec[0].dir = sortSpec[0].dir === 1 ? -1 : 1; // toggle the sole key
1047
+ } else {
1048
+ sortSpec = [{ col: k, dir: 1 }]; // replace with a single primary key
1049
+ }
1050
+ refreshList();
1051
+ });
1052
+
1053
+ content.addEventListener("click", (e) => {
1054
+ const target = e.target as HTMLElement;
1055
+ const groupEl = target.closest<HTMLElement>(".dt-group");
1056
+ if (groupEl?.dataset.gkey) {
1057
+ const gk = groupEl.dataset.gkey;
1058
+ if (collapsedGroups.has(gk)) collapsedGroups.delete(gk);
1059
+ else collapsedGroups.add(gk);
1060
+ refreshList();
1061
+ return;
1062
+ }
1063
+ const rowEl = target.closest<HTMLElement>(".dt-row");
1064
+ if (rowEl?.dataset.rk) {
1065
+ onRowClick(rowEl.dataset.rk, (e as MouseEvent).ctrlKey || (e as MouseEvent).metaKey);
1066
+ }
1067
+ });
1068
+
1069
+ let scrollRaf = 0;
1070
+ viewport.addEventListener("scroll", () => {
1071
+ if (scrollRaf) return;
1072
+ scrollRaf = requestAnimationFrame(() => {
1073
+ scrollRaf = 0;
1074
+ render();
1075
+ });
1076
+ });
1077
+ new ResizeObserver(() => render()).observe(viewport);
1078
+
1079
+ // ── CSV export (delimited-text per export-serialization) ───────
1080
+ // RFC 4180 quoting; a UTF-8 BOM so importers detect encoding; FULL-PRECISION
1081
+ // numerics (raw stored value, not the thousands-separated display string, so
1082
+ // downstream math isn't corrupted); and when grouped, one leading column per
1083
+ // group level (the level-column convention) so the group structure survives
1084
+ // the flat stream and the result is pivot-friendly.
1085
+ const exportCsv = () => {
1086
+ const cell = (s: string) =>
1087
+ /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
1088
+ // Numeric columns export their canonical value (full precision); text keeps
1089
+ // its display string.
1090
+ const cellFor = (r: number, k: string) => {
1091
+ const col = colMeta(k);
1092
+ if (col?.kind === "number") {
1093
+ const n = cellNum(r, k);
1094
+ if (!Number.isNaN(n)) return cell(String(n));
1095
+ }
1096
+ return cell(cellText(r, k));
1097
+ };
1098
+ const labels = visibleCols.map((k) => cell(colMeta(k)?.label ?? k));
1099
+ const lines: string[] = [];
1100
+ if (groupCols.length === 0) {
1101
+ lines.push(labels.join(","));
1102
+ for (const r of filtered) lines.push(visibleCols.map((k) => cellFor(r, k)).join(","));
1103
+ } else {
1104
+ const gLabels = groupCols.map((k) => cell(`Group: ${colMeta(k)?.label ?? k}`));
1105
+ lines.push([...gLabels, ...labels].join(","));
1106
+ // Sort the filtered rows by the group tuple so groups are contiguous; emit
1107
+ // each row with its group values prepended (complete — ignores collapse).
1108
+ const sorted = [...filtered].sort((a, b) => {
1109
+ for (const col of groupCols) {
1110
+ const c = groupValueOf(a, col).localeCompare(groupValueOf(b, col), undefined, { numeric: true });
1111
+ if (c !== 0) return c;
1112
+ }
1113
+ return 0;
1114
+ });
1115
+ for (const r of sorted) {
1116
+ lines.push(
1117
+ [...groupCols.map((k) => cell(groupValueOf(r, k))), ...visibleCols.map((k) => cellFor(r, k))].join(","),
1118
+ );
1119
+ }
1120
+ }
1121
+ const blob = new Blob(["" + lines.join("\r\n")], { type: "text/csv;charset=utf-8;" });
1122
+ const url = URL.createObjectURL(blob);
1123
+ const a = document.createElement("a");
1124
+ a.href = url;
1125
+ a.download = "elements.csv";
1126
+ a.click();
1127
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
1128
+ };
1129
+
1130
+ // Themed single-select (BUI bim-dropdown) replacing native <select>. Each
1131
+ // bim-option binds its raw `.value` (property, not attribute → no JSON parse),
1132
+ // and bim-dropdown.value returns the picked option's value, so onPick gets the
1133
+ // exact value (no label-collision risk). `width` keeps the dropdowns compact.
1134
+ const dropdown = (
1135
+ opts: { value: string; label: string }[],
1136
+ selected: string,
1137
+ onPick: (value: string) => void,
1138
+ width?: string,
1139
+ ) =>
1140
+ BUI.html`<bim-dropdown
1141
+ style=${width ? `width:${width};` : ""}
1142
+ @change=${(e: Event) => {
1143
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1144
+ const v = (e.target as any).value?.[0];
1145
+ if (v !== undefined && v !== null) onPick(String(v));
1146
+ }}
1147
+ >
1148
+ ${opts.map(
1149
+ (o) => BUI.html`<bim-option .value=${o.value} label=${o.label} ?checked=${o.value === selected}></bim-option>`,
1150
+ )}
1151
+ </bim-dropdown>`;
1152
+
1153
+ // ── Structured-filter builder handlers ─────────────────────────
1154
+ // The active column universe (name-collapsed by default, or raw per-pset when
1155
+ // "show by pset" is on), alphabetical, as filter / group / picker targets.
1156
+ const allColumns = (): ColMeta[] | Col[] =>
1157
+ [...universe()].sort((a, b) => a.label.localeCompare(b.label));
1158
+ const defaultOp = (kind: "text" | "number"): Op => (kind === "number" ? "eqn" : "contains");
1159
+ // Operand typing debounced (don't re-render the chrome on each keystroke — that
1160
+ // would steal focus; only the table refreshes).
1161
+ let filterTimer: number | undefined;
1162
+ const debouncedFilter = () => {
1163
+ if (filterTimer !== undefined) clearTimeout(filterTimer);
1164
+ filterTimer = window.setTimeout(() => {
1165
+ viewport.scrollTop = 0;
1166
+ refreshList();
1167
+ }, 200);
1168
+ };
1169
+ const addCondition = () => {
1170
+ const first = allColumns()[0];
1171
+ if (!first) return;
1172
+ conditions.push({ id: ++condId, col: first.key, op: defaultOp(first.kind), v: "", v2: "", conj: "AND" });
1173
+ refreshList();
1174
+ update({});
1175
+ };
1176
+ const removeCondition = (id: number) => {
1177
+ conditions = conditions.filter((c) => c.id !== id);
1178
+ viewport.scrollTop = 0;
1179
+ refreshList();
1180
+ update({});
1181
+ };
1182
+ const onColChange = (c: Condition, key: string) => {
1183
+ c.col = key;
1184
+ const kind = colMeta(key)?.kind ?? "text";
1185
+ const valid = (kind === "number" ? NUM_OPS : TEXT_OPS).some((o) => o.op === c.op);
1186
+ if (!valid) c.op = defaultOp(kind);
1187
+ refreshList();
1188
+ update({});
1189
+ };
1190
+
1191
+ // ── Group-by (multi-level) handlers ────────────────────────────
1192
+ const regroup = () => {
1193
+ collapsedGroups.clear();
1194
+ viewport.scrollTop = 0;
1195
+ refreshList();
1196
+ update({});
1197
+ };
1198
+ const addGroupLevel = () => {
1199
+ const used = new Set(groupCols);
1200
+ const next = allColumns().find((c) => !used.has(c.key)) ?? allColumns()[0];
1201
+ if (!next) return;
1202
+ groupCols.push(next.key);
1203
+ regroup();
1204
+ };
1205
+
1206
+ // ── Saved-view storage (mirrors CDEManager's __project_data/<app>/ pattern
1207
+ // with the raw client — __project_data is excluded from CDE listings, so
1208
+ // this is genuinely hidden, not a visible root file) ────────────────────
1209
+ const PDATA = "__project_data";
1210
+ const APP = "bim-viewer";
1211
+ const VIEWS_FILE = "saved-views.json";
1212
+ let appFolderId: string | null = null;
1213
+ const ensureAppFolder = async (): Promise<string | null> => {
1214
+ if (appFolderId) return appFolderId;
1215
+ if (!client || !projectId) return null;
1216
+ try {
1217
+ const roots = await client.listFolders({ projectId });
1218
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1219
+ let root = roots.find((f: any) => f.name === PDATA && !f.parentId);
1220
+ if (!root) root = await client.createFolder(PDATA, undefined, projectId);
1221
+ const subs = await client.listFolders({ parentFolderId: root._id, projectId });
1222
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1223
+ let app = subs.find((f: any) => f.name === APP);
1224
+ if (!app) app = await client.createFolder(APP, root._id, projectId);
1225
+ appFolderId = String(app._id);
1226
+ return appFolderId;
1227
+ } catch (error) {
1228
+ console.warn("[data-table] ensureAppFolder failed", error);
1229
+ return null;
1230
+ }
1231
+ };
1232
+ const findViewsFileId = async (folderId: string): Promise<string | null> => {
1233
+ const items = await client.listFiles({ projectId });
1234
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1235
+ const f = items.find((i: any) => i.folderId === folderId && i.name === VIEWS_FILE);
1236
+ return f ? String(f._id) : null;
1237
+ };
1238
+ const readViews = async (): Promise<SavedView[]> => {
1239
+ if (!client || !projectId) return [];
1240
+ try {
1241
+ const folderId = await ensureAppFolder();
1242
+ if (!folderId) return [];
1243
+ const id = await findViewsFileId(folderId);
1244
+ if (!id) return [];
1245
+ const resp = await client.downloadFile(id);
1246
+ const data = await resp.json();
1247
+ return Array.isArray(data?.views) ? (data.views as SavedView[]) : [];
1248
+ } catch (error) {
1249
+ console.warn("[data-table] readViews failed", error);
1250
+ return [];
1251
+ }
1252
+ };
1253
+ const writeViews = async (views: SavedView[]): Promise<void> => {
1254
+ if (!client || !projectId) return;
1255
+ try {
1256
+ const folderId = await ensureAppFolder();
1257
+ if (!folderId) return;
1258
+ const blob = new Blob([JSON.stringify({ views }, null, 2)], { type: "application/json" });
1259
+ const id = await findViewsFileId(folderId);
1260
+ // Versioning is optional for v1 — overwrite via updateFile (createVersion
1261
+ // isn't guaranteed on this client; updateFile is the proven path here).
1262
+ if (id) await client.updateFile(id, { file: blob, versionTag: `v${Date.now()}` });
1263
+ else
1264
+ await client.createFile({
1265
+ file: blob,
1266
+ name: VIEWS_FILE,
1267
+ versionTag: "v1",
1268
+ projectId,
1269
+ parentFolderId: folderId,
1270
+ });
1271
+ } catch (error) {
1272
+ console.warn("[data-table] writeViews failed", error);
1273
+ }
1274
+ };
1275
+
1276
+ // ── Capture / apply / dirty-detect (content-hash-dirty-detection) ──────────
1277
+ const captureConfig = (): ViewConfig => ({
1278
+ visibleCols: [...visibleCols],
1279
+ aggs: [...aggs.entries()].sort((a, b) => a[0].localeCompare(b[0])),
1280
+ conditions: conditions.map(({ col, op, v, v2, conj }) => ({ col, op, v, v2, conj })),
1281
+ query,
1282
+ sortSpec: sortSpec.map((s) => ({ col: s.col, dir: s.dir })),
1283
+ groupCols: [...groupCols],
1284
+ });
1285
+ // Canonical serialization (stable field order via the literal above + sorted
1286
+ // aggs) → string equality is the dirty check.
1287
+ const serializeConfig = (cfg: ViewConfig) => JSON.stringify(cfg);
1288
+ const isDirty = () => {
1289
+ if (!activeViewName) return false;
1290
+ const v = savedViews.find((s) => s.name === activeViewName);
1291
+ return !v || serializeConfig(v.config) !== serializeConfig(captureConfig());
1292
+ };
1293
+ const applyConfig = (cfg: ViewConfig) => {
1294
+ rebuildNameIndex(); // ensure the universe is current before validity-filtering keys
1295
+ visibleCols = cfg.visibleCols.filter((k) => inUniverse(k));
1296
+ if (visibleCols.length === 0) visibleCols = [...DEFAULT_VISIBLE].filter((k) => inUniverse(k));
1297
+ aggs.clear();
1298
+ for (const [k, fn] of cfg.aggs) if (inUniverse(k)) aggs.set(k, fn);
1299
+ conditions = cfg.conditions.map((c) => ({ ...c, id: ++condId }));
1300
+ query = cfg.query ?? "";
1301
+ sortSpec = cfg.sortSpec.filter((s) => inUniverse(s.col));
1302
+ groupCols = cfg.groupCols.filter((k) => inUniverse(k));
1303
+ collapsedGroups.clear();
1304
+ viewport.scrollTop = 0;
1305
+ };
1306
+
1307
+ const loadSavedViews = async () => {
1308
+ savedViews = await readViews();
1309
+ update({});
1310
+ };
1311
+ const restoreView = (name: string) => {
1312
+ const v = savedViews.find((s) => s.name === name);
1313
+ if (!v) return;
1314
+ applyConfig(v.config);
1315
+ activeViewName = name;
1316
+ refreshList();
1317
+ update({});
1318
+ };
1319
+ const persistView = async (name: string) => {
1320
+ const cfg = captureConfig();
1321
+ savedViews = [...savedViews.filter((s) => s.name !== name), { name, config: cfg }].sort((a, b) =>
1322
+ a.name.localeCompare(b.name),
1323
+ );
1324
+ activeViewName = name;
1325
+ savingNew = false;
1326
+ newViewName = "";
1327
+ update({});
1328
+ await writeViews(savedViews);
1329
+ };
1330
+ const deleteView = async (name: string) => {
1331
+ savedViews = savedViews.filter((s) => s.name !== name);
1332
+ if (activeViewName === name) activeViewName = null;
1333
+ update({});
1334
+ await writeViews(savedViews);
1335
+ };
1336
+
1337
+ // ── Column picker (transient popover, mounted to body, BUI-rendered) ────────
1338
+ // Built with BUI.Component.create so its controls are real bim components
1339
+ // (bim-checkbox / bim-dropdown / bim-text-input) themed like every other panel
1340
+ // — no native <select>/<input>. Positioned fixed near the toolbar button.
1341
+ let picker: HTMLElement | null = null;
1342
+ let pickerSearch = "";
1343
+ const closePicker = () => {
1344
+ if (picker) {
1345
+ picker.remove();
1346
+ picker = null;
1347
+ document.removeEventListener("pointerdown", onDocDown, true);
1348
+ }
1349
+ };
1350
+ const onDocDown = (e: PointerEvent) => {
1351
+ if (picker && !picker.contains(e.target as Node)) closePicker();
1352
+ };
1353
+ const setColumnVisible = (key: string, on: boolean) => {
1354
+ if (on) {
1355
+ if (!visibleCols.includes(key)) visibleCols.push(key);
1356
+ } else {
1357
+ visibleCols = visibleCols.filter((k) => k !== key);
1358
+ aggs.delete(key);
1359
+ sortSpec = sortSpec.filter((s) => s.col !== key); // drop sort key on the gone column
1360
+ }
1361
+ refreshList();
1362
+ update({});
1363
+ };
1364
+ const setColumnAgg = (key: string, fn: string) => {
1365
+ if (fn) aggs.set(key, fn as AggFn);
1366
+ else aggs.delete(key);
1367
+ refreshList();
1368
+ };
1369
+ // Flip between name-collapsed (default) and raw per-pset provenance. Keys differ
1370
+ // between modes, so prune any view state that doesn't exist in the new universe
1371
+ // (attr/storey keys are valid in both; property selections may drop).
1372
+ const togglePsetMode = (byPset: boolean) => {
1373
+ collapseByName = !byPset;
1374
+ rebuildNameIndex();
1375
+ visibleCols = visibleCols.filter(inUniverse);
1376
+ if (visibleCols.length === 0) visibleCols = [...DEFAULT_VISIBLE].filter(inUniverse);
1377
+ conditions = conditions.filter((c) => inUniverse(c.col));
1378
+ sortSpec = sortSpec.filter((s) => inUniverse(s.col));
1379
+ groupCols = groupCols.filter(inUniverse);
1380
+ for (const k of [...aggs.keys()]) if (!inUniverse(k)) aggs.delete(k);
1381
+ facetCache.clear();
1382
+ refreshList();
1383
+ update({});
1384
+ };
1385
+ const openPicker = (anchor: HTMLElement) => {
1386
+ if (picker) {
1387
+ closePicker();
1388
+ return;
1389
+ }
1390
+ pickerSearch = "";
1391
+ const [el, pickerUpdate] = BUI.Component.create<HTMLElement, { tick: number }>(
1392
+ (_s) => {
1393
+ rebuildNameIndex(); // current universe (collapsed or by-pset) for this render
1394
+ const byGroup = new Map<string, (ColMeta | Col)[]>();
1395
+ for (const c of universe()) {
1396
+ let arr = byGroup.get(c.group);
1397
+ if (!arr) {
1398
+ arr = [];
1399
+ byGroup.set(c.group, arr);
1400
+ }
1401
+ arr.push(c);
1402
+ }
1403
+ const order = (g: string) =>
1404
+ g === "Attributes" ? 0 : g === "Spatial" ? 1 : g === "Properties" ? 2 : 3;
1405
+ const groups = [...byGroup.keys()].sort(
1406
+ (a, b) => order(a) - order(b) || a.localeCompare(b),
1407
+ );
1408
+ const q = pickerSearch.trim().toLowerCase();
1409
+ const blocks = groups
1410
+ .map((g) => {
1411
+ const cols = byGroup
1412
+ .get(g)!
1413
+ .filter((c) => !q || c.label.toLowerCase().includes(q))
1414
+ .sort((a, b) => a.label.localeCompare(b.label));
1415
+ if (cols.length === 0) return null;
1416
+ return BUI.html`
1417
+ <div class="dt-pk-grp">${g}</div>
1418
+ ${cols.map(
1419
+ (c) => BUI.html`
1420
+ <div class="dt-pk-item">
1421
+ <bim-checkbox class="dt-pk-cb" label=${c.label} ?checked=${visibleCols.includes(c.key)}
1422
+ @change=${(e: Event) => setColumnVisible(c.key, !!(e.target as { checked?: boolean }).checked)}
1423
+ ></bim-checkbox>
1424
+ ${c.kind === "number" ? BUI.html`<span class="dt-pk-num">#</span>` : null}
1425
+ ${(c as ColMeta).conflict
1426
+ ? BUI.html`<bim-icon class="dt-pk-warn" icon="mdi:alert"><bim-tooltip>Mixed types across property sets — shown as text, not coerced</bim-tooltip></bim-icon>`
1427
+ : null}
1428
+ ${dropdown(
1429
+ [{ value: "", label: "∑ —" }, ...fnsFor(c.kind).map((f) => ({ value: f, label: f }))],
1430
+ aggs.get(c.key) ?? "",
1431
+ (v) => setColumnAgg(c.key, v),
1432
+ "5rem",
1433
+ )}
1434
+ </div>`,
1435
+ )}`;
1436
+ })
1437
+ .filter(Boolean);
1438
+ return BUI.html`
1439
+ <div class="dt-picker">
1440
+ <div class="dt-pk-hd">
1441
+ <span>Columns</span>
1442
+ <bim-icon class="dt-pk-x" icon="mdi:close" @click=${closePicker}></bim-icon>
1443
+ </div>
1444
+ <bim-text-input class="dt-pk-search" icon="mdi:magnify" icon-inside placeholder="Filter columns…"
1445
+ .value=${pickerSearch}
1446
+ @input=${(e: Event) => {
1447
+ pickerSearch = String((e.target as { value?: string }).value ?? "");
1448
+ pickerUpdate({ tick: 0 });
1449
+ }}
1450
+ ></bim-text-input>
1451
+ <label class="dt-pk-mode">
1452
+ <bim-checkbox ?checked=${!collapseByName}
1453
+ @change=${(e: Event) => {
1454
+ togglePsetMode(!!(e.target as { checked?: boolean }).checked);
1455
+ pickerUpdate({ tick: 0 });
1456
+ }}
1457
+ ></bim-checkbox>
1458
+ <span>Show by property set</span>
1459
+ </label>
1460
+ <div class="dt-pk-list">
1461
+ ${blocks.length ? blocks : BUI.html`<div class="dt-pk-empty">No columns yet.</div>`}
1462
+ </div>
1463
+ </div>`;
1464
+ },
1465
+ { tick: 0 },
1466
+ );
1467
+ const rect = anchor.getBoundingClientRect();
1468
+ el.style.position = "fixed";
1469
+ el.style.top = `${Math.min(rect.bottom + 4, window.innerHeight - 360)}px`;
1470
+ el.style.left = `${Math.min(rect.left, window.innerWidth - 280)}px`;
1471
+ el.style.zIndex = "9999";
1472
+ // The popover is mounted to document.body — OUTSIDE the app's themed subtree —
1473
+ // so .dt-picker's `var(--bim-ui_bg-base)` doesn't resolve and falls back to the
1474
+ // dark hardcoded value. Copy the RESOLVED var values from the panel's own
1475
+ // themed light-DOM content (`viewport` — where the table cells already render
1476
+ // themed, proving the vars resolve there) onto the popover, so its existing
1477
+ // var-based CSS resolves to the exact same surface as the panel. (Reading from
1478
+ // `anchor` failed: it's in shadow DOM where getPropertyValue returns empty.)
1479
+ const tcs = getComputedStyle(viewport);
1480
+ for (const v of [
1481
+ "--bim-ui_bg-base",
1482
+ "--bim-ui_bg-contrast-10",
1483
+ "--bim-ui_bg-contrast-20",
1484
+ "--bim-ui_bg-contrast-30",
1485
+ "--bim-ui_bg-contrast-40",
1486
+ "--bim-ui_bg-contrast-60",
1487
+ "--bim-ui_bg-contrast-80",
1488
+ "--bim-ui_bg-contrast-90",
1489
+ "--bim-ui_bg-contrast-100",
1490
+ "--bim-ui_accent-base",
1491
+ "--bim-ui_color-warning",
1492
+ ]) {
1493
+ const val = tcs.getPropertyValue(v);
1494
+ if (val) el.style.setProperty(v, val);
1495
+ }
1496
+ // The visible bim-panel SURFACE is #262629 — NOT --bim-ui_bg-base (#19191E,
1497
+ // which is darker and what .dt-picker's CSS fell back to). Match the real panel
1498
+ // surface explicitly so this body-mounted popover reads as the same panel.
1499
+ el.style.background = "#262629";
1500
+ document.body.appendChild(el);
1501
+ picker = el;
1502
+ document.addEventListener("pointerdown", onDocDown, true);
1503
+ };
1504
+
1505
+ // ── Bulk index: interned columnar store from all loaded models ─────
1506
+ // FIRST PAINT is attributes-only (model.getTableData({ mode: "attributes" }))
1507
+ // for a sub-second populate; pset/quantity columns are then filled lazily in
1508
+ // the BACKGROUND (model.getTablePsets, batched per model) and merged in. Both
1509
+ // return the interned columnar TableData; each payload carries its OWN string
1510
+ // dictionary, so we remap its indices into the panel's master dictionary.
1511
+
1512
+ // Default rendered width for a worker-reported column.
1513
+ const ATTR_WIDTH = new Map<string, number>(ATTR_COLUMNS.map((c) => [c.key, c.width]));
1514
+ const defaultColWidth = (col: { key: string; kind: "text" | "number" }) =>
1515
+ ATTR_WIDTH.get(col.key) ?? (col.kind === "number" ? 120 : 160);
1516
+
1517
+ const PSET_BATCH = 4000; // localIds per getTablePsets call (background fill)
1518
+
1519
+ const resetStore = () => {
1520
+ strings = [];
1521
+ intern = new Map<string, number>();
1522
+ rowCount = 0;
1523
+ rowLocalId = new Uint32Array(0);
1524
+ rowModelIdx = new Uint16Array(0);
1525
+ modelIdsOrder = [];
1526
+ columns.clear();
1527
+ };
1528
+
1529
+ // Intern a payload's dictionary into the master dictionary; returns the
1530
+ // payload-index -> master-index remap.
1531
+ const internDict = (dict: string[]): Int32Array => {
1532
+ const remap = new Int32Array(dict.length);
1533
+ for (let i = 0; i < dict.length; i++) remap[i] = internStr(dict[i]);
1534
+ return remap;
1535
+ };
1536
+
1537
+ // Merge one worker TableData column into the master store. `remap` maps payload
1538
+ // string indices -> master indices; `rowOf(i)` gives the master rowIdx for
1539
+ // payload row i (-1 to skip); `n` = number of payload rows to read.
1540
+ const mergeColumn = (
1541
+ pc: FRAGS.TableColumn,
1542
+ remap: Int32Array,
1543
+ rowOf: (i: number) => number,
1544
+ n: number,
1545
+ ) => {
1546
+ const col = ensureColumn(
1547
+ pc.key, pc.group, pc.label, pc.kind, defaultColWidth(pc), pc.kind === "number",
1548
+ );
1549
+ for (let i = 0; i < n; i++) {
1550
+ const r = rowOf(i);
1551
+ if (r < 0) continue;
1552
+ const si = pc.text[i];
1553
+ col.text[r] = si < 0 ? -1 : remap[si];
1554
+ if (col.num && pc.num) col.num[r] = pc.num[i];
1555
+ }
1556
+ };
1557
+
1558
+ // OPTIONAL storey enrichment (the worker table has no storey column). Cheap:
1559
+ // walks the spatial tree and fetches ONLY storey-level Names, not per-element
1560
+ // data. Returns localId -> storeyId and storeyId -> storey name.
1561
+ const collectStoreys = async (model: FRAGS.FragmentsModel) => {
1562
+ const storeyOf = new Map<number, string>();
1563
+ const storeyName = new Map<string, string>();
1564
+ const storeyIds: number[] = [];
1565
+ const structure = await model.getSpatialStructure().catch(() => null);
1566
+ if (structure) {
1567
+ const walk = (node: FRAGS.SpatialTreeItem, storey: number | null) => {
1568
+ const cat = (node.category ?? "").toUpperCase();
1569
+ let curStorey = storey;
1570
+ if (cat === "IFCBUILDINGSTOREY" && node.localId != null) {
1571
+ curStorey = node.localId;
1572
+ storeyIds.push(node.localId);
1573
+ }
1574
+ if (node.localId != null && !CONTAINER_CATEGORIES.has(cat) && curStorey != null) {
1575
+ storeyOf.set(node.localId, String(curStorey));
1576
+ }
1577
+ node.children?.forEach((c) => walk(c, curStorey));
1578
+ };
1579
+ walk(structure, null);
1580
+ }
1581
+ if (storeyIds.length > 0) {
1582
+ const sData = await model.getItemsData(storeyIds, {
1583
+ attributesDefault: false,
1584
+ attributes: ["Name"],
1585
+ });
1586
+ sData.forEach((d, i) => {
1587
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1588
+ const nm = (d as any)?.Name;
1589
+ if (nm && "value" in nm && nm.value != null) {
1590
+ storeyName.set(String(storeyIds[i]), String(nm.value));
1591
+ }
1592
+ });
1593
+ }
1594
+ return { storeyOf, storeyName };
1595
+ };
1596
+
1597
+ // PASS 1 — attributes only: build the row set + attribute columns + storey.
1598
+ const indexAttributes = async (token: number) => {
1599
+ const models = [...fragments.list.values()];
1600
+ type P = {
1601
+ modelId: string;
1602
+ data: FRAGS.TableData;
1603
+ storeyOf: Map<number, string>;
1604
+ storeyName: Map<string, string>;
1605
+ };
1606
+ const payloads: P[] = [];
1607
+ let total = 0;
1608
+ let notReady = false;
1609
+ for (const model of models) {
1610
+ if (total >= MAX_ROWS) break;
1611
+ try {
1612
+ const data = await model.getTableData({ mode: "attributes" });
1613
+ if (token !== rebuildToken) return { notReady: false };
1614
+ const { storeyOf, storeyName } = await collectStoreys(model).catch(() => ({
1615
+ storeyOf: new Map<number, string>(),
1616
+ storeyName: new Map<string, string>(),
1617
+ }));
1618
+ if (token !== rebuildToken) return { notReady: false };
1619
+ payloads.push({ modelId: model.modelId, data, storeyOf, storeyName });
1620
+ total += data.localIds.length;
1621
+ console.log(
1622
+ `[data-table] getTableData(attributes) ${model.modelId}: ` +
1623
+ `${data.stats.rowCount} rows, ${data.stats.columnCount} cols, ` +
1624
+ `${Math.round(data.stats.ms)}ms`,
1625
+ );
1626
+ } catch (error) {
1627
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1628
+ if (/Model not found/i.test(String((error as any)?.message ?? error))) notReady = true;
1629
+ else console.warn("[data-table] getTableData failed", model.modelId, error);
1630
+ }
1631
+ }
1632
+ // Allocate the master row arrays (capped at MAX_ROWS).
1633
+ rowCount = Math.min(total, MAX_ROWS);
1634
+ rowLocalId = new Uint32Array(rowCount);
1635
+ rowModelIdx = new Uint16Array(rowCount);
1636
+ modelIdsOrder = payloads.map((p) => p.modelId);
1637
+ let w = 0;
1638
+ payloads.forEach((p, mi) => {
1639
+ const ids = p.data.localIds;
1640
+ const take = Math.min(ids.length, rowCount - w);
1641
+ const startRow = w;
1642
+ const remap = internDict(p.data.strings);
1643
+ for (let i = 0; i < take; i++) {
1644
+ const r = startRow + i;
1645
+ rowLocalId[r] = ids[i];
1646
+ rowModelIdx[r] = mi;
1647
+ }
1648
+ for (const pc of p.data.columns) {
1649
+ mergeColumn(pc, remap, (i) => startRow + i, take);
1650
+ }
1651
+ if (p.storeyOf.size > 0) {
1652
+ const sc = ensureColumn(STOREY_KEY, "Spatial", "Storey", "text", 140);
1653
+ for (let i = 0; i < take; i++) {
1654
+ const sid = p.storeyOf.get(ids[i]);
1655
+ const name = sid ? p.storeyName.get(sid) : undefined;
1656
+ if (name) sc.text[startRow + i] = internStr(name);
1657
+ }
1658
+ }
1659
+ w += take;
1660
+ });
1661
+ return { notReady };
1662
+ };
1663
+
1664
+ // PASS 2 (background) — fetch pset/quantity columns for ALL rows in batches per
1665
+ // model and merge progressively. Pset-column ops (filter/sort/group/aggregate)
1666
+ // become correct as each batch lands; a subtle note shows until done.
1667
+ const fillPsets = async (token: number) => {
1668
+ for (let mi = 0; mi < modelIdsOrder.length; mi++) {
1669
+ const modelId = modelIdsOrder[mi];
1670
+ const model = fragments.list.get(modelId);
1671
+ if (!model) continue;
1672
+ const ids: number[] = [];
1673
+ const rowOfId = new Map<number, number>();
1674
+ for (let r = 0; r < rowCount; r++) {
1675
+ if (rowModelIdx[r] === mi) {
1676
+ ids.push(rowLocalId[r]);
1677
+ rowOfId.set(rowLocalId[r], r);
1678
+ }
1679
+ }
1680
+ for (let i = 0; i < ids.length; i += PSET_BATCH) {
1681
+ const batch = ids.slice(i, i + PSET_BATCH);
1682
+ let data: FRAGS.TableData;
1683
+ try {
1684
+ data = await model.getTablePsets(batch, { includeTypePsets: true });
1685
+ } catch (error) {
1686
+ console.warn("[data-table] getTablePsets failed", modelId, error);
1687
+ continue;
1688
+ }
1689
+ if (token !== rebuildToken) return;
1690
+ const remap = internDict(data.strings);
1691
+ const bIds = data.localIds; // echoes input order
1692
+ for (const pc of data.columns) {
1693
+ mergeColumn(pc, remap, (j) => rowOfId.get(bIds[j]) ?? -1, bIds.length);
1694
+ }
1695
+ facetCache.clear();
1696
+ // Force a full repaint: merged pset values land in columns that may be
1697
+ // visible (e.g. a restored saved view) AND may change sort/group order,
1698
+ // and the row recycler keeps same-index DOM on a soft refresh.
1699
+ refreshList(true);
1700
+ update({ status: "ready", note: indexNote(rowCount, false) });
1701
+ await new Promise<void>((res) => setTimeout(res, 0)); // yield between batches
1702
+ if (token !== rebuildToken) return;
1703
+ }
1704
+ }
1705
+ facetCache.clear();
1706
+ refreshList(true);
1707
+ update({ status: rowCount > 0 ? "ready" : "empty", note: indexNote(rowCount, true) });
1708
+ if (rowCount > 0) logStoreBytes("full");
1709
+ };
1710
+
1711
+ const indexNote = (n: number, done = true) => {
1712
+ const base =
1713
+ n >= MAX_ROWS
1714
+ ? `${MAX_ROWS.toLocaleString()}+ elements (capped)`
1715
+ : `${n.toLocaleString()} element${n === 1 ? "" : "s"}`;
1716
+ return done ? base : `${base} · loading properties…`;
1717
+ };
1718
+
1719
+ let readyRetries = 0; // bounded retries when a model isn't worker-ready yet
1720
+ const rebuild = async () => {
1721
+ const token = ++rebuildToken;
1722
+ resetStore();
1723
+ facetCache.clear();
1724
+ selectedRowKeys.clear();
1725
+ collapsedGroups.clear();
1726
+ const models = [...fragments.list.values()];
1727
+ visibleCols = [...DEFAULT_VISIBLE];
1728
+ if (models.length === 0) {
1729
+ refreshList();
1730
+ update({ status: "empty", note: "" });
1731
+ return;
1732
+ }
1733
+ update({ status: "loading", note: "Indexing…" });
1734
+ try {
1735
+ const { notReady } = await indexAttributes(token);
1736
+ if (token !== rebuildToken) return;
1737
+ // A model can throw "Model not found" when an onItemSet fires before its
1738
+ // worker is ready. If NOTHING indexed, retry from scratch. If SOME rows
1739
+ // landed (e.g. model #1 ready, model #2 still loading), paint what we have
1740
+ // now AND schedule a retry to pick up the not-yet-ready model(s) — we don't
1741
+ // rely solely on onModelLoaded re-firing for the second model.
1742
+ if (rowCount === 0 && notReady && readyRetries < 8) {
1743
+ readyRetries++;
1744
+ setTimeout(() => {
1745
+ if (token === rebuildToken) void rebuild();
1746
+ }, 500);
1747
+ return;
1748
+ }
1749
+ // Show default-visible columns that exist; else the first 3 discovered.
1750
+ visibleCols = [...DEFAULT_VISIBLE].filter((k) => columns.has(k));
1751
+ if (visibleCols.length === 0) visibleCols = [...columns.keys()].slice(0, 3);
1752
+ // FIRST PAINT now (attributes only) — don't wait for psets.
1753
+ refreshList();
1754
+ update({ status: rowCount > 0 ? "ready" : "empty", note: indexNote(rowCount, rowCount === 0) });
1755
+ if (rowCount > 0) logStoreBytes("attributes");
1756
+ // Some models weren't ready yet but others gave rows → retry (bounded) to
1757
+ // fold the missing model(s) in, while the current rows stay on screen.
1758
+ if (notReady && rowCount > 0 && readyRetries < 8) {
1759
+ readyRetries++;
1760
+ setTimeout(() => {
1761
+ if (token === rebuildToken) void rebuild();
1762
+ }, 600);
1763
+ return; // the retry will run fillPsets once the full set is in
1764
+ }
1765
+ readyRetries = 0; // clean pass — every model in the list indexed
1766
+ // Background pset fill (non-blocking; cancels if a newer rebuild starts).
1767
+ if (rowCount > 0) void fillPsets(token);
1768
+ } catch (error) {
1769
+ if (token !== rebuildToken) return;
1770
+ console.warn("[data-table] index failed", error);
1771
+ update({ status: "empty", note: "" });
1772
+ }
1773
+ };
1774
+
1775
+ // ── Panel chrome (BUI) ─────────────────────────────────────────
1776
+ interface PanelState {
1777
+ status: "loading" | "empty" | "ready";
1778
+ note: string;
1779
+ }
1780
+ let footerEl: HTMLElement | null = null;
1781
+ const syncFooter = () => {
1782
+ if (!footerEl) return;
1783
+ const { count, parts } = footer();
1784
+ footerEl.innerHTML =
1785
+ `<span class="dt-ft-item"><b>${count.toLocaleString()}</b> rows</span>` +
1786
+ parts
1787
+ .map((p) => `<span class="dt-ft-item">${esc(p.label)}: <b>${esc(p.value)}</b></span>`)
1788
+ .join("");
1789
+ };
1790
+
1791
+ const [panel, update] = BUI.Component.create<BUI.Panel, PanelState>(
1792
+ (state) => {
1793
+ const onHostCreated = (el?: Element) => {
1794
+ if (!el || el.contains(viewport)) return;
1795
+ el.appendChild(viewport);
1796
+ render(true);
1797
+ };
1798
+ const onFooterCreated = (el?: Element) => {
1799
+ footerEl = (el as HTMLElement) ?? null;
1800
+ syncFooter();
1801
+ };
1802
+ const colLabel = (k: string) => colMeta(k)?.label ?? k;
1803
+ const opLabel = (op: Op) => [...TEXT_OPS, ...NUM_OPS].find((o) => o.op === op)?.label ?? op;
1804
+ const condLabel = (c: Condition) =>
1805
+ `${colLabel(c.col)} ${opLabel(c.op)}${
1806
+ PRESENCE_OPS.has(c.op) ? "" : ` ${c.op === "between" ? `${c.v}–${c.v2}` : c.v}`
1807
+ }`;
1808
+ const toggleEditor = (k: typeof openEditor) => {
1809
+ openEditor = openEditor === k ? "" : k;
1810
+ update({});
1811
+ };
1812
+
1813
+ return BUI.html`
1814
+ <bim-panel
1815
+ label="Element Data"
1816
+ icon="mdi:table"
1817
+ header-hidden
1818
+ style="width: 100%; height: 100%; pointer-events: auto;"
1819
+ >
1820
+ <style>
1821
+ .dt-vp { height: 100%; overflow: auto; }
1822
+ .dt-vp::-webkit-scrollbar { width: 0.4rem; height: 0.4rem; }
1823
+ .dt-vp::-webkit-scrollbar-thumb { border-radius: 0.25rem; background-color: var(--bim-scrollbar--c, #3C3C41); }
1824
+ .dt-vp::-webkit-scrollbar-track { background-color: var(--bim-scrollbar--bgc, var(--bim-ui_bg-contrast-10)); }
1825
+ /* The intersection of the H+V scrollbars defaults to white — match the surface. */
1826
+ .dt-vp::-webkit-scrollbar-corner { background-color: transparent; }
1827
+ .dt-head { position: sticky; top: 0; z-index: 3; display: flex; height: ${HEAD_H}px;
1828
+ background: var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.1));
1829
+ border-bottom: 1px solid var(--bim-ui_bg-contrast-40, rgba(255,255,255,0.2)); }
1830
+ .dt-hc { position: relative; box-sizing: border-box; flex: 0 0 auto; display: flex; align-items: center; gap: 0.2rem;
1831
+ padding: 0 0.5rem; cursor: pointer; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.01em;
1832
+ color: var(--bim-ui_bg-contrast-80, #c9c9c9); white-space: nowrap; overflow: hidden;
1833
+ border-right: 1px solid var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.08)); }
1834
+ .dt-hc:hover { background: var(--bim-ui_bg-contrast-30, rgba(255,255,255,0.14)); }
1835
+ .dt-hc.sorted { color: var(--bim-ui_accent-base, #6528d7); }
1836
+ .dt-hc-lbl { overflow: hidden; text-overflow: ellipsis; }
1837
+ .dt-hc-arr { flex: 0 0 auto; font-size: 0.9rem; }
1838
+ /* Column resize handle: grab strip on the header's right edge. */
1839
+ .dt-hc-rz { position: absolute; top: 0; right: 0; width: 6px; height: 100%;
1840
+ cursor: col-resize; z-index: 1; touch-action: none; }
1841
+ .dt-hc-rz:hover { background: var(--bim-ui_accent-base, #6528d7); opacity: 0.5; }
1842
+ .dt-body { position: relative; }
1843
+ .dt-row { display: flex; box-sizing: border-box; cursor: pointer;
1844
+ border-bottom: 1px solid var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.07)); }
1845
+ .dt-row:hover { background: var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.06)); }
1846
+ .dt-row.sel { background: var(--bim-ui_accent-base, #6528d7); color: #fff; }
1847
+ .dt-c { box-sizing: border-box; flex: 0 0 auto; display: flex; align-items: center;
1848
+ padding: 0 0.5rem; font-size: 0.74rem; line-height: ${ROW_H}px;
1849
+ color: var(--bim-ui_bg-contrast-100, #e3e3e3); white-space: nowrap; overflow: hidden;
1850
+ text-overflow: ellipsis;
1851
+ border-right: 1px solid var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.05)); }
1852
+ .dt-row.sel .dt-c { color: #fff; }
1853
+ .dt-c.num { justify-content: flex-end; font-variant-numeric: tabular-nums; }
1854
+ /* The ellipsizing element: a bare text node in a flex cell never gets
1855
+ an ellipsis, so the value lives in this span. flex:0 1 auto + min-width:0
1856
+ lets it shrink to the cell width and truncate; for .num cells the
1857
+ cell's flex-end keeps short numbers right-aligned. */
1858
+ .dt-c-tx { flex: 0 1 auto; min-width: 0; overflow: hidden;
1859
+ text-overflow: ellipsis; white-space: nowrap; }
1860
+ /* Force neutral THEME text on every cell value — the .dt-vp prefix
1861
+ lifts specificity over any inherited/external color so values never
1862
+ render red/green/black; selected rows go white. */
1863
+ .dt-vp .dt-c, .dt-vp .dt-c-tx { color: var(--bim-ui_bg-contrast-100, #e3e3e3); }
1864
+ .dt-vp .dt-row.sel .dt-c, .dt-vp .dt-row.sel .dt-c-tx { color: #fff; }
1865
+ /* A group's roll-up cell that's the "varies" sentinel — muted/italic
1866
+ so it reads as informational, not as a real value. */
1867
+ .dt-c.varies { opacity: 0.5; font-style: italic; }
1868
+ /* Group row: a row of column-aligned roll-up cells over a band bg. */
1869
+ .dt-group { display: flex; align-items: center; box-sizing: border-box; cursor: pointer;
1870
+ font-weight: 600; color: var(--bim-ui_bg-contrast-90, #d5d5d5);
1871
+ background: var(--bim-ui_bg-contrast-10, rgba(255,255,255,0.05));
1872
+ border-bottom: 1px solid var(--bim-ui_bg-contrast-30, rgba(255,255,255,0.12)); }
1873
+ .dt-group .dt-c { color: var(--bim-ui_bg-contrast-90, #d5d5d5); }
1874
+ /* Identity cell (first column): caret + group label + count. */
1875
+ .dt-gc-id { gap: 0.3rem; }
1876
+ .dt-g-car { flex: 0 0 auto; color: var(--bim-ui_bg-contrast-60, #9a9a9a); }
1877
+ .dt-g-lbl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1878
+ .dt-g-cnt { flex: 0 0 auto; opacity: 0.7; font-variant-numeric: tabular-nums;
1879
+ padding: 0.02rem 0.4rem; border-radius: 0.6rem; font-size: 0.68rem;
1880
+ background: var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.1)); }
1881
+ .dt-ctrls { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
1882
+ /* Compact toolbar: full-width search + icon controls (no wrap). */
1883
+ .dt-toolbar { display: flex; align-items: stretch; gap: 0.3rem; }
1884
+ .dt-search { flex: 1 1 auto; min-width: 0; }
1885
+ .dt-tbtn { flex: 0 0 auto; }
1886
+ /* Active filter / group chips (shown only when present). */
1887
+ .dt-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; }
1888
+ .dt-chip { display: inline-flex; align-items: center; gap: 0.25rem; max-width: 100%;
1889
+ font-size: 0.68rem; padding: 0.08rem 0.2rem 0.08rem 0.45rem; border-radius: 0.8rem;
1890
+ color: var(--bim-ui_bg-contrast-90, #d5d5d5);
1891
+ background: var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.08));
1892
+ border: 1px solid var(--bim-ui_bg-contrast-30, rgba(255,255,255,0.14)); }
1893
+ .dt-chip.grp bim-icon { font-size: 0.8rem; opacity: 0.7; }
1894
+ .dt-chip-tx { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 9rem; }
1895
+ .dt-chip-x { cursor: pointer; opacity: 0.55; padding: 0 0.2rem; border-radius: 50%; }
1896
+ .dt-chip-x:hover { opacity: 1; background: var(--bim-ui_bg-contrast-30, rgba(255,255,255,0.14)); }
1897
+ .dt-footer { display: flex; align-items: center; gap: 1rem; padding: 0.3rem 0.5rem;
1898
+ font-size: 0.72rem; color: var(--bim-ui_bg-contrast-80, #c9c9c9);
1899
+ border-top: 1px solid var(--bim-ui_bg-contrast-30, rgba(255,255,255,0.12)); }
1900
+ .dt-ft-item b { color: var(--bim-ui_bg-contrast-100, #e3e3e3); }
1901
+ /* Column-picker popover (mounted to body, fixed-positioned). */
1902
+ .dt-picker { z-index: 9999; width: 260px; max-height: 360px; display: flex; flex-direction: column;
1903
+ background: var(--bim-ui_bg-base, #1b1b1f); color: var(--bim-ui_bg-contrast-100, #e3e3e3);
1904
+ border: 1px solid var(--bim-ui_bg-contrast-40, rgba(255,255,255,0.2)); border-radius: 0.4rem;
1905
+ box-shadow: 0 6px 20px rgba(0,0,0,0.4); font-size: 0.76rem; overflow: hidden; }
1906
+ .dt-pk-hd { display: flex; align-items: center; justify-content: space-between; padding: 0.45rem 0.6rem;
1907
+ font-weight: 600; border-bottom: 1px solid var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.1)); }
1908
+ .dt-pk-x { cursor: pointer; opacity: 0.7; }
1909
+ .dt-pk-x:hover { opacity: 1; }
1910
+ .dt-pk-search { margin: 0.4rem 0.6rem; } /* bim-text-input — self-themed */
1911
+ .dt-pk-list { flex: 1 1 auto; overflow-y: auto; padding: 0 0.3rem 0.4rem; }
1912
+ .dt-pk-list::-webkit-scrollbar { width: 0.4rem; }
1913
+ .dt-pk-list::-webkit-scrollbar-thumb { border-radius: 0.25rem; background-color: #3C3C41; }
1914
+ .dt-pk-grp { padding: 0.4rem 0.4rem 0.2rem; font-size: 0.68rem; font-weight: 600; text-transform: uppercase;
1915
+ letter-spacing: 0.04em; color: var(--bim-ui_bg-contrast-60, #99a0ae); }
1916
+ .dt-pk-item { display: flex; align-items: center; gap: 0.4rem; padding: 0.2rem 0.4rem; border-radius: 0.25rem; }
1917
+ .dt-pk-item:hover { background: var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.08)); }
1918
+ .dt-pk-lbl { display: flex; align-items: center; gap: 0.4rem; flex: 1 1 auto; min-width: 0; cursor: pointer; }
1919
+ .dt-pk-lbl span { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1920
+ .dt-pk-num { flex: 0 0 auto; opacity: 0.5; font-weight: 700; }
1921
+ .dt-pk-warn { flex: 0 0 auto; color: var(--bim-ui_color-warning, #e0a23b); font-size: 0.85rem; }
1922
+ .dt-pk-item bim-checkbox { flex: 1 1 auto; min-width: 0; }
1923
+ /* "Show by property set" provenance toggle at the foot of the picker. */
1924
+ .dt-pk-mode { display: flex; align-items: center; gap: 0.4rem; cursor: pointer;
1925
+ margin: 0 0.6rem 0.4rem; padding-top: 0.3rem; padding-bottom: 0.4rem; font-size: 0.7rem;
1926
+ color: var(--bim-ui_bg-contrast-80, #c9c9c9);
1927
+ border-top: 1px solid var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.1));
1928
+ border-bottom: 1px solid var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.1)); }
1929
+ .dt-pk-empty { padding: 0.6rem; opacity: 0.6; }
1930
+ /* Structured-filter builder (AND-of-ORs condition rows). */
1931
+ .dtf { display: flex; flex-direction: column; gap: 0.3rem; }
1932
+ .dtf-head { display: flex; align-items: center; justify-content: space-between; }
1933
+ .dtf-title { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.72rem;
1934
+ font-weight: 600; color: var(--bim-ui_bg-contrast-80, #c9c9c9); }
1935
+ .dtf-actions { display: inline-flex; gap: 0.3rem; }
1936
+ .dtf-row { display: flex; align-items: center; gap: 0.25rem; flex-wrap: wrap; }
1937
+ .dtf-where { font-size: 0.68rem; opacity: 0.55; width: 2.6rem; }
1938
+ /* bim-text-input operand — layout only; the component is self-themed. */
1939
+ .dtf-val { flex: 1 1 5rem; min-width: 4rem; }
1940
+ .dtf-and { font-size: 0.68rem; opacity: 0.55; }
1941
+ /* Multi-level group-by control. */
1942
+ .dtg { display: flex; align-items: center; gap: 0.3rem; flex-wrap: wrap; }
1943
+ .dtg-title { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.72rem;
1944
+ font-weight: 600; color: var(--bim-ui_bg-contrast-80, #c9c9c9); }
1945
+ .dtg-chip { display: inline-flex; align-items: center; gap: 0.15rem; }
1946
+ .dtg-arrow { opacity: 0.45; font-size: 0.8rem; }
1947
+ /* Sort-order badge in a header cell (multi-key sort) — neutral. */
1948
+ .dt-hc-ord { flex: 0 0 auto; font-size: 0.6rem; font-weight: 700; line-height: 1;
1949
+ padding: 0.05rem 0.2rem; border-radius: 0.5rem;
1950
+ color: var(--bim-ui_bg-contrast-100, #e3e3e3);
1951
+ background: var(--bim-ui_bg-contrast-40, rgba(255,255,255,0.2)); }
1952
+ /* Saved-views control. */
1953
+ .dtv { display: flex; align-items: center; gap: 0.3rem; flex-wrap: wrap; }
1954
+ .dtv-title { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.72rem;
1955
+ font-weight: 600; color: var(--bim-ui_bg-contrast-80, #c9c9c9); }
1956
+ /* bim-text-input view-name — layout only; the component is self-themed. */
1957
+ .dtv-name { flex: 1 1 7rem; min-width: 6rem; }
1958
+ </style>
1959
+ <div style="display: flex; flex-direction: column; height: 100%; width: 100%;">
1960
+ ${cardHeader("mdi:table", "Element Data", "1.1rem")}
1961
+ ${state.status === "empty"
1962
+ ? BUI.html`<div style="padding: 0.75rem 1.1rem;"><bim-label style="opacity: 0.6; white-space: normal;">No model loaded. Load a model to populate the table.</bim-label></div>`
1963
+ : BUI.html`
1964
+ <div style="flex: 0 0 auto; display: flex; flex-direction: column; gap: 0.4rem; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.1));">
1965
+ <!-- ONE compact toolbar: full-width search + icon controls. -->
1966
+ <div class="dt-toolbar">
1967
+ <bim-text-input
1968
+ class="dt-search" icon="mdi:magnify" icon-inside placeholder="Search…"
1969
+ .value=${query}
1970
+ @input=${(e: Event) => {
1971
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1972
+ const v = String((e.target as any).value ?? "");
1973
+ if (searchTimer !== undefined) clearTimeout(searchTimer);
1974
+ searchTimer = window.setTimeout(() => {
1975
+ query = v;
1976
+ viewport.scrollTop = 0;
1977
+ refreshList();
1978
+ }, 200);
1979
+ }}
1980
+ ></bim-text-input>
1981
+ <bim-button class="dt-tbtn" icon="mdi:table-column-plus-after"
1982
+ @click=${(e: Event) => openPicker(e.currentTarget as HTMLElement)}
1983
+ ><bim-tooltip>Columns</bim-tooltip></bim-button>
1984
+ <bim-button class="dt-tbtn" icon="mdi:filter-variant"
1985
+ ?active=${conditions.length > 0 || openEditor === "filter"}
1986
+ @click=${() => toggleEditor("filter")}
1987
+ ><bim-tooltip>Filter</bim-tooltip></bim-button>
1988
+ <bim-button class="dt-tbtn" icon="mdi:format-list-group"
1989
+ ?active=${groupCols.length > 0 || openEditor === "group"}
1990
+ @click=${() => toggleEditor("group")}
1991
+ ><bim-tooltip>Group</bim-tooltip></bim-button>
1992
+ ${client && projectId
1993
+ ? BUI.html`<bim-button class="dt-tbtn" icon="mdi:bookmark-multiple-outline"
1994
+ ?active=${!!activeViewName || openEditor === "views"}
1995
+ @click=${() => toggleEditor("views")}
1996
+ ><bim-tooltip>Saved views</bim-tooltip></bim-button>`
1997
+ : null}
1998
+ <bim-button class="dt-tbtn" icon="mdi:file-delimited-outline"
1999
+ @click=${exportCsv}
2000
+ ><bim-tooltip>Export CSV</bim-tooltip></bim-button>
2001
+ </div>
2002
+
2003
+ <!-- Active filter + group chips — only when present. -->
2004
+ ${conditions.length || groupCols.length
2005
+ ? BUI.html`<div class="dt-chips">
2006
+ ${conditions.map(
2007
+ (c) => BUI.html`<span class="dt-chip" title=${condLabel(c)}>
2008
+ <span class="dt-chip-tx">${condLabel(c)}</span>
2009
+ <span class="dt-chip-x" @click=${() => removeCondition(c.id)}>✕</span>
2010
+ </span>`,
2011
+ )}
2012
+ ${groupCols.map(
2013
+ (g, i) => BUI.html`<span class="dt-chip grp" title=${colLabel(g)}>
2014
+ <bim-icon icon="mdi:format-list-group"></bim-icon>
2015
+ <span class="dt-chip-tx">${colLabel(g)}</span>
2016
+ <span class="dt-chip-x" @click=${() => { groupCols.splice(i, 1); regroup(); }}>✕</span>
2017
+ </span>`,
2018
+ )}
2019
+ </div>`
2020
+ : null}
2021
+
2022
+ <!-- Saved-views editor (on demand). -->
2023
+ ${openEditor === "views" && client && projectId
2024
+ ? BUI.html`
2025
+ <div class="dtv">
2026
+ ${dropdown(
2027
+ [
2028
+ { value: "", label: "— Saved views —" },
2029
+ ...savedViews.map((sv) => ({
2030
+ value: sv.name,
2031
+ label: `${sv.name}${activeViewName === sv.name && isDirty() ? " •" : ""}`,
2032
+ })),
2033
+ ],
2034
+ activeViewName ?? "",
2035
+ (v) => { if (v) restoreView(v); },
2036
+ "9rem",
2037
+ )}
2038
+ ${savingNew
2039
+ ? BUI.html`
2040
+ <bim-text-input class="dtv-name" placeholder="View name" .value=${newViewName}
2041
+ @input=${(e: Event) => { newViewName = String((e.target as { value?: string }).value ?? ""); }}
2042
+ @keydown=${(e: KeyboardEvent) => {
2043
+ if (e.key === "Enter" && newViewName.trim()) persistView(newViewName.trim());
2044
+ else if (e.key === "Escape") { savingNew = false; update({}); }
2045
+ }}></bim-text-input>
2046
+ <bim-button label="Save" @click=${() => { if (newViewName.trim()) persistView(newViewName.trim()); }}></bim-button>
2047
+ <bim-button icon="mdi:close" @click=${() => { savingNew = false; newViewName = ""; update({}); }}><bim-tooltip>Cancel</bim-tooltip></bim-button>`
2048
+ : BUI.html`<bim-button label="Save as…" @click=${() => { savingNew = true; newViewName = activeViewName ?? ""; update({}); }}></bim-button>`}
2049
+ ${activeViewName && !savingNew
2050
+ ? BUI.html`
2051
+ <bim-button label="Update" ?disabled=${!isDirty()} @click=${() => persistView(activeViewName!)}></bim-button>
2052
+ <bim-button label="Delete" @click=${() => deleteView(activeViewName!)}></bim-button>`
2053
+ : null}
2054
+ </div>`
2055
+ : null}
2056
+
2057
+ <!-- Filter editor (on demand). -->
2058
+ ${openEditor === "filter"
2059
+ ? BUI.html`<div class="dtf">
2060
+ <div class="dtf-head">
2061
+ <span class="dtf-title"><bim-icon icon="mdi:filter-variant"></bim-icon>Filters${conditions.length ? ` (${conditions.length})` : ""}</span>
2062
+ <span class="dtf-actions">
2063
+ ${conditions.length
2064
+ ? BUI.html`<bim-button label="Clear" @click=${() => { conditions = []; viewport.scrollTop = 0; refreshList(); update({}); }}></bim-button>`
2065
+ : null}
2066
+ <bim-button label="Add filter" icon="mdi:plus" @click=${addCondition}></bim-button>
2067
+ </span>
2068
+ </div>
2069
+ ${conditions.map((c, i) => {
2070
+ const kind = colMeta(c.col)?.kind ?? "text";
2071
+ const ops = kind === "number" ? NUM_OPS : TEXT_OPS;
2072
+ const needOperand = !PRESENCE_OPS.has(c.op);
2073
+ const between = c.op === "between";
2074
+ return BUI.html`
2075
+ <div class="dtf-row">
2076
+ ${i > 0
2077
+ ? dropdown(
2078
+ [{ value: "AND", label: "AND" }, { value: "OR", label: "OR" }],
2079
+ c.conj,
2080
+ (v) => { c.conj = v as "AND" | "OR"; refreshList(); },
2081
+ "3.6rem",
2082
+ )
2083
+ : BUI.html`<span class="dtf-where">Where</span>`}
2084
+ ${dropdown(
2085
+ allColumns().map((cc) => ({ value: cc.key, label: cc.label })),
2086
+ c.col,
2087
+ (v) => onColChange(c, v),
2088
+ "8rem",
2089
+ )}
2090
+ ${dropdown(
2091
+ ops.map((o) => ({ value: o.op, label: o.label })),
2092
+ c.op,
2093
+ (v) => { c.op = v as Op; refreshList(); update({}); },
2094
+ "6rem",
2095
+ )}
2096
+ ${needOperand
2097
+ ? BUI.html`<bim-text-input class="dtf-val" type=${kind === "number" ? "number" : "text"} .value=${c.v}
2098
+ placeholder="value"
2099
+ @input=${(e: Event) => { c.v = String((e.target as { value?: string }).value ?? ""); debouncedFilter(); }}></bim-text-input>`
2100
+ : null}
2101
+ ${between
2102
+ ? BUI.html`<span class="dtf-and">and</span>
2103
+ <bim-text-input class="dtf-val" type="number" .value=${c.v2} placeholder="value"
2104
+ @input=${(e: Event) => { c.v2 = String((e.target as { value?: string }).value ?? ""); debouncedFilter(); }}></bim-text-input>`
2105
+ : null}
2106
+ <bim-button icon="mdi:close" @click=${() => removeCondition(c.id)}><bim-tooltip>Remove condition</bim-tooltip></bim-button>
2107
+ </div>`;
2108
+ })}
2109
+ </div>`
2110
+ : null}
2111
+
2112
+ <!-- Group editor (on demand). -->
2113
+ ${openEditor === "group"
2114
+ ? BUI.html`<div class="dtg">
2115
+ <span class="dtg-title"><bim-icon icon="mdi:format-list-group"></bim-icon>Group by</span>
2116
+ ${groupCols.map(
2117
+ (gc, i) => BUI.html`
2118
+ <span class="dtg-chip">
2119
+ ${i > 0 ? BUI.html`<span class="dtg-arrow">›</span>` : null}
2120
+ ${dropdown(
2121
+ allColumns().map((cc) => ({ value: cc.key, label: cc.label })),
2122
+ gc,
2123
+ (v) => { groupCols[i] = v; regroup(); },
2124
+ "8rem",
2125
+ )}
2126
+ <bim-button icon="mdi:close" @click=${() => { groupCols.splice(i, 1); regroup(); }}><bim-tooltip>Remove level</bim-tooltip></bim-button>
2127
+ </span>`,
2128
+ )}
2129
+ <bim-button label="Level" icon="mdi:plus" @click=${addGroupLevel}></bim-button>
2130
+ ${groupCols.length
2131
+ ? BUI.html`<bim-button label="Ungroup" @click=${() => { groupCols = []; regroup(); }}></bim-button>`
2132
+ : BUI.html`<span style="font-size: 0.68rem; opacity: 0.5; color: var(--bim-ui_bg-contrast-80, #c9c9c9);">none</span>`}
2133
+ </div>`
2134
+ : null}
2135
+
2136
+ ${state.note
2137
+ ? BUI.html`<div style="font-size: 0.7rem; opacity: 0.6; color: var(--bim-ui_bg-contrast-80, #c9c9c9);">${state.note}</div>`
2138
+ : null}
2139
+ </div>
2140
+ ${state.status === "loading"
2141
+ ? BUI.html`<div style="padding: 0.5rem 1.1rem;"><bim-label style="opacity: 0.6;">Indexing…</bim-label></div>`
2142
+ : null}
2143
+ <div
2144
+ ${BUI.ref(onHostCreated)}
2145
+ style=${BUI.styleMap({ flex: "1 1 auto", minHeight: "0" })}
2146
+ ></div>
2147
+ <div class="dt-footer" ${BUI.ref(onFooterCreated)}></div>`}
2148
+ </div>
2149
+ </bim-panel>
2150
+ `;
2151
+ },
2152
+ { status: "loading", note: "" },
2153
+ );
2154
+
2155
+ // ── 3D → table selection reflection ────────────────────────────
2156
+ highlighter.events.select.onHighlight.add((modelIdMap: OBC.ModelIdMap) => {
2157
+ // Skip the echo of our own row-click drive (avoids feedback loop).
2158
+ if (pendingInternal > 0) {
2159
+ pendingInternal--;
2160
+ return;
2161
+ }
2162
+ selectedRowKeys.clear();
2163
+ for (const [modelId, set] of Object.entries(modelIdMap)) {
2164
+ for (const id of set) selectedRowKeys.add(`${modelId}:${id}`);
2165
+ }
2166
+ render(true);
2167
+ scrollSelectionIntoView();
2168
+ });
2169
+
2170
+ highlighter.events.select.onClear.add(() => {
2171
+ if (selectedRowKeys.size === 0) return;
2172
+ selectedRowKeys.clear();
2173
+ render(true);
2174
+ });
2175
+
2176
+ // ── Triggers ───────────────────────────────────────────────────
2177
+ // Index whatever is already in fragments.list at construction (the panel is
2178
+ // lazy — it may be created AFTER the auto-loaded model arrived, so onModelLoaded
2179
+ // would never fire for it). Then (re)index on every model load. We listen to
2180
+ // BOTH the worker-ready signal (onModelLoaded — the reliable one) AND the raw
2181
+ // list add (onItemSet) as a fallback: an early onItemSet may fail with "Model
2182
+ // not found", but the rebuildToken makes the later successful pass win, so an
2183
+ // empty table can never get stuck if onModelLoaded is missed.
2184
+ // LAZY indexing — the per-model `getTableData` build must NEVER run on the
2185
+ // model-load path: firing it on every onModelLoaded/onItemSet (even with the
2186
+ // panel CLOSED) would contend with the fragments worker during streaming and
2187
+ // make hover/select/auto-anchor crawl for the whole load window. So we only
2188
+ // index when the Data panel is actually VISIBLE. Model changes just mark the
2189
+ // index dirty; the (re)build happens the next time the panel is shown — by
2190
+ // then the model has finished streaming, so there's no contention.
2191
+ let rebuildScheduled = false;
2192
+ let indexDirty = true; // models already in fragments.list need a first index
2193
+ let panelVisible = false;
2194
+ const maybeRebuild = () => {
2195
+ if (!panelVisible || !indexDirty || rebuildScheduled) return;
2196
+ rebuildScheduled = true;
2197
+ queueMicrotask(() => {
2198
+ rebuildScheduled = false;
2199
+ indexDirty = false;
2200
+ void rebuild();
2201
+ });
2202
+ };
2203
+ const markDirty = () => {
2204
+ indexDirty = true;
2205
+ maybeRebuild(); // re-index immediately only if the panel is open right now
2206
+ };
2207
+ fragments.core.onModelLoaded.add(markDirty);
2208
+ fragments.list.onItemSet.add(markDirty);
2209
+ fragments.list.onItemDeleted.add(markDirty);
2210
+ // Flip on when the docked panel is shown by the layout switcher, off when hidden.
2211
+ const visObserver = new IntersectionObserver((entries) => {
2212
+ const shown = entries.some((e) => e.isIntersecting);
2213
+ if (shown) {
2214
+ panelVisible = true;
2215
+ maybeRebuild();
2216
+ } else {
2217
+ panelVisible = false;
2218
+ }
2219
+ });
2220
+ visObserver.observe(panel);
2221
+ void loadSavedViews(); // hydrate the Views dropdown from __project_data/bim-viewer/
2222
+
2223
+ return panel;
2224
+ };