@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,1048 @@
1
+ import * as OBC from "@thatopen/components";
2
+ import * as OBF from "@thatopen/components-front";
3
+ import * as BUI from "@thatopen/ui";
4
+ import { cardHeader } from "./card-header";
5
+ import { toolPlaceholderUri } from "../assets/tool-placeholder";
6
+
7
+ // ── Category icons / labels (copied from model-tree.ts so the two stay in
8
+ // sync without a cross-import; same iconify/mdi names bim-icon takes). ──
9
+ const prettyCategory = (category: string) => {
10
+ const base = category.replace(/^IFC/i, "");
11
+ if (!base) return category || "";
12
+ return base.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (c) => c.toUpperCase());
13
+ };
14
+ const CATEGORY_ICONS: Record<string, string> = {
15
+ IFCPROJECT: "mdi:sitemap",
16
+ IFCSITE: "mdi:terrain",
17
+ IFCBUILDING: "mdi:office-building",
18
+ IFCBUILDINGSTOREY: "mdi:layers",
19
+ IFCSPACE: "mdi:floor-plan",
20
+ IFCZONE: "mdi:select-group",
21
+ IFCWALL: "mdi:wall",
22
+ IFCWALLSTANDARDCASE: "mdi:wall",
23
+ IFCCURTAINWALL: "mdi:wall",
24
+ IFCSLAB: "mdi:floor-plan",
25
+ IFCROOF: "mdi:home-roof",
26
+ IFCCOLUMN: "mdi:view-column",
27
+ IFCBEAM: "mdi:minus",
28
+ IFCMEMBER: "mdi:minus",
29
+ IFCPLATE: "mdi:rectangle-outline",
30
+ IFCFOOTING: "mdi:foundation",
31
+ IFCPILE: "mdi:format-vertical-align-bottom",
32
+ IFCSTAIR: "mdi:stairs",
33
+ IFCSTAIRFLIGHT: "mdi:stairs",
34
+ IFCRAMP: "mdi:slope-uphill",
35
+ IFCRAMPFLIGHT: "mdi:slope-uphill",
36
+ IFCRAILING: "mdi:fence",
37
+ IFCCOVERING: "mdi:texture-box",
38
+ IFCDOOR: "mdi:door",
39
+ IFCWINDOW: "mdi:window-closed-variant",
40
+ IFCOPENINGELEMENT: "mdi:vector-rectangle",
41
+ IFCFURNISHINGELEMENT: "mdi:sofa",
42
+ IFCFURNITURE: "mdi:sofa",
43
+ IFCSANITARYTERMINAL: "mdi:toilet",
44
+ IFCPIPESEGMENT: "mdi:pipe",
45
+ IFCFLOWSEGMENT: "mdi:pipe",
46
+ IFCDUCTSEGMENT: "mdi:pipe",
47
+ IFCPIPEFITTING: "mdi:pipe-disconnected",
48
+ IFCDUCTFITTING: "mdi:pipe-disconnected",
49
+ IFCCABLECARRIERSEGMENT: "mdi:pipe",
50
+ IFCFLOWTERMINAL: "mdi:water-pump",
51
+ IFCLIGHTFIXTURE: "mdi:lightbulb",
52
+ IFCOUTLET: "mdi:power-socket",
53
+ IFCFLOWCONTROLLER: "mdi:valve",
54
+ IFCBUILDINGELEMENTPROXY: "mdi:cube-outline",
55
+ IFCANNOTATION: "mdi:tag-outline",
56
+ };
57
+ const iconFor = (category: string) =>
58
+ CATEGORY_ICONS[(category || "").toUpperCase()] ?? "mdi:cube-outline";
59
+
60
+ /**
61
+ * Properties panel — VIRTUALIZED (windowed) like the model tree. Multi-selecting
62
+ * many elements stacks hundreds of attribute/pset rows; rendering them all as
63
+ * DOM over the WebGL canvas costs per-frame compositing. So we flatten the
64
+ * selected elements into a single uniform-height row list (element header,
65
+ * attribute rows, pset header, pset rows) and render only the rows in the scroll
66
+ * window, recycled on scroll.
67
+ *
68
+ * Trade-off for fixed-height windowing: values render on one line with ellipsis
69
+ * (full text on hover via `title`), rather than wrapping.
70
+ *
71
+ * Listens to the Highlighter "select" set (tree/viewport drive it), reads
72
+ * attributes + IFC psets via `getItemsData` (IsDefinedBy relation). Returns the
73
+ * panel element; the caller mounts it.
74
+ */
75
+ interface PropertyRow {
76
+ name: string;
77
+ value: string;
78
+ }
79
+ interface PropertySet {
80
+ name: string;
81
+ props: PropertyRow[];
82
+ }
83
+ interface ElementProps {
84
+ title: string; // Name, else Category, else #localId
85
+ attributes: PropertyRow[];
86
+ psets: PropertySet[];
87
+ }
88
+
89
+ interface PanelState {
90
+ empty: boolean;
91
+ loading: boolean;
92
+ message: string;
93
+ note: string; // e.g. "Showing 40 of 120 selected"
94
+ }
95
+
96
+ const EMPTY_MESSAGE = "Select an element to see its properties.";
97
+ const MAX_ELEMENTS = 40; // cap fetch+parse (a storey select can be hundreds)
98
+ const BUFFER = 6;
99
+ // Rows wrap (no ellipsis), so heights vary. The virtualizer starts from these
100
+ // per-type estimates and corrects each row to its real height after it renders.
101
+ const EST: Record<FlatRow["t"], number> = {
102
+ elh: 30,
103
+ psh: 28,
104
+ kv: 26,
105
+ kvvar: 26, // aggregated "Varies" property row (muted)
106
+ msg: 26,
107
+ sp: 10,
108
+ cat: 30, // category breakdown row (click-to-sub-select)
109
+ crumb: 28, // breadcrumb / back chip
110
+ };
111
+
112
+ // Multi-select aggregation: we fetch the `_category` attribute for the selected
113
+ // ids in the fragments WORKER (off the main thread) and tally them in a single
114
+ // bounded main-thread pass. Very large selections are SAMPLED — we fetch+tally
115
+ // at most AGG_CAP ids (with a "showing N of M" note) so the main-thread tally
116
+ // is always bounded and never blocks on a million-item synchronous loop.
117
+ const AGG_CAP = 30000; // max ids fetched + tallied for the category breakdown
118
+ const AGG_BATCH = 5000; // ids per worker getItemsData call (per model, batched)
119
+ // Property aggregation (shared-vs-varying) fetches FULL item data — attributes +
120
+ // IsDefinedBy psets, the same expensive shape single-select uses — so its sample
121
+ // cap is far smaller than the cheap _category-only breakdown. We fetch+parse at
122
+ // most PROP_AGG_CAP items (per-model batched) and tally them in one bounded pass.
123
+ const PROP_AGG_CAP = 300; // max items fetched + tallied for property aggregation
124
+ const PROP_AGG_BATCH = 100; // ids per worker getItemsData call (full-data path)
125
+ const PROP_VARIES_SHOW = 4; // ≤ this many distinct values → list them, else "N values"
126
+
127
+ const ATTR_LABELS: Record<string, string> = {
128
+ _category: "Category",
129
+ _localId: "LocalId",
130
+ _guid: "Guid",
131
+ };
132
+
133
+ const esc = (s: string) =>
134
+ s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
135
+
136
+ // Parse one element's getItemsData result into attributes + psets.
137
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
138
+ const parseElement = (data: any, localId: number): ElementProps => {
139
+ const attributes: PropertyRow[] = [];
140
+ let name = "";
141
+ let category = "";
142
+ for (const [key, attr] of Object.entries(data ?? {})) {
143
+ if (Array.isArray(attr)) continue;
144
+ if (attr && typeof attr === "object" && "value" in attr) {
145
+ const value = (attr as { value: unknown }).value;
146
+ if (value === null || value === undefined || value === "") continue;
147
+ attributes.push({ name: ATTR_LABELS[key] ?? key, value: String(value) });
148
+ if (key === "Name") name = String(value);
149
+ if (key === "_category") category = String(value);
150
+ }
151
+ }
152
+ const psets: PropertySet[] = [];
153
+ const rawPsets = data?.IsDefinedBy;
154
+ if (Array.isArray(rawPsets)) {
155
+ for (const pset of rawPsets) {
156
+ const psetName = pset?.Name;
157
+ const hasProperties = pset?.HasProperties;
158
+ if (!(psetName && "value" in psetName && Array.isArray(hasProperties))) continue;
159
+ const props: PropertyRow[] = [];
160
+ for (const prop of hasProperties) {
161
+ const pName = prop?.Name;
162
+ const nominal = prop?.NominalValue;
163
+ if (!(pName && "value" in pName && nominal && "value" in nominal)) continue;
164
+ if (pName.value == null || nominal.value == null) continue;
165
+ props.push({ name: String(pName.value), value: String(nominal.value) });
166
+ }
167
+ psets.push({ name: String(psetName.value), props });
168
+ }
169
+ }
170
+ const title = name || category || `#${localId}`;
171
+ return { title, attributes, psets };
172
+ };
173
+
174
+ // One flat row of the windowed list.
175
+ type FlatRow =
176
+ | { t: "elh"; title: string }
177
+ | { t: "psh"; name: string }
178
+ | { t: "kv"; name: string; value: string }
179
+ | { t: "kvvar"; name: string; value: string } // muted "Varies" agg-property row
180
+ | { t: "msg"; text: string }
181
+ | { t: "sp" }
182
+ | { t: "crumb"; depth: number } // breadcrumb / back chip (multi-select)
183
+ | { t: "cat"; category: string; count: number }; // clickable category breakdown row
184
+
185
+ // ── Multi-select aggregation model ─────────────────────────────────
186
+ // A per-model selection: modelId → Set<localId>. We aggregate by asking the
187
+ // fragments WORKER for each item's `_category` (minimal attribute fetch, off
188
+ // the main thread) and tallying the returned categories in one bounded pass.
189
+ // This is the foundation for property aggregation generally: swap/extend the
190
+ // fetched attribute(s) and the per-item tally to break down by any property.
191
+ type Aggregation = {
192
+ total: number; // total ids in the live selection
193
+ sampled: number; // ids actually fetched + tallied (≤ total, capped at AGG_CAP)
194
+ models: number; // distinct models in the set
195
+ cats: { category: string; count: number }[]; // sorted desc by count
196
+ // Property aggregation (shared-vs-varying), computed lazily on a SECOND, much
197
+ // smaller sample (PROP_AGG_CAP) via the expensive full-data fetch. Cached on
198
+ // the same fingerprint entry as the breakdown above. `undefined` = not yet
199
+ // requested/in flight; once set it stays on the cached Aggregation.
200
+ props?: PropAggregation;
201
+ };
202
+
203
+ // One aggregated property: shared across the whole sample (a single `value`) or
204
+ // VARYING (`varies` true, `distinct` distinct-value count, optional small sample
205
+ // of the values for a compact preview).
206
+ type AggProp = {
207
+ name: string;
208
+ varies: boolean;
209
+ value: string; // the shared value (when !varies)
210
+ distinct: number; // number of distinct non-empty values seen (when varies)
211
+ values?: string[]; // up to PROP_VARIES_SHOW distinct values, for a compact hint
212
+ present: number; // how many sampled items had this property at all
213
+ };
214
+
215
+ // Shared-vs-varying aggregation grouped like the single-select view: an
216
+ // attributes band, then one band per property-set name.
217
+ type PropAggregation = {
218
+ sampled: number; // items fetched + parsed for the property tally (≤ PROP_AGG_CAP)
219
+ total: number; // total ids in the live selection (for the "N of M" note)
220
+ attributes: AggProp[];
221
+ psets: { name: string; props: AggProp[] }[];
222
+ };
223
+
224
+ export const propertiesPanel = (components: OBC.Components) => {
225
+ const fragments = components.get(OBC.FragmentsManager);
226
+ const highlighter = components.get(OBF.Highlighter);
227
+ const selectName = highlighter.config.selectName;
228
+
229
+ let elements: ElementProps[] = [];
230
+ let query = "";
231
+ let flat: FlatRow[] = [];
232
+
233
+ // ── Multi-select aggregation state ─────────────────────────────────
234
+ // `mode` switches the windowed list between the single-element property
235
+ // view ("props") and the aggregation/insights view ("agg").
236
+ let mode: "props" | "agg" = "props";
237
+ let agg: Aggregation | null = null;
238
+ // Non-null while the property aggregation is unavailable for a reason other
239
+ // than "still loading" (e.g. the full-data fetch failed) — shown in place of
240
+ // the "Aggregating properties…" placeholder. Cleared on each new selection.
241
+ let propAggError: string | null = null;
242
+ // Selection stack: each entry is the OBC.ModelIdMap that produced the view
243
+ // BELOW the current one, so "Back" restores the previous (wider) selection.
244
+ // Index-clicking a category pushes the current map and re-selects the subset.
245
+ // BOUNDED to SELSTACK_MAX so a runaway loop can never grow it without limit.
246
+ const SELSTACK_MAX = 32;
247
+ const selStack: OBC.ModelIdMap[] = [];
248
+
249
+ // Aggregation result cache, keyed on a cheap fingerprint of the selection
250
+ // (per-model size + a sampled-id hash) → re-selecting the same set is instant.
251
+ // BOUNDED tiny LRU: only the current + a few recent results are retained so
252
+ // the cache can never grow without bound (a crash cause we are fixing).
253
+ const AGG_CACHE_MAX = 8;
254
+ const aggCache = new Map<string, Aggregation>();
255
+ const cacheGet = (key: string): Aggregation | undefined => {
256
+ const hit = aggCache.get(key);
257
+ if (hit) {
258
+ // Refresh LRU recency: re-insert so it becomes most-recently-used.
259
+ aggCache.delete(key);
260
+ aggCache.set(key, hit);
261
+ }
262
+ return hit;
263
+ };
264
+ const cachePut = (key: string, value: Aggregation) => {
265
+ aggCache.delete(key);
266
+ aggCache.set(key, value);
267
+ while (aggCache.size > AGG_CACHE_MAX) {
268
+ const oldest = aggCache.keys().next().value;
269
+ if (oldest === undefined) break;
270
+ aggCache.delete(oldest);
271
+ }
272
+ };
273
+ // Variable-height windowing: per-row measured heights + their prefix-sum
274
+ // offsets (offsets[i] = top of row i; offsets[flat.length] = total height).
275
+ let heights: number[] = [];
276
+ let offsets: number[] = [0];
277
+
278
+ // Virtualization DOM (created once).
279
+ const viewport = document.createElement("div");
280
+ viewport.className = "prop-vp";
281
+ const sizer = document.createElement("div");
282
+ sizer.style.position = "relative";
283
+ sizer.style.width = "100%";
284
+ const content = document.createElement("div");
285
+ content.style.position = "absolute";
286
+ content.style.top = "0";
287
+ content.style.left = "0";
288
+ content.style.right = "0";
289
+ sizer.appendChild(content);
290
+ viewport.appendChild(sizer);
291
+
292
+ // ── Flatten the aggregation → uniform rows (category breakdown +
293
+ // shared-vs-varying property aggregation). READ-ONLY: no row here drives
294
+ // selection (cat rows render but, see content click handler, are inert). ──
295
+ const buildAggFlat = () => {
296
+ const out: FlatRow[] = [];
297
+ const a = agg;
298
+ if (!a) {
299
+ flat = out;
300
+ return;
301
+ }
302
+ const q = query.trim().toLowerCase();
303
+ out.push({ t: "psh", name: "Category breakdown" });
304
+ if (a.cats.length === 0) {
305
+ out.push({ t: "msg", text: "No categories found in selection." });
306
+ }
307
+ // Search filters the category list (cheap; list is small).
308
+ for (const c of a.cats) {
309
+ if (q && !prettyCategory(c.category).toLowerCase().includes(q) && !c.category.toLowerCase().includes(q)) {
310
+ continue;
311
+ }
312
+ out.push({ t: "cat", category: c.category, count: c.count });
313
+ }
314
+ flat = out;
315
+ };
316
+
317
+ // ── Flatten selected elements → uniform rows (with the search filter) ──
318
+ const buildFlat = () => {
319
+ if (mode === "agg") {
320
+ buildAggFlat();
321
+ return;
322
+ }
323
+ const q = query.trim().toLowerCase();
324
+ const match = (r: PropertyRow) =>
325
+ !q || r.name.toLowerCase().includes(q) || r.value.toLowerCase().includes(q);
326
+ const out: FlatRow[] = [];
327
+ let first = true;
328
+ for (const el of elements) {
329
+ const attrs = q ? el.attributes.filter(match) : el.attributes;
330
+ const psets = q
331
+ ? el.psets.map((p) => ({ ...p, props: p.props.filter(match) })).filter((p) => p.props.length)
332
+ : el.psets;
333
+ if (q && attrs.length === 0 && psets.length === 0) continue;
334
+ if (!first) out.push({ t: "sp" });
335
+ first = false;
336
+ out.push({ t: "elh", title: el.title });
337
+ if (attrs.length === 0 && !q) out.push({ t: "msg", text: "No attributes." });
338
+ for (const a of attrs) out.push({ t: "kv", name: a.name, value: a.value });
339
+ for (const p of psets) {
340
+ out.push({ t: "sp" }); // gap above each pset header → groups its rows
341
+ out.push({ t: "psh", name: p.name });
342
+ for (const pr of p.props) out.push({ t: "kv", name: pr.name, value: pr.value });
343
+ }
344
+ }
345
+ if (elements.length > 0 && out.length === 0) {
346
+ out.push({ t: "msg", text: "No matching properties." });
347
+ }
348
+ flat = out;
349
+ };
350
+
351
+ // ── Render the window ──────────────────────────────────────────
352
+ const rowHtml = (r: FlatRow) => {
353
+ switch (r.t) {
354
+ case "elh":
355
+ return `<div class="p-row p-elh"><bim-icon class="p-ico" icon="mdi:cube-outline"></bim-icon><span class="p-htxt">${esc(r.title)}</span></div>`;
356
+ case "psh":
357
+ return `<div class="p-row p-psh"><bim-icon class="p-ico" icon="mdi:format-list-bulleted-square"></bim-icon><span class="p-htxt">${esc(r.name)}</span></div>`;
358
+ case "kv":
359
+ return `<div class="p-row p-kv"><span class="p-k">${esc(r.name)}</span><span class="p-v">${esc(r.value)}</span></div>`;
360
+ case "kvvar":
361
+ return `<div class="p-row p-kv p-kvvar"><span class="p-k">${esc(r.name)}</span><span class="p-v p-vvar">${esc(r.value)}</span></div>`;
362
+ case "msg":
363
+ return `<div class="p-row p-msg">${esc(r.text)}</div>`;
364
+ case "sp":
365
+ return `<div class="p-row p-sp"></div>`;
366
+ case "crumb":
367
+ return `<div class="p-row p-crumb" data-act="back"><bim-icon class="p-ico" icon="mdi:arrow-left"></bim-icon><span class="p-htxt">Back to previous selection${r.depth > 1 ? ` (${r.depth} levels)` : ""}</span></div>`;
368
+ case "cat":
369
+ return (
370
+ `<div class="p-row p-cat" title="${esc(prettyCategory(r.category) || r.category)}">` +
371
+ `<bim-icon class="p-ico" icon="${iconFor(r.category)}"></bim-icon>` +
372
+ `<span class="p-htxt p-cat-name">${esc(prettyCategory(r.category) || r.category)}</span>` +
373
+ `<span class="p-cat-count">${r.count.toLocaleString()}</span>` +
374
+ `</div>`
375
+ );
376
+ }
377
+ };
378
+
379
+ // Rebuild the prefix-sum offsets from row `from` onward.
380
+ const recomputeOffsets = (from = 0) => {
381
+ if (from <= 0) {
382
+ offsets[0] = 0;
383
+ from = 0;
384
+ }
385
+ for (let i = from; i < flat.length; i++) offsets[i + 1] = offsets[i] + heights[i];
386
+ offsets.length = flat.length + 1;
387
+ };
388
+
389
+ // Largest row index whose top offset is ≤ y (binary search).
390
+ const rowAt = (y: number) => {
391
+ let lo = 0;
392
+ let hi = flat.length - 1;
393
+ let res = 0;
394
+ while (lo <= hi) {
395
+ const mid = (lo + hi) >> 1;
396
+ if (offsets[mid] <= y) {
397
+ res = mid;
398
+ lo = mid + 1;
399
+ } else {
400
+ hi = mid - 1;
401
+ }
402
+ }
403
+ return res;
404
+ };
405
+
406
+ // After a window renders, correct the real (post-wrap) height of rows that were
407
+ // just CREATED this pass (reused rows keep their already-measured height, so we
408
+ // never re-measure them). If any height changed, refresh offsets + total so
409
+ // scrolling and the scrollbar stay accurate. Only rows ≥ start change here, so
410
+ // the current window's top offset never moves (no scroll jump).
411
+ const measureNew = (newIdx: number[]) => {
412
+ let changedFrom = -1;
413
+ for (const i of newIdx) {
414
+ const el = mounted.get(i);
415
+ if (!el) continue;
416
+ const h = el.offsetHeight;
417
+ if (h && h !== heights[i]) {
418
+ heights[i] = h;
419
+ if (changedFrom < 0 || i < changedFrom) changedFrom = i;
420
+ }
421
+ }
422
+ if (changedFrom >= 0) {
423
+ recomputeOffsets(changedFrom);
424
+ sizer.style.height = `${offsets[flat.length]}px`;
425
+ }
426
+ };
427
+
428
+ // Build one row element from its HTML (entering rows get the exact same markup
429
+ // the old innerHTML path produced — including each row's <bim-icon>).
430
+ const buildRow = (i: number): HTMLElement => {
431
+ const tmp = document.createElement("div");
432
+ tmp.innerHTML = rowHtml(flat[i]);
433
+ return tmp.firstElementChild as HTMLElement;
434
+ };
435
+
436
+ // Index→element recycler (variable height). A row that stays within the window
437
+ // keeps its exact DOM element (and its already-rendered icon + measured height)
438
+ // across scroll, so icons are never recreated → no blink. Only rows ENTERING
439
+ // the window are built (and measured); rows LEAVING are removed. A forced
440
+ // rebuild (new selection / search / data change) clears this map.
441
+ const mounted = new Map<number, HTMLElement>();
442
+
443
+ let lastStart = -1;
444
+ let lastEnd = -1;
445
+ const render = (force = false) => {
446
+ const total = flat.length;
447
+ sizer.style.height = `${offsets[total]}px`;
448
+ const top = viewport.scrollTop;
449
+ const vh = viewport.clientHeight || 300;
450
+ const start = Math.max(0, rowAt(top) - BUFFER);
451
+ const end = Math.min(total, rowAt(top + vh) + BUFFER + 1);
452
+ if (!force && start === lastStart && end === lastEnd) return;
453
+ // A forced rebuild means flat[]/heights[] were just rebuilt, so any mounted
454
+ // element is stale — drop them all and rebuild the window once. This only
455
+ // happens off the scroll path, so the one-time rebuild is unnoticeable;
456
+ // plain scrolling never forces and so always reuses existing rows.
457
+ if (force) {
458
+ mounted.clear();
459
+ content.textContent = "";
460
+ }
461
+ lastStart = start;
462
+ lastEnd = end;
463
+ content.style.transform = `translateY(${offsets[start]}px)`;
464
+ // Remove rows that scrolled out of [start, end).
465
+ for (const [i, el] of mounted) {
466
+ if (i < start || i >= end) {
467
+ el.remove();
468
+ mounted.delete(i);
469
+ }
470
+ }
471
+ // Insert entering rows in ascending DOM order, splicing each before the next
472
+ // already-mounted element. Track which indices are new so only those get
473
+ // measured (reused rows keep their measured height).
474
+ const newIdx: number[] = [];
475
+ let cursor = content.firstElementChild as HTMLElement | null;
476
+ for (let i = start; i < end; i++) {
477
+ const existing = mounted.get(i);
478
+ if (existing) {
479
+ cursor = existing.nextElementSibling as HTMLElement | null;
480
+ continue;
481
+ }
482
+ const el = buildRow(i);
483
+ content.insertBefore(el, cursor);
484
+ mounted.set(i, el);
485
+ newIdx.push(i);
486
+ }
487
+ if (newIdx.length) measureNew(newIdx);
488
+ };
489
+
490
+ const refreshList = () => {
491
+ buildFlat();
492
+ heights = flat.map((r) => EST[r.t]);
493
+ offsets = new Array(flat.length + 1);
494
+ recomputeOffsets(0);
495
+ lastStart = lastEnd = -1;
496
+ render(true);
497
+ };
498
+
499
+ let scrollRaf = 0;
500
+ viewport.addEventListener("scroll", () => {
501
+ if (scrollRaf) return;
502
+ scrollRaf = requestAnimationFrame(() => {
503
+ scrollRaf = 0;
504
+ render();
505
+ });
506
+ });
507
+ new ResizeObserver(() => render()).observe(viewport);
508
+
509
+ // ── Panel chrome (BUI) — windowed viewport mounted into the host ──
510
+ let searchTimer: number | undefined;
511
+ const [panel, update] = BUI.Component.create<BUI.Panel, PanelState>(
512
+ (state) => {
513
+ const onHostCreated = (el?: Element) => {
514
+ if (!el || el.contains(viewport)) return;
515
+ el.appendChild(viewport);
516
+ render(true);
517
+ };
518
+ return BUI.html`
519
+ <bim-panel
520
+ label="Properties"
521
+ icon="mdi:information-outline"
522
+ header-hidden
523
+ style="width: 100%; height: 100%; pointer-events: auto;"
524
+ >
525
+ <style>
526
+ .prop-vp { height: 100%; overflow-y: auto; }
527
+ .prop-vp::-webkit-scrollbar { width: 0.4rem; height: 0.4rem; }
528
+ .prop-vp::-webkit-scrollbar-thumb { border-radius: 0.25rem; background-color: var(--bim-scrollbar--c, #3C3C41); }
529
+ .prop-vp::-webkit-scrollbar-track { background-color: var(--bim-scrollbar--bgc, var(--bim-ui_bg-contrast-10)); }
530
+ /* Rows are full-bleed (the host breaks out of the panel's horizontal
531
+ padding) so every divider/band reaches both panel edges; text is
532
+ re-inset via this padding. Heights are variable: every name/value
533
+ wraps fully (no ellipsis), and the virtualizer measures each row. */
534
+ .p-row { box-sizing: border-box; min-height: 24px; display: flex; align-items: flex-start; padding: 0.28rem 1.1rem; font-size: 0.78rem; line-height: 1.3; color: var(--bim-ui_bg-contrast-100, #e3e3e3); }
535
+ .p-kv { justify-content: space-between; gap: 0.75rem; border-bottom: 1px solid var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.1)); }
536
+ /* Key takes its natural width (never crushed below its text), but is
537
+ capped so an overly long key wraps instead of starving the value;
538
+ the value gets the remaining width and wraps there. */
539
+ .p-k { opacity: 0.6; flex: 0 0 auto; max-width: 45%; overflow-wrap: anywhere; word-break: break-word; }
540
+ .p-v { text-align: right; flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; word-break: break-word; }
541
+ /* Aggregated "Varies" value: muted + italic so a non-shared property
542
+ reads as informational, distinct from a concrete shared value. */
543
+ .p-vvar { opacity: 0.5; font-style: italic; }
544
+ /* Section headers read as distinct bands via a subtle BACKGROUND
545
+ (one step up from the panel surface), NOT a heavier rule — their
546
+ top/bottom hairlines match the same 1px contrast-20 used between
547
+ rows everywhere else, so nothing looks oddly thick. */
548
+ /* Same treatment as the graphics-menu bands: muted (secondary)
549
+ colour, lighter weight, compact — subordinate to the card title. */
550
+ .p-elh, .p-psh { gap: 0.4rem; font-weight: 500; font-size: 0.76rem;
551
+ color: var(--bim-ui_bg-contrast-70, #a7a7ab);
552
+ background: var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.1));
553
+ border-top: 1px solid var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.1));
554
+ border-bottom: 1px solid var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.1)); }
555
+ .p-htxt { overflow-wrap: anywhere; word-break: break-word; }
556
+ .p-ico { flex: 0 0 auto; margin-top: 0.05rem; color: var(--bim-ui_bg-contrast-70, #a7a7ab); font-size: 0.9rem; }
557
+ .p-msg { opacity: 0.6; }
558
+ .p-sp { border: none; }
559
+ /* Category breakdown rows: clickable (sub-select), full-bleed, with a
560
+ right-aligned count badge. */
561
+ .p-cat { gap: 0.4rem; align-items: center;
562
+ border-bottom: 1px solid var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.1)); }
563
+ .p-cat-name { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
564
+ .p-cat-count { flex: 0 0 auto; opacity: 0.75; font-variant-numeric: tabular-nums;
565
+ padding: 0.05rem 0.4rem; border-radius: 0.6rem; font-size: 0.7rem;
566
+ background: var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.1)); }
567
+ /* Breadcrumb / back chip. */
568
+ .p-crumb { gap: 0.4rem; cursor: pointer; align-items: center; font-weight: 600;
569
+ color: var(--bim-ui_accent-base, var(--bim-ui_accent-base));
570
+ background: var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.1));
571
+ border-bottom: 1px solid var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.1)); }
572
+ .p-crumb:hover { background: var(--bim-ui_bg-contrast-20); }
573
+ .p-crumb .p-ico { color: var(--bim-ui_accent-base, var(--bim-ui_accent-base)); }
574
+ </style>
575
+ <div style="display: flex; flex-direction: column; height: 100%; width: 100%;">
576
+ ${cardHeader("mdi:information-outline", "Properties", "1.1rem")}
577
+ <div style="flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; padding: 0.4rem 0.4rem 0.4rem 1.1rem; gap: 0;">
578
+ ${state.empty
579
+ ? BUI.html`<div style="
580
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
581
+ gap: 0.75rem; height: 100%; min-height: 12rem; padding: 1rem; text-align: center;
582
+ ">
583
+ <img src=${toolPlaceholderUri} alt="" style="width: 7.5rem; height: auto; opacity: 0.9;" />
584
+ <bim-label style="opacity: 0.55; white-space: normal;">${state.message}</bim-label>
585
+ </div>`
586
+ : BUI.html`
587
+ <bim-text-input
588
+ icon="mdi:magnify"
589
+ icon-inside
590
+ placeholder="Filter properties…"
591
+ style="flex: 0 0 auto; width: 100%; margin: 0 0 0.25rem -0.35rem;"
592
+ @input=${(e: Event) => {
593
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
594
+ const v = String((e.target as any).value ?? "");
595
+ if (searchTimer !== undefined) clearTimeout(searchTimer);
596
+ searchTimer = window.setTimeout(() => {
597
+ query = v;
598
+ viewport.scrollTop = 0;
599
+ refreshList();
600
+ }, 200);
601
+ }}
602
+ ></bim-text-input>
603
+ <div style="flex: 0 0 auto; border-bottom: 1px solid var(--bim-ui_bg-contrast-20, rgba(255, 255, 255, 0.1)); margin: 0 -0.4rem 0 -1.1rem;"></div>
604
+ ${state.note
605
+ ? BUI.html`<div style="flex: 0 0 auto; font-size: 0.7rem; line-height: 1.4; color: var(--bim-ui_bg-contrast-80, #c9c9c9);">${state.note}</div>`
606
+ : null}
607
+ ${state.loading
608
+ ? BUI.html`<div style="padding: 0.35rem;"><bim-label style="opacity: 0.6;">Loading…</bim-label></div>`
609
+ : null}
610
+ <div
611
+ ${BUI.ref(onHostCreated)}
612
+ style=${BUI.styleMap({
613
+ display: state.loading ? "none" : "block",
614
+ flex: "1 1 auto",
615
+ minHeight: "0",
616
+ // Break out of the container's horizontal padding so the
617
+ // windowed rows (and their dividers/bands) are full-bleed.
618
+ marginLeft: "-1.1rem",
619
+ marginRight: "-0.4rem",
620
+ })}
621
+ ></div>`}
622
+ </div>
623
+ </div>
624
+ </bim-panel>
625
+ `;
626
+ },
627
+ { empty: true, loading: false, message: EMPTY_MESSAGE, note: "" },
628
+ );
629
+
630
+ // ── Multi-select aggregation engine ────────────────────────────
631
+ let selToken = 0;
632
+ // Counter of panel-initiated Highlighter drives that are still in flight.
633
+ // It is INCREMENTED immediately before a highlightByID call and DECREMENTED
634
+ // in a `finally`, so it can never be stranded "true" if the call early-returns
635
+ // (highlighter disabled) or throws. The next onHighlight echo treats the
636
+ // selection as panel-initiated iff this counter is > 0. No persistent boolean
637
+ // flag exists that a failed call could leave permanently set.
638
+ let pendingInternalSelects = 0;
639
+ // Drive the Highlighter from the panel (sub-select / back) with a bulletproof
640
+ // guard: the in-flight counter is raised around the call and always lowered,
641
+ // even on early-return or throw. Returns whether the drive actually changed
642
+ // the highlight (so the caller can roll back its stack push if it didn't).
643
+ const driveHighlight = async (map: OBC.ModelIdMap): Promise<void> => {
644
+ pendingInternalSelects++;
645
+ try {
646
+ await highlighter.highlightByID(selectName, map, true, false);
647
+ } finally {
648
+ // If highlightByID early-returned/threw without emitting an onHighlight
649
+ // echo, the echo handler never decremented us — settle on next microtask
650
+ // so a real synchronous echo (which decrements) wins first, then clamp.
651
+ Promise.resolve().then(() => {
652
+ if (pendingInternalSelects > 0) pendingInternalSelects--;
653
+ });
654
+ }
655
+ };
656
+
657
+ // Cheap fingerprint: per-model size + a sampled-id xor hash. Two different
658
+ // selections of the same sizes hashing equal is astronomically unlikely for
659
+ // the sampled stride; a collision only re-shows a cached (correct-shape)
660
+ // breakdown, never corrupts the live Highlighter selection.
661
+ const fingerprint = (map: OBC.ModelIdMap) => {
662
+ const parts: string[] = [];
663
+ for (const modelId of Object.keys(map).sort()) {
664
+ const set = map[modelId];
665
+ let h = set.size >>> 0;
666
+ let i = 0;
667
+ const stride = Math.max(1, Math.floor(set.size / 64)); // ≤64 samples
668
+ for (const id of set) {
669
+ if (i % stride === 0) h = (h * 31 + id) >>> 0;
670
+ i++;
671
+ }
672
+ parts.push(`${modelId}:${set.size}:${h}`);
673
+ }
674
+ return parts.join("|");
675
+ };
676
+
677
+ // Aggregate a selection into a category breakdown by fetching each item's
678
+ // `_category` in the fragments WORKER (model.getItemsData runs off the main
679
+ // thread) and doing ONE bounded tally of the returned values. Very large
680
+ // selections are SAMPLED: at most AGG_CAP ids are fetched + tallied (per-model
681
+ // batched calls), with `sampled < total` surfaced to the UI. Bails (returns
682
+ // null) if `token` is stale — a superseded selection never schedules more
683
+ // work. Latency is fine; the only main-thread loop is the tally over results.
684
+ const buildAggregation = async (
685
+ map: OBC.ModelIdMap,
686
+ token: number,
687
+ ): Promise<Aggregation | null> => {
688
+ const fp = fingerprint(map);
689
+ const hit = cacheGet(fp);
690
+ if (hit) return hit;
691
+ const entries = Object.entries(map).filter(([, s]) => s.size > 0);
692
+ const total = entries.reduce((n, [, s]) => n + s.size, 0);
693
+ const counts = new Map<string, number>();
694
+ let sampled = 0;
695
+ let budget = AGG_CAP; // global cap across all models in the selection
696
+
697
+ for (const [modelId, set] of entries) {
698
+ if (budget <= 0) break;
699
+ const model = fragments.list.get(modelId);
700
+ if (!model) continue;
701
+ // Take at most `budget` ids from this model's set (sampling cap).
702
+ const ids: number[] = [];
703
+ for (const id of set) {
704
+ if (ids.length >= budget) break;
705
+ ids.push(id);
706
+ }
707
+ budget -= ids.length;
708
+ // Fetch `_category` in the worker, batched, so no single call is huge.
709
+ for (let i = 0; i < ids.length; i += AGG_BATCH) {
710
+ const batch = ids.slice(i, i + AGG_BATCH);
711
+ const datas = await model.getItemsData(batch, {
712
+ attributesDefault: false,
713
+ attributes: ["_category"],
714
+ });
715
+ if (token !== selToken) return null; // superseded → bail, no more work
716
+ // Single bounded main-thread pass over the returned results.
717
+ for (const data of datas) {
718
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
719
+ const attr = (data as any)?._category;
720
+ const cat =
721
+ attr && typeof attr === "object" && "value" in attr && attr.value != null
722
+ ? String(attr.value)
723
+ : "Unknown";
724
+ counts.set(cat, (counts.get(cat) ?? 0) + 1);
725
+ sampled++;
726
+ }
727
+ }
728
+ }
729
+ const cats = [...counts.entries()]
730
+ .map(([category, count]) => ({ category, count }))
731
+ .sort((a, b) => b.count - a.count);
732
+ const result: Aggregation = { total, sampled, models: entries.length, cats };
733
+ cachePut(fp, result);
734
+ return result;
735
+ };
736
+
737
+ // Build the shared-vs-varying PROPERTY aggregation for a selection. This is the
738
+ // EXPENSIVE path: it fetches FULL item data (attributes + IsDefinedBy psets,
739
+ // the same shape single-select uses) in the fragments WORKER, so it is capped
740
+ // hard at PROP_AGG_CAP items (per-model batched) and tallied in ONE bounded
741
+ // pass over the parsed sample. It mutates+caches `.props` onto the SAME
742
+ // fingerprint entry as the category breakdown (extending that cache slot), so a
743
+ // re-selection is instant. Bails (returns null) if `token` is stale.
744
+ const buildPropAggregation = async (
745
+ map: OBC.ModelIdMap,
746
+ token: number,
747
+ ): Promise<PropAggregation | null> => {
748
+ const fp = fingerprint(map);
749
+ const cached = cacheGet(fp);
750
+ if (cached?.props) return cached.props; // already computed for this selection
751
+
752
+ const entries = Object.entries(map).filter(([, s]) => s.size > 0);
753
+ const total = entries.reduce((n, [, s]) => n + s.size, 0);
754
+
755
+ // Tally accumulators. For each property key we keep the first value seen, a
756
+ // `varies` flag, a bounded set of distinct values (capped so it can never
757
+ // grow large), and how many sampled items had it. ONE entry per distinct
758
+ // property name — never per item — so memory is bounded by the schema, not
759
+ // the selection size.
760
+ type Tally = { first: string; varies: boolean; distinct: Set<string>; present: number };
761
+ const newTally = (): Tally => ({ first: "", varies: false, distinct: new Set(), present: 0 });
762
+ const DISTINCT_CAP = 64; // stop growing a distinct-set past this (we only need ≤ PROP_VARIES_SHOW + a count)
763
+ // Map insertion order gives stable first-seen ordering for free, so we read
764
+ // names back off the Maps directly — no separate order arrays needed.
765
+ const attrTally = new Map<string, Tally>();
766
+ const psetTally = new Map<string, Map<string, Tally>>(); // psetName → propName → Tally
767
+
768
+ const record = (bucket: Map<string, Tally>, name: string, value: string) => {
769
+ let t = bucket.get(name);
770
+ if (!t) {
771
+ t = newTally();
772
+ t.first = value;
773
+ bucket.set(name, t);
774
+ }
775
+ t.present++;
776
+ if (value !== t.first) t.varies = true;
777
+ if (t.distinct.size < DISTINCT_CAP) t.distinct.add(value);
778
+ };
779
+
780
+ let sampled = 0;
781
+ let budget = PROP_AGG_CAP; // global cap across all models
782
+ for (const [modelId, set] of entries) {
783
+ if (budget <= 0) break;
784
+ const model = fragments.list.get(modelId);
785
+ if (!model) continue;
786
+ const ids: number[] = [];
787
+ for (const id of set) {
788
+ if (ids.length >= budget) break;
789
+ ids.push(id);
790
+ }
791
+ budget -= ids.length;
792
+ for (let i = 0; i < ids.length; i += PROP_AGG_BATCH) {
793
+ const batch = ids.slice(i, i + PROP_AGG_BATCH);
794
+ // SAME expensive fetch shape as single-select (see onHighlight props path).
795
+ const datas = await model.getItemsData(batch, {
796
+ attributesDefault: true,
797
+ relations: {
798
+ IsDefinedBy: {
799
+ attributes: true,
800
+ relations: {
801
+ HasProperties: { attributes: true, relations: false },
802
+ },
803
+ },
804
+ DefinesOccurrence: { attributes: false, relations: false },
805
+ },
806
+ });
807
+ if (token !== selToken) return null; // superseded → bail, no more work
808
+ // ONE bounded pass: parse each item (reusing parseElement) and fold its
809
+ // attributes + pset props into the per-name tallies.
810
+ for (let j = 0; j < datas.length; j++) {
811
+ const el = parseElement(datas[j], batch[j]);
812
+ for (const a of el.attributes) record(attrTally, a.name, a.value);
813
+ for (const ps of el.psets) {
814
+ let bucket = psetTally.get(ps.name);
815
+ if (!bucket) {
816
+ bucket = new Map();
817
+ psetTally.set(ps.name, bucket);
818
+ }
819
+ for (const pr of ps.props) record(bucket, pr.name, pr.value);
820
+ }
821
+ }
822
+ sampled += datas.length;
823
+ }
824
+ }
825
+
826
+ const finalize = (t: Tally, name: string): AggProp => {
827
+ const values = [...t.distinct];
828
+ return {
829
+ name,
830
+ varies: t.varies,
831
+ value: t.first,
832
+ distinct: t.distinct.size,
833
+ values: t.varies && values.length <= PROP_VARIES_SHOW ? values : undefined,
834
+ present: t.present,
835
+ };
836
+ };
837
+ const attributes = [...attrTally.entries()].map(([name, t]) => finalize(t, name));
838
+ const psets = [...psetTally.entries()].map(([psName, bucket]) => ({
839
+ name: psName,
840
+ props: [...bucket.entries()].map(([n, t]) => finalize(t, n)),
841
+ }));
842
+
843
+ const propAgg: PropAggregation = { sampled, total, attributes, psets };
844
+ // Extend the SAME cached Aggregation entry (bounded by the existing LRU).
845
+ const slot = cacheGet(fp);
846
+ if (slot) slot.props = propAgg;
847
+ return propAgg;
848
+ };
849
+
850
+ // Sub-select all ids of one category WITHIN the current selection. We resolve
851
+ // the per-category id set the same worker way: fetch `_category` for the live
852
+ // selection (batched, capped) and keep the ids whose category matches.
853
+ const subSelectCategory = async (category: string) => {
854
+ const current = lastMap; // the live selection we're narrowing
855
+ if (!current) return;
856
+ const token = selToken; // bail if the selection changes under us
857
+ const subset: OBC.ModelIdMap = {};
858
+ let budget = AGG_CAP;
859
+ for (const [modelId, set] of Object.entries(current)) {
860
+ if (budget <= 0) break;
861
+ const model = fragments.list.get(modelId);
862
+ if (!model) continue;
863
+ const all: number[] = [];
864
+ for (const id of set) {
865
+ if (all.length >= budget) break;
866
+ all.push(id);
867
+ }
868
+ budget -= all.length;
869
+ const ids = new Set<number>();
870
+ for (let i = 0; i < all.length; i += AGG_BATCH) {
871
+ const batch = all.slice(i, i + AGG_BATCH);
872
+ const datas = await model.getItemsData(batch, {
873
+ attributesDefault: false,
874
+ attributes: ["_category"],
875
+ });
876
+ if (token !== selToken) return; // superseded → bail
877
+ datas.forEach((data, j) => {
878
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
879
+ const attr = (data as any)?._category;
880
+ const value =
881
+ attr && typeof attr === "object" && "value" in attr ? String(attr.value) : "Unknown";
882
+ if (value === category) ids.add(batch[j]);
883
+ });
884
+ }
885
+ if (ids.size > 0) subset[modelId] = ids;
886
+ }
887
+ if (token !== selToken) return;
888
+ if (Object.keys(subset).length === 0) return;
889
+ if (selStack.length >= SELSTACK_MAX) selStack.shift(); // bound depth
890
+ selStack.push(current); // remember the wider set for "Back"
891
+ await driveHighlight(subset);
892
+ };
893
+
894
+ // Restore the previous (wider) selection from the breadcrumb stack.
895
+ const goBack = async () => {
896
+ const prev = selStack.pop();
897
+ if (!prev) return;
898
+ await driveHighlight(prev);
899
+ };
900
+
901
+ // Delegated click on the windowed rows (category sub-select / back chip).
902
+ content.addEventListener("click", (e) => {
903
+ if (mode !== "agg") return;
904
+ const target = e.target as HTMLElement;
905
+ const row = target.closest<HTMLElement>("[data-act]");
906
+ if (!row) return;
907
+ const act = row.dataset.act;
908
+ if (act === "back") void goBack();
909
+ else if (act === "cat" && row.dataset.cat) void subSelectCategory(row.dataset.cat);
910
+ });
911
+
912
+ // ── Selection (one or many) → flat rows ────────────────────────
913
+ // Remembers the live selection map (for sub-select intersection).
914
+ let lastMap: OBC.ModelIdMap | null = null;
915
+ highlighter.events.select.onHighlight.add(async (modelIdMap: OBC.ModelIdMap) => {
916
+ const token = ++selToken;
917
+ const total = Object.values(modelIdMap).reduce((n, set) => n + set.size, 0);
918
+ if (total === 0) return;
919
+ query = "";
920
+ lastMap = modelIdMap;
921
+ // Multi-select → aggregation/insights view. The category breakdown is built
922
+ // by fetching `_category` in the fragments WORKER (off the main thread) and
923
+ // tallying the results in one bounded pass — latency is fine, the main
924
+ // thread never blocks. A "Aggregating…" state shows while it runs.
925
+ if (total > 1) {
926
+ // Was this onHighlight echo produced by the panel itself (sub-select /
927
+ // back)? Read+consume one pending internal drive. The counter is raised
928
+ // in driveHighlight around the call and always lowered in a finally, so it
929
+ // can never be stranded; an external selection sees it at 0.
930
+ const wasInternal = pendingInternalSelects > 0;
931
+ if (wasInternal) pendingInternalSelects--;
932
+ // External selection (tree/viewport) starts a fresh breadcrumb stack;
933
+ // our own sub-select/back keeps the stack it just edited.
934
+ if (!wasInternal) selStack.length = 0;
935
+ mode = "agg";
936
+ elements = [];
937
+ agg = null;
938
+ propAggError = null;
939
+ update({
940
+ empty: false,
941
+ loading: true,
942
+ message: "",
943
+ note: `${total.toLocaleString()} elements selected — aggregating…`,
944
+ });
945
+ try {
946
+ const result = await buildAggregation(modelIdMap, token);
947
+ if (token !== selToken) return;
948
+ if (!result) return;
949
+ agg = result;
950
+ const modelNote = result.models > 1 ? ` across ${result.models} models` : "";
951
+ const sampleNote =
952
+ result.sampled < result.total
953
+ ? ` · showing ${result.sampled.toLocaleString()} of ${result.total.toLocaleString()}`
954
+ : "";
955
+ update({
956
+ empty: false,
957
+ loading: false,
958
+ message: "",
959
+ note: `${result.total.toLocaleString()} elements selected${modelNote} · ${result.cats.length} categor${result.cats.length === 1 ? "y" : "ies"}${sampleNote}`,
960
+ });
961
+ viewport.scrollTop = 0;
962
+ refreshList(); // shows the read-only category breakdown
963
+ } catch (error) {
964
+ if (token !== selToken) return;
965
+ console.warn("[properties-panel] aggregation failed", error);
966
+ agg = null;
967
+ update({ empty: true, loading: false, message: "Could not aggregate selection.", note: "" });
968
+ }
969
+ return;
970
+ }
971
+ // Single-select → unchanged full property view. A category sub-select that
972
+ // narrows down to exactly one element keeps the breadcrumb stack (so "Back"
973
+ // still works from the property view); an external single-click resets it.
974
+ mode = "props";
975
+ const wasInternalSingle = pendingInternalSelects > 0;
976
+ if (wasInternalSingle) pendingInternalSelects--;
977
+ if (!wasInternalSingle) selStack.length = 0;
978
+ update({ empty: false, loading: true, note: "" });
979
+ try {
980
+ const parsed: ElementProps[] = [];
981
+ let budget = MAX_ELEMENTS;
982
+ for (const [modelId, set] of Object.entries(modelIdMap)) {
983
+ if (budget <= 0) break;
984
+ const model = fragments.list.get(modelId);
985
+ if (!model) continue;
986
+ const ids = [...set].slice(0, budget);
987
+ budget -= ids.length;
988
+ const datas = await model.getItemsData(ids, {
989
+ attributesDefault: true,
990
+ relations: {
991
+ // Expand ONLY the property set's HasProperties (what the panel
992
+ // shows), not the pset's whole relation graph. `relations: true`
993
+ // would also pull the pset's inverse back-reference to every item
994
+ // that shares it (thousands of siblings for a repeated family) —
995
+ // the dominant cost. Relies on the fragments behaviour where an
996
+ // explicit nested relations object expands only the relations it names.
997
+ IsDefinedBy: {
998
+ attributes: true,
999
+ relations: {
1000
+ HasProperties: { attributes: true, relations: false },
1001
+ },
1002
+ },
1003
+ DefinesOccurrence: { attributes: false, relations: false },
1004
+ },
1005
+ });
1006
+ datas.forEach((data, i) => parsed.push(parseElement(data, ids[i])));
1007
+ }
1008
+ if (token !== selToken) return;
1009
+ elements = parsed;
1010
+ const note =
1011
+ total > parsed.length
1012
+ ? `Showing ${parsed.length} of ${total} selected`
1013
+ : total > 1
1014
+ ? `${total} elements selected`
1015
+ : "";
1016
+ update({ empty: parsed.length === 0, loading: false, note });
1017
+ viewport.scrollTop = 0;
1018
+ refreshList();
1019
+ } catch (error) {
1020
+ if (token !== selToken) return;
1021
+ console.warn("[properties-panel] failed to read item data", error);
1022
+ elements = [];
1023
+ update({ empty: true, loading: false, message: "Could not read properties." });
1024
+ }
1025
+ });
1026
+
1027
+ highlighter.events.select.onClear.add(() => {
1028
+ selToken++;
1029
+ elements = [];
1030
+ query = "";
1031
+ mode = "props";
1032
+ agg = null;
1033
+ propAggError = null;
1034
+ lastMap = null;
1035
+ selStack.length = 0;
1036
+ pendingInternalSelects = 0;
1037
+ refreshList();
1038
+ update({ empty: true, loading: false, message: EMPTY_MESSAGE, note: "" });
1039
+ });
1040
+
1041
+ // A model unload/reload invalidates any cached aggregations (localIds may be
1042
+ // reassigned). Drop the cache; it rebuilds lazily on the next selection.
1043
+ fragments.list.onItemDeleted.add(() => {
1044
+ aggCache.clear();
1045
+ });
1046
+
1047
+ return panel;
1048
+ };