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