@thatopen/services 0.2.0 → 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.
- package/README.md +59 -59
- package/dist/built-in/index.d.ts +486 -563
- package/dist/cli.js +10 -0
- package/dist/index.cjs.js +2 -2
- package/dist/index.es.js +3 -5
- package/dist/vite-env.d.ts +1 -1
- package/docs/builtin/paths.json +74 -70
- package/docs/cli/create.md +28 -28
- package/docs/cli/local-server.md +38 -38
- package/docs/cli/login.md +32 -32
- package/docs/cli/publish.md +31 -31
- package/docs/cli/run.md +26 -26
- package/docs/cli/serve.md +43 -43
- package/docs/cli/swap.md +30 -30
- package/package.json +100 -100
- package/resources/AGENTS.md +40 -40
- package/src/built-in/CDEManager/example.ts +214 -214
- package/src/built-in/CDEManager/examples/FileEditing/example.ts +226 -226
- package/src/built-in/CDEManager/examples/FileMoves/example.ts +186 -186
- package/src/built-in/CDEManager/examples/FileOperations/example.ts +295 -295
- package/src/built-in/CDEManager/examples/FileTransfers/example.ts +210 -210
- package/src/built-in/CDEManager/examples/FolderManagement/example.ts +230 -230
- package/src/built-in/CDEManager/examples/MetadataSchema/example.ts +230 -230
- package/src/built-in/CDEManager/examples/MetadataViews/example.ts +166 -166
- package/src/built-in/CDEManager/examples/ProjectConfig/example.ts +177 -177
- package/src/built-in/UIManager/src/app/example.ts +99 -99
- package/src/built-in/UIManager/src/app/examples/AppState/example.ts +142 -142
- package/src/built-in/UIManager/src/app/examples/Banner/example.ts +161 -0
- package/src/built-in/UIManager/src/app/examples/Contexts/example.ts +125 -125
- package/src/built-in/UIManager/src/app/examples/FileDrop/example.ts +126 -0
- package/src/built-in/UIManager/src/app/examples/FloatingLayout/example.ts +92 -92
- package/src/built-in/UIManager/src/app/examples/Modal/example.ts +165 -0
- package/src/built-in/UIManager/src/app/examples/Notifications/example.ts +135 -161
- package/src/built-in/UIManager/src/app/examples/Sidebar/example.ts +104 -104
- package/src/built-in/index.ts +649 -755
- package/src/cli/templates/app/README.md +241 -241
- package/src/cli/templates/app/index.html +35 -27
- package/src/cli/templates/app/package.json +31 -27
- package/src/cli/templates/app/src/app.ts +37 -0
- package/src/cli/templates/app/src/assets/tool-placeholder.ts +50 -0
- package/src/cli/templates/app/src/bim-components/CloudRunner/index.ts +95 -87
- package/src/cli/templates/app/src/bim-components/CloudRunner/src/index.ts +1 -1
- package/src/cli/templates/app/src/bim-components/CloudRunner/src/types.ts +5 -5
- package/src/cli/templates/app/src/bim-components/index.ts +1 -1
- package/src/cli/templates/app/src/globals.ts +1 -0
- package/src/cli/templates/app/src/main.ts +340 -56
- package/src/cli/templates/app/src/setups/active-tool-hud.ts +69 -0
- package/src/cli/templates/app/src/setups/camera-tools.ts +63 -0
- package/src/cli/templates/app/src/setups/camera-views.ts +201 -0
- package/src/cli/templates/app/src/setups/card-header.ts +35 -0
- package/src/cli/templates/app/src/setups/clipper-panel.ts +167 -0
- package/src/cli/templates/app/src/setups/clipper-tool.ts +754 -0
- package/src/cli/templates/app/src/setups/clipper.ts +75 -0
- package/src/cli/templates/app/src/setups/cloud-runner.ts +13 -9
- package/src/cli/templates/app/src/setups/commands-panel.ts +239 -0
- package/src/cli/templates/app/src/setups/data-table-panel.ts +2224 -0
- package/src/cli/templates/app/src/setups/exploded-view.ts +246 -0
- package/src/cli/templates/app/src/setups/file-format-icons.ts +95 -0
- package/src/cli/templates/app/src/setups/files-panel.ts +1503 -0
- package/src/cli/templates/app/src/setups/fps-indicator.ts +63 -0
- package/src/cli/templates/app/src/setups/graphics-panel.ts +490 -0
- package/src/cli/templates/app/src/setups/helper-panel.ts +88 -0
- package/src/cli/templates/app/src/setups/hider.ts +98 -0
- package/src/cli/templates/app/src/setups/index.ts +33 -3
- package/src/cli/templates/app/src/setups/inspection.ts +136 -0
- package/src/cli/templates/app/src/setups/measurement-panel.ts +188 -0
- package/src/cli/templates/app/src/setups/measurement-settings-panel.ts +161 -0
- package/src/cli/templates/app/src/setups/measurement-tool.ts +570 -0
- package/src/cli/templates/app/src/setups/measurements.ts +133 -0
- package/src/cli/templates/app/src/setups/model-tree.ts +797 -0
- package/src/cli/templates/app/src/setups/navigation-gizmo.ts +316 -0
- package/src/cli/templates/app/src/setups/objects-panel.ts +127 -0
- package/src/cli/templates/app/src/setups/plans-panel.ts +619 -0
- package/src/cli/templates/app/src/setups/properties-panel.ts +1048 -0
- package/src/cli/templates/app/src/setups/reality-capture/lib/hidden-tiles-plugin.ts +88 -0
- package/src/cli/templates/app/src/setups/reality-capture/lib/point-tile-plugin.ts +166 -0
- package/src/cli/templates/app/src/setups/reality-capture/lib/render-frame.ts +31 -0
- package/src/cli/templates/app/src/setups/reality-capture/lib/splat-tile-plugin.ts +51 -0
- package/src/cli/templates/app/src/setups/reality-capture-viewer.ts +1383 -0
- package/src/cli/templates/app/src/setups/right-sidebar.ts +178 -0
- package/src/cli/templates/app/src/setups/settings-panel.ts +94 -0
- package/src/cli/templates/app/src/setups/styles-panel.ts +110 -0
- package/src/cli/templates/app/src/setups/styles.ts +375 -0
- package/src/cli/templates/app/src/setups/tool-mode-manager.ts +150 -0
- package/src/cli/templates/app/src/setups/tool-mode.ts +23 -0
- package/src/cli/templates/app/src/setups/toolbar.ts +229 -0
- package/src/cli/templates/app/src/setups/ui-manager.ts +27 -0
- package/src/cli/templates/app/src/setups/visibility-toolbar.ts +498 -0
- package/src/cli/templates/app/src/setups/walkthrough.ts +528 -0
- package/src/cli/templates/app/src/ui-components/app-info-section/index.ts +26 -0
- package/src/cli/templates/app/src/ui-components/app-info-section/src/index.ts +1 -0
- package/src/cli/templates/app/src/ui-components/app-info-section/src/types.ts +15 -0
- package/src/cli/templates/app/src/ui-components/cloud-runner-section/index.ts +37 -0
- package/src/cli/templates/app/src/ui-components/cloud-runner-section/src/index.ts +1 -0
- package/src/cli/templates/app/src/ui-components/cloud-runner-section/src/types.ts +14 -0
- package/src/cli/templates/app/src/ui-components/index.ts +2 -1
- package/src/cli/templates/app/tsconfig.json +16 -17
- package/src/cli/templates/app/vite.config.js +45 -42
- package/src/cli/templates/cloud-component/README.md +205 -205
- package/src/cli/templates/cloud-component/_thatopen +5 -5
- package/src/cli/templates/cloud-component/declarations.json +4 -4
- package/src/cli/templates/cloud-component/package.json +21 -21
- package/src/cli/templates/cloud-component/src/main.ts +70 -70
- package/src/cli/templates/cloud-component/tsconfig.json +16 -16
- package/src/cli/templates/cloud-component/vite.config.js +46 -46
- package/src/cli/templates/shared/AGENTS.md +8 -8
- package/src/cli/templates/shared/CLAUDE.md +1 -1
- package/src/cli/templates/shared/_gitignore +5 -5
- package/src/built-in/AppManager/example.ts +0 -86
- package/src/built-in/FileList/example.ts +0 -107
- package/src/built-in/TabbedNavigation/example.ts +0 -115
- package/src/built-in/ViewportsManager/example.ts +0 -106
- package/src/cli/templates/app/src/setups/fragments.ts +0 -18
- package/src/cli/templates/app/src/setups/ifc-loader.ts +0 -12
- 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
+
};
|