@peter.prib/renderengine 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +45 -19
  2. package/bin/renderengine.mjs +157 -0
  3. package/dist/renderengine-workspace.umd.js +49641 -39747
  4. package/dist/renderengine.umd.js +25366 -15650
  5. package/documentation/REFERENCE.md +206 -0
  6. package/examples/decision-workbench.html +26 -0
  7. package/package.json +7 -1
  8. package/src/CanvasRenderer.js +225 -200
  9. package/src/SVGRenderer.js +476 -454
  10. package/src/archimate.js +22 -2
  11. package/src/archimateArchiFormat.js +1735 -1701
  12. package/src/archimateArchive.js +36 -7
  13. package/src/archimateCSV.js +93 -37
  14. package/src/archimateDrawio.js +2 -0
  15. package/src/archimateExchangeFormat.js +3 -1
  16. package/src/chartCatalog.js +219 -0
  17. package/src/charting/Annotations.js +169 -0
  18. package/src/charting/ArchimateDiagramEditor.js +54 -10
  19. package/src/charting/Axis.js +217 -5
  20. package/src/charting/Chart.js +99 -9
  21. package/src/charting/Chart3D.js +3 -0
  22. package/src/charting/ChartBox.js +8 -1
  23. package/src/charting/ColorPalette.js +42 -4
  24. package/src/charting/DiagramEditor.js +174 -39
  25. package/src/charting/DiagramEditorCommands.js +28 -0
  26. package/src/charting/Edge.js +7 -0
  27. package/src/charting/Hierarchy.js +114 -0
  28. package/src/charting/InteractionManager.js +4 -0
  29. package/src/charting/ModelFileFormat.js +5 -1
  30. package/src/charting/SettingsManager.js +35 -0
  31. package/src/charting/ShapePaths.js +82 -10
  32. package/src/charting/archimate/ArchimateCsvFileFormat.js +51 -12
  33. package/src/charting/archimate/ArchimateElementTemplate.js +235 -231
  34. package/src/charting/charts/AlluvialDiagram.js +311 -0
  35. package/src/charting/charts/AreaChart.js +52 -15
  36. package/src/charting/charts/BarChart.js +38 -3
  37. package/src/charting/charts/BarChart3D.js +5 -3
  38. package/src/charting/charts/BarChartBase.js +26 -6
  39. package/src/charting/charts/BeeswarmChart.js +164 -0
  40. package/src/charting/charts/BoxPlotChart.js +23 -1
  41. package/src/charting/charts/CalendarHeatmapChart.js +2 -1
  42. package/src/charting/charts/CandlestickChart.js +172 -4
  43. package/src/charting/charts/ChordDiagram.js +105 -2
  44. package/src/charting/charts/CirclePackChart.js +81 -0
  45. package/src/charting/charts/ComboChart.js +233 -0
  46. package/src/charting/charts/ContourChart.js +310 -0
  47. package/src/charting/charts/DonutChart3D.js +13 -0
  48. package/src/charting/charts/DumbbellChart.js +217 -0
  49. package/src/charting/charts/EcdfChart.js +150 -0
  50. package/src/charting/charts/FunnelChart.js +4 -1
  51. package/src/charting/charts/GeoMapChart.js +132 -11
  52. package/src/charting/charts/GroupedBarChart.js +44 -3
  53. package/src/charting/charts/GroupedDistributionChart.js +26 -1
  54. package/src/charting/charts/HeatmapChart.js +2 -2
  55. package/src/charting/charts/HexbinChart.js +137 -0
  56. package/src/charting/charts/HierarchyChart.js +77 -3
  57. package/src/charting/charts/HorizonChart.js +105 -0
  58. package/src/charting/charts/KagiChart.js +167 -0
  59. package/src/charting/charts/LineChart.js +74 -18
  60. package/src/charting/charts/OhlcChart.js +79 -0
  61. package/src/charting/charts/ParallelCoordinatesChart.js +9 -1
  62. package/src/charting/charts/PieChart.js +195 -17
  63. package/src/charting/charts/PieChart3D.js +137 -0
  64. package/src/charting/charts/PointAndFigureChart.js +198 -0
  65. package/src/charting/charts/PolarAreaChart.js +189 -0
  66. package/src/charting/charts/RadarChart.js +7 -1
  67. package/src/charting/charts/RadialBarChart.js +5 -2
  68. package/src/charting/charts/RenkoChart.js +178 -0
  69. package/src/charting/charts/RidgelineChart.js +113 -0
  70. package/src/charting/charts/SankeyDiagram.js +127 -4
  71. package/src/charting/charts/ScatterChart.js +13 -0
  72. package/src/charting/charts/ScatterMatrixChart.js +119 -0
  73. package/src/charting/charts/SlopegraphChart.js +205 -0
  74. package/src/charting/charts/SparklineTableChart.js +166 -0
  75. package/src/charting/charts/StackedBarChart.js +48 -5
  76. package/src/charting/charts/StackedBarChart3DOrbit.js +10 -5
  77. package/src/charting/charts/StreamGraphChart.js +10 -3
  78. package/src/charting/charts/SunburstChart.js +27 -7
  79. package/src/charting/charts/SurfaceChart3D.js +157 -0
  80. package/src/charting/charts/TreemapChart.js +28 -8
  81. package/src/charting/charts/ViolinChart.js +18 -0
  82. package/src/charting/datamodel/ArchimateDataLink.js +70 -0
  83. package/src/charting/datamodel/DataLineage.js +150 -0
  84. package/src/charting/datamodel/DataModel.js +83 -1
  85. package/src/charting/datamodel/ModelMapping.js +87 -0
  86. package/src/charting/datamodel/SqlDdl.js +110 -85
  87. package/src/charting/datamodel/index.js +3 -0
  88. package/src/charting/decision/DecisionWorkbench.js +295 -0
  89. package/src/charting/diagrams/ArchimateDiagram.js +25 -9
  90. package/src/charting/diagrams/ArchimateModel.js +1382 -1238
  91. package/src/charting/diagrams/GraphDiagram.js +1424 -1350
  92. package/src/charting/diagrams/SketchView.js +121 -0
  93. package/src/charting/diagrams/sketchBoxKinds.js +30 -0
  94. package/src/charting/modelTree/AnalysisPanel.js +101 -23
  95. package/src/charting/modelTree/NavigatorModel.js +410 -0
  96. package/src/charting/modelTree/NavigatorPanel.js +453 -0
  97. package/src/charting/pivot/PivotChart.js +109 -4
  98. package/src/charting/pivot/PivotGridChart.js +321 -33
  99. package/src/charting/pivot/PivotView.js +82 -0
  100. package/src/charting/report/TemplatedReport.js +547 -0
  101. package/src/charting/templates/BoxTemplate.js +28 -27
  102. package/src/charting/templates/PackageTemplate.js +26 -25
  103. package/src/charting/templates/SwimlaneTemplate.js +19 -18
  104. package/src/charting/templates/Template.js +219 -212
  105. package/src/cli.js +146 -0
  106. package/src/index.js +3370 -2995
  107. package/src/workspace/Workspace.js +126 -7
  108. package/src/workspace/archimateServices.js +4 -0
  109. package/src/workspace/index.js +3 -0
  110. package/src/workspace/parts/ArchimateCanvasPart.js +1 -1
  111. package/src/workspace/parts/DecisionWorkbenchPart.js +58 -0
  112. package/src/workspace/parts/ViewsPart.js +196 -0
  113. package/src/workspace/styles.js +36 -1
@@ -25,6 +25,20 @@ export class PivotGridChart extends TableViewChart {
25
25
  static EXPANDED = '▾';
26
26
  static COLLAPSED = '▸';
27
27
 
28
+ constructor(renderer, view, options = {}) {
29
+ super(renderer, view, options);
30
+ this.pinnedHeaders = options.pinnedHeaders !== false;
31
+ this.virtualize = options.virtualize !== false;
32
+ this.virtualBuffer = options.virtualBuffer ?? 5;
33
+ this._renderedRange = null;
34
+ this._scrollLeft = 0;
35
+ this._scrollTop = 0;
36
+ this._scrollHandler = null;
37
+ this._scrollContainer = null;
38
+ this._rowShapesStartIndex = null;
39
+ this._rowCtx = null;
40
+ }
41
+
28
42
  /** Lines the layout strip takes, when the view's layout is the host's to change. */
29
43
  static layoutLines(view) { return view.onLayout ? 2 : 0; }
30
44
 
@@ -56,6 +70,58 @@ export class PivotGridChart extends TableViewChart {
56
70
  return width;
57
71
  }
58
72
 
73
+ _rule(x, y, width) {
74
+ return this.renderer.path(`M${x},${y} L${x + width},${y}`, 'none', TableChart.RULE);
75
+ }
76
+
77
+ _isVirtualizable() {
78
+ if (this.options.virtualize === false) return false;
79
+ if (!this.renderer?.supportsSceneGraph) return false;
80
+ const totalRows = this.view.visibleRows().length;
81
+ if (this.options.virtualize === true) return totalRows > 0;
82
+ const container = this.renderer.container;
83
+ if (container && container.clientHeight > 0) {
84
+ const fullHeight = PivotGridChart.heightFor(this.view, this.options);
85
+ return container.clientHeight < fullHeight && totalRows > 20;
86
+ }
87
+ return false;
88
+ }
89
+
90
+ _getVisibleRowRange(scrollTop, viewportH) {
91
+ const totalRows = this.view.visibleRows().length;
92
+ const relativeY = Math.max(0, scrollTop - (this._rowTopStart || 0));
93
+ const rawStart = Math.floor(relativeY / TableChart.ROW_H);
94
+ const start = Math.max(0, rawStart - this.virtualBuffer);
95
+ const rawEnd = Math.ceil((relativeY + viewportH) / TableChart.ROW_H);
96
+ const end = Math.min(totalRows, rawEnd + this.virtualBuffer);
97
+ return { start, end };
98
+ }
99
+
100
+ get renderedRowRange() {
101
+ return this._renderedRange ? { ...this._renderedRange } : null;
102
+ }
103
+
104
+ scrollTo(scrollTop, scrollLeft = 0) {
105
+ if (this._scrollContainer) {
106
+ this._scrollContainer.scrollTop = scrollTop;
107
+ this._scrollContainer.scrollLeft = scrollLeft;
108
+ }
109
+ this._handleScroll(scrollLeft, scrollTop);
110
+ }
111
+
112
+ scrollToRow(index) {
113
+ const targetY = (this._rowTopStart || 0) + index * TableChart.ROW_H;
114
+ this.scrollTo(targetY, this._scrollLeft || 0);
115
+ }
116
+
117
+ destroy() {
118
+ if (this._scrollHandler && this._scrollContainer) {
119
+ this._scrollContainer.removeEventListener('scroll', this._scrollHandler);
120
+ this._scrollHandler = null;
121
+ this._scrollContainer = null;
122
+ }
123
+ }
124
+
59
125
  _render() {
60
126
  const { view } = this;
61
127
  const { title, nullText = '—' } = this.options;
@@ -71,17 +137,50 @@ export class PivotGridChart extends TableViewChart {
71
137
 
72
138
  new ChartLabels({ title }).draw(this.renderer, { x: box.x, y: box.y, width: box.width, height: box.height });
73
139
 
140
+ const inGroup = (group, shapeEl) => {
141
+ if (group && typeof group.appendChild === 'function' && shapeEl && shapeEl.nodeType) {
142
+ group.appendChild(shapeEl);
143
+ }
144
+ return shapeEl;
145
+ };
146
+
147
+ const createGroup = (className) => {
148
+ if (interactive && typeof this.renderer.group === 'function') {
149
+ return this.renderer.group({ class: className });
150
+ }
151
+ return null;
152
+ };
153
+
154
+ const bodyGroup = createGroup('pivot-body');
155
+ const bodyRowsGroup = createGroup('pivot-body-rows');
156
+ const colHeaderGroup = createGroup('pivot-col-headers');
157
+ const rowHeaderGroup = createGroup('pivot-row-headers');
158
+ const rowHeadersContentGroup = createGroup('pivot-row-headers-content');
159
+ const cornerGroup = createGroup('pivot-corner');
160
+ const freezeLinesGroup = createGroup('pivot-freeze-lines');
161
+
162
+ this._colHeaderGroup = colHeaderGroup;
163
+ this._rowHeaderGroup = rowHeaderGroup;
164
+ this._cornerGroup = cornerGroup;
165
+ this._bodyGroup = bodyGroup;
166
+ this._bodyRowsGroup = bodyRowsGroup;
167
+ this._rowHeadersContentGroup = rowHeadersContentGroup;
168
+ this._freezeLinesGroup = freezeLinesGroup;
169
+
74
170
  // The corner: the grid's menu, and what each axis holds.
75
171
  let top = box.y;
76
- if (view.onLayout) top = this._layoutStrip(box, top, interactive);
77
- const corner = this.renderer.rect(box.x, top, sideW, lines.length * ROW_H, TableChart.HEADER_FILL, null);
78
- const glyph = this._text(TableViewChart.TABLE_GLYPH, box.x + PAD_X + 2, top, { textAnchor: 'middle', fontWeight: 'bold', fill: '#555' });
172
+ if (view.onLayout) top = this._layoutStrip(box, top, interactive, colHeaderGroup);
173
+ const headerTop = top;
174
+ const headerHeight = lines.length * ROW_H;
175
+
176
+ const corner = inGroup(cornerGroup, this.renderer.rect(box.x, top, sideW, headerHeight, TableChart.HEADER_FILL, null));
177
+ const glyph = inGroup(cornerGroup, this._text(TableViewChart.TABLE_GLYPH, box.x + PAD_X + 2, top, { textAnchor: 'middle', fontWeight: 'bold', fill: '#555' }));
79
178
  this._on(interactive, [corner, glyph], (e) => this.showMenu(e.clientX, e.clientY, view.gridMenu()), 'Grid menu');
80
179
  if (view.columnAxis.length > 0 && lines.length > 1) {
81
- this._cell(view.columnAxis.join(' › '), end, box.x + 16, top, sideW - 16, { fill: '#666', fontSize: '11px' });
180
+ inGroup(cornerGroup, this._cell(view.columnAxis.join(' › '), end, box.x + 16, top, sideW - 16, { fill: '#666', fontSize: '11px' }));
82
181
  }
83
182
  if (view.rowAxis.length > 0) {
84
- this._cell(view.rowAxis.join(' › '), {}, box.x + 12, top + (lines.length - 1) * ROW_H, sideW - 12, { fontWeight: 'bold', fill: '#333' });
183
+ inGroup(cornerGroup, this._cell(view.rowAxis.join(' › '), {}, box.x + 12, top + (lines.length - 1) * ROW_H, sideW - 12, { fontWeight: 'bold', fill: '#333' }));
85
184
  }
86
185
 
87
186
  // The column headers: a line per level, a member spanning the columns beneath it.
@@ -89,46 +188,223 @@ export class PivotGridChart extends TableViewChart {
89
188
  let left = x0;
90
189
  for (const cell of line) {
91
190
  const w = cell.span * colW;
92
- this.renderer.rect(left, top, w, ROW_H, TableChart.HEADER_FILL, null);
93
- this._member('columns', cell.node, cell.text, left, top, w, interactive, { fontWeight: 'bold', fill: '#333' });
191
+ inGroup(colHeaderGroup, this.renderer.rect(left, top, w, ROW_H, TableChart.HEADER_FILL, null));
192
+ this._member('columns', cell.node, cell.text, left, top, w, interactive, { fontWeight: 'bold', fill: '#333' }, colHeaderGroup);
94
193
  left += w;
95
194
  }
96
195
  top += ROW_H;
97
- this._rule(box.x, top, box.width);
196
+ inGroup(colHeaderGroup, this._rule(box.x, top, box.width));
197
+ }
198
+
199
+ this._rowTopStart = top;
200
+ this._sideW = sideW;
201
+ this._colW = colW;
202
+ this._x0 = x0;
203
+ this._box = box;
204
+
205
+ if (interactive && freezeLinesGroup) {
206
+ this._colDivider = inGroup(freezeLinesGroup, this.renderer.path(`M${box.x},${top} L${box.x + box.width},${top}`, 'none', { color: '#c5cde0', width: 1.5 }));
207
+ const fullHeight = PivotGridChart.heightFor(view, this.options);
208
+ this._rowDivider = inGroup(freezeLinesGroup, this.renderer.path(`M${x0},${headerTop} L${x0},${box.y + fullHeight}`, 'none', { color: '#c5cde0', width: 1.5 }));
98
209
  }
99
210
 
100
211
  // The rows: a member (indented, with its ▸ / ▾) and its cells; members with members below are totals.
101
212
  const cuts = view.lights && view.lights !== 'rules' ? view.thirds() : null; // once per drawing, not per cell
102
- view.visibleRows().forEach(({ node, depth }, r) => {
213
+ const colMaxValues = this.options.dataBars ? columns.map(c => {
214
+ let max = 0;
215
+ view.visibleRows().forEach(({ node }) => {
216
+ const isTotal = node.children.size > 0 || node.path.length === 0;
217
+ if (!isTotal) {
218
+ const r = view.cell(node, c.node);
219
+ const val = r?.[c.measure];
220
+ if (typeof val === 'number' && Number.isFinite(val) && val > max) max = val;
221
+ }
222
+ });
223
+ return max;
224
+ }) : null;
225
+
226
+ const allRows = view.visibleRows();
227
+ const totalRows = allRows.length;
228
+ const isVirtual = this._isVirtualizable();
229
+ this.isVirtual = isVirtual;
230
+
231
+ let startRow = 0;
232
+ let endRow = totalRows;
233
+
234
+ if (isVirtual) {
235
+ const container = this.renderer.container;
236
+ const viewportH = this.options.viewportHeight || container?.clientHeight || 400;
237
+ const range = this._getVisibleRowRange(this._scrollTop, viewportH);
238
+ startRow = range.start;
239
+ endRow = range.end;
240
+ this._renderedRange = range;
241
+ } else {
242
+ this._renderedRange = { start: 0, end: totalRows };
243
+ }
244
+
245
+ this._rowShapesStartIndex = Array.isArray(this.renderer?.shapes) ? this.renderer.shapes.length : null;
246
+ this._rowCtx = {
247
+ cuts, colMaxValues, columns, box, sideW, colW, x0, end, interactive, nullText,
248
+ bodyGroup: bodyRowsGroup || bodyGroup,
249
+ rowHeaderGroup: rowHeadersContentGroup || rowHeaderGroup,
250
+ };
251
+
252
+ this._renderRowsSlice(startRow, endRow, this._rowCtx);
253
+
254
+ let notesTop = this._rowTopStart + totalRows * ROW_H;
255
+ if (view.unplaced > 0) {
256
+ inGroup(bodyGroup, this._text(`${view.unplaced} row(s) total an outer level but not an inner one, and are not shown`, box.x + PAD_X, notesTop,
257
+ { textAnchor: 'start', fill: '#888', fontSize: '11px' }));
258
+ notesTop += ROW_H;
259
+ }
260
+ const legend = view.lightsLegend();
261
+ if (legend) inGroup(bodyGroup, this._text(legend, box.x + PAD_X, notesTop, { textAnchor: 'start', fill: '#666', fontSize: '11px' }));
262
+
263
+ this._wireScrollListener();
264
+ }
265
+
266
+ _renderRowsSlice(startRow, endRow, ctx) {
267
+ const { view } = this;
268
+ const { ROW_H } = TableChart;
269
+ const { cuts, colMaxValues, columns, box, sideW, colW, x0, end, interactive, nullText, bodyGroup, rowHeaderGroup } = ctx;
270
+ const inGroup = (group, shapeEl) => {
271
+ if (group && typeof group.appendChild === 'function' && shapeEl && shapeEl.nodeType) {
272
+ group.appendChild(shapeEl);
273
+ }
274
+ return shapeEl;
275
+ };
276
+
277
+ const allRows = view.visibleRows();
278
+ for (let r = startRow; r < endRow; r++) {
279
+ const item = allRows[r];
280
+ if (!item) continue;
281
+ const { node, depth } = item;
282
+ let top = this._rowTopStart + r * ROW_H;
103
283
  const total = node.children.size > 0 || node.path.length === 0;
104
- if (total) this.renderer.rect(box.x, top, box.width, ROW_H, PivotGridChart.TOTAL_FILL, null);
105
- else if (r % 2 === 1) this.renderer.rect(box.x, top, box.width, ROW_H, TableChart.STRIPE_FILL, null);
284
+
285
+ if (total) inGroup(bodyGroup, this.renderer.rect(box.x, top, box.width, ROW_H, PivotGridChart.TOTAL_FILL, null));
286
+ else if (r % 2 === 1) inGroup(bodyGroup, this.renderer.rect(box.x, top, box.width, ROW_H, TableChart.STRIPE_FILL, null));
287
+
288
+ if (rowHeaderGroup && interactive) {
289
+ const headerFill = total ? PivotGridChart.TOTAL_FILL : (r % 2 === 1 ? TableChart.STRIPE_FILL : '#ffffff');
290
+ inGroup(rowHeaderGroup, this.renderer.rect(box.x, top, sideW, ROW_H, headerFill, null));
291
+ }
292
+
106
293
  const indent = node.path.length === 0 ? 0 : depth * PivotGridChart.INDENT;
107
294
  const weight = total ? { fontWeight: 'bold' } : {};
108
- this._member('rows', node.path.length === 0 ? null : node, PivotView.label(node), box.x + indent, top, sideW - indent, interactive, { fill: '#222', ...weight });
295
+ this._member('rows', node.path.length === 0 ? null : node, PivotView.label(node), box.x + indent, top, sideW - indent, interactive, { fill: '#222', ...weight }, rowHeaderGroup);
109
296
  columns.forEach((c, i) => {
110
297
  const lit = view.cellFill(node, c, cuts);
111
- if (lit) this.renderer.rect(x0 + i * colW + 1, top + 1, colW - 2, ROW_H - 2, lit, null);
298
+ if (lit) inGroup(bodyGroup, this.renderer.rect(x0 + i * colW + 1, top + 1, colW - 2, ROW_H - 2, lit, null));
112
299
  const row = view.cell(node, c.node);
300
+ if (colMaxValues && !total) {
301
+ const val = row?.[c.measure];
302
+ const colMax = colMaxValues[i];
303
+ if (typeof val === 'number' && Number.isFinite(val) && val > 0 && colMax > 0) {
304
+ const barW = Math.max(2, (val / colMax) * (colW - 8));
305
+ const barColor = typeof this.options.dataBars === 'string' ? this.options.dataBars : (this.options.dataBarColor || 'rgba(66, 133, 244, 0.22)');
306
+ inGroup(bodyGroup, this.renderer.rect(x0 + i * colW + 4, top + 4, barW, ROW_H - 8, barColor, null));
307
+ }
308
+ }
113
309
  const text = view.cellText ? view.cellText(node, c, nullText) : (row === null ? '' : view.valueText(c.measure, row[c.measure], nullText));
114
- const shown = this._cell(text, end, x0 + i * colW, top, colW, { fill: row?.[c.measure] == null ? '#999' : '#222', ...weight });
310
+ const shown = inGroup(bodyGroup, this._cell(text, end, x0 + i * colW, top, colW, { fill: row?.[c.measure] == null ? '#999' : '#222', ...weight }));
115
311
  // A cell with a value opens its menu (drill-through) where the host gives one.
116
312
  if (row !== null && view.cellHasMenu()) {
117
- const hit = this.renderer.rect(x0 + i * colW, top, colW, ROW_H, 'transparent', null);
313
+ const hit = inGroup(bodyGroup, this.renderer.rect(x0 + i * colW, top, colW, ROW_H, 'transparent', null));
118
314
  this._on(interactive, [hit, shown], (e) => this.showMenu(e.clientX, e.clientY, view.cellMenu(node, c)), 'Cell menu');
119
315
  }
120
316
  });
121
317
  top += ROW_H;
122
- this._rule(box.x, top, box.width);
123
- });
318
+ inGroup(bodyGroup, this._rule(box.x, top, box.width));
319
+ }
320
+ }
124
321
 
125
- if (view.unplaced > 0) {
126
- this._text(`${view.unplaced} row(s) total an outer level but not an inner one, and are not shown`, box.x + PAD_X, top,
127
- { textAnchor: 'start', fill: '#888', fontSize: '11px' });
128
- top += ROW_H;
322
+ _updateVirtualSlice(start, end) {
323
+ if (!this._rowCtx) return;
324
+ const { bodyGroup, rowHeaderGroup } = this._rowCtx;
325
+
326
+ if (bodyGroup && typeof bodyGroup.replaceChildren === 'function') {
327
+ bodyGroup.replaceChildren();
328
+ } else if (bodyGroup && bodyGroup.firstChild) {
329
+ while (bodyGroup.firstChild) bodyGroup.removeChild(bodyGroup.firstChild);
330
+ }
331
+ if (rowHeaderGroup && typeof rowHeaderGroup.replaceChildren === 'function') {
332
+ rowHeaderGroup.replaceChildren();
333
+ } else if (rowHeaderGroup && rowHeaderGroup.firstChild) {
334
+ while (rowHeaderGroup.firstChild) rowHeaderGroup.removeChild(rowHeaderGroup.firstChild);
335
+ }
336
+
337
+ if (Array.isArray(this.renderer?.shapes) && this._rowShapesStartIndex != null) {
338
+ this.renderer.shapes.length = this._rowShapesStartIndex;
339
+ }
340
+
341
+ this._renderRowsSlice(start, end, this._rowCtx);
342
+ }
343
+
344
+ _handleScroll(scrollLeft, scrollTop) {
345
+ this._scrollLeft = scrollLeft;
346
+ this._scrollTop = scrollTop;
347
+
348
+ if (this.options.pinnedHeaders !== false && this.renderer?.supportsSceneGraph) {
349
+ if (this._colHeaderGroup?.setAttribute) {
350
+ this._colHeaderGroup.setAttribute('transform', `translate(0, ${scrollTop})`);
351
+ }
352
+ if (this._rowHeaderGroup?.setAttribute) {
353
+ this._rowHeaderGroup.setAttribute('transform', `translate(${scrollLeft}, 0)`);
354
+ }
355
+ if (this._cornerGroup?.setAttribute) {
356
+ this._cornerGroup.setAttribute('transform', `translate(${scrollLeft}, ${scrollTop})`);
357
+ }
358
+ if (this._colDivider?.setAttribute) {
359
+ this._colDivider.setAttribute('transform', `translate(0, ${scrollTop})`);
360
+ }
361
+ if (this._rowDivider?.setAttribute) {
362
+ this._rowDivider.setAttribute('transform', `translate(${scrollLeft}, 0)`);
363
+ }
364
+
365
+ if (this._rowHeaderGroup?.style) {
366
+ this._rowHeaderGroup.style.filter = scrollLeft > 0 ? 'drop-shadow(2px 0 3px rgba(0,0,0,0.12))' : '';
367
+ }
368
+ if (this._colHeaderGroup?.style) {
369
+ this._colHeaderGroup.style.filter = scrollTop > 0 ? 'drop-shadow(0 2px 3px rgba(0,0,0,0.12))' : '';
370
+ }
371
+ }
372
+
373
+ if (this._isVirtualizable()) {
374
+ const container = this.renderer.container;
375
+ const viewportH = this.options.viewportHeight || container?.clientHeight || 400;
376
+ const newRange = this._getVisibleRowRange(scrollTop, viewportH);
377
+ if (!this._renderedRange || newRange.start !== this._renderedRange.start || newRange.end !== this._renderedRange.end) {
378
+ this._renderedRange = newRange;
379
+ this._updateVirtualSlice(newRange.start, newRange.end);
380
+ }
381
+ }
382
+
383
+ if (typeof this.options.onScroll === 'function') {
384
+ this.options.onScroll({ scrollLeft, scrollTop, range: this._renderedRange });
385
+ }
386
+ }
387
+
388
+ _wireScrollListener() {
389
+ const container = this.renderer.container;
390
+ if (!container || typeof container.addEventListener !== 'function') return;
391
+
392
+ if (this._scrollHandler && this._scrollContainer) {
393
+ this._scrollContainer.removeEventListener('scroll', this._scrollHandler);
394
+ }
395
+
396
+ this._scrollContainer = container;
397
+ this._scrollHandler = () => {
398
+ const scrollLeft = container.scrollLeft || 0;
399
+ const scrollTop = container.scrollTop || 0;
400
+ this._handleScroll(scrollLeft, scrollTop);
401
+ };
402
+
403
+ container.addEventListener('scroll', this._scrollHandler);
404
+
405
+ if (container.scrollLeft || container.scrollTop) {
406
+ this._handleScroll(container.scrollLeft || 0, container.scrollTop || 0);
129
407
  }
130
- const legend = view.lightsLegend();
131
- if (legend) this._text(legend, box.x + PAD_X, top, { textAnchor: 'start', fill: '#666', fontSize: '11px' });
132
408
  }
133
409
 
134
410
  /**
@@ -136,8 +412,14 @@ export class PivotGridChart extends TableViewChart {
136
412
  * member, or All) and "Add" (the dimensions not placed). Each dimension is a chip; a click opens
137
413
  * its layout menu. Returns where the grid starts.
138
414
  */
139
- _layoutStrip(box, top, interactive) {
415
+ _layoutStrip(box, top, interactive, colHeaderGroup = null) {
140
416
  const { view } = this;
417
+ const inGroup = (shapeEl) => {
418
+ if (colHeaderGroup && typeof colHeaderGroup.appendChild === 'function' && shapeEl && shapeEl.nodeType) {
419
+ colHeaderGroup.appendChild(shapeEl);
420
+ }
421
+ return shapeEl;
422
+ };
141
423
  const zones = [
142
424
  [['Rows', view.rowAxis.map(d => [d, d + view.memberFilterText(d)])], ['Columns', view.columnAxis.map(d => [d, d + view.memberFilterText(d)])]],
143
425
  [['Filters', view.pageAxis.map(p => [p.dimension, `${p.dimension}: ${p.value === null ? 'All' : TableChart.cellText(p.value)}`])],
@@ -147,17 +429,17 @@ export class PivotGridChart extends TableViewChart {
147
429
  for (const line of zones) {
148
430
  let x = box.x + TableChart.PAD_X;
149
431
  for (const [name, chips] of line) {
150
- this._text(name, x, top, { textAnchor: 'start', fill: '#666', fontSize: '11px', fontWeight: 'bold' });
432
+ inGroup(this._text(name, x, top, { textAnchor: 'start', fill: '#666', fontSize: '11px', fontWeight: 'bold' }));
151
433
  x += measure(name) + 8;
152
434
  if (chips.length === 0) {
153
- this._text('—', x, top, { textAnchor: 'start', fill: '#aaa' });
435
+ inGroup(this._text('—', x, top, { textAnchor: 'start', fill: '#aaa' }));
154
436
  x += 16;
155
437
  }
156
438
  for (const [dimension, text] of chips) {
157
439
  const label = `${text} ▾`;
158
440
  const w = measure(label) + 14;
159
- const chip = this.renderer.rect(x, top + 3, w, TableChart.ROW_H - 6, PivotGridChart.TOTAL_FILL, { color: '#c5cde0', width: 1 });
160
- const t = this._text(label, x + 7, top, { textAnchor: 'start', fill: '#2b3f66' });
441
+ const chip = inGroup(this.renderer.rect(x, top + 3, w, TableChart.ROW_H - 6, PivotGridChart.TOTAL_FILL, { color: '#c5cde0', width: 1 }));
442
+ const t = inGroup(this._text(label, x + 7, top, { textAnchor: 'start', fill: '#2b3f66' }));
161
443
  this._on(interactive, [chip, t], (e) => this.showMenu(e.clientX, e.clientY, view.layoutMenu(dimension)), `${dimension}: move, filter or remove`);
162
444
  x += w + 6;
163
445
  }
@@ -172,20 +454,26 @@ export class PivotGridChart extends TableViewChart {
172
454
  * A member's header: ▸ / ▾ when it can collapse onto its total (a click toggles it), then its label
173
455
  * (a click opens its menu). Without a member (a total's or a blank header) just the text.
174
456
  */
175
- _member(axis, node, text, left, top, width, interactive, attrs) {
457
+ _member(axis, node, text, left, top, width, interactive, attrs, group = null) {
176
458
  const { PAD_X } = TableChart;
459
+ const inGroup = (shapeEl) => {
460
+ if (group && typeof group.appendChild === 'function' && shapeEl && shapeEl.nodeType) {
461
+ group.appendChild(shapeEl);
462
+ }
463
+ return shapeEl;
464
+ };
177
465
  if (node === null) {
178
- this._cell(text, {}, left, top, width, attrs);
179
- return;
466
+ return inGroup(this._cell(text, {}, left, top, width, attrs));
180
467
  }
181
468
  // Room for the ▸ / ▾ on every member, so a level down is indented whether or not it has one.
182
469
  const labelLeft = left + 14;
183
470
  if (this.view.collapsible(node)) {
184
471
  const sign = this.view.isCollapsed(axis, node) ? PivotGridChart.COLLAPSED : PivotGridChart.EXPANDED;
185
- const toggle = this._text(sign, left + PAD_X + 3, top, { textAnchor: 'middle', fill: '#555' });
472
+ const toggle = inGroup(this._text(sign, left + PAD_X + 3, top, { textAnchor: 'middle', fill: '#555' }));
186
473
  this._on(interactive, [toggle], () => this.view.toggle(axis, node), this.view.isCollapsed(axis, node) ? 'Expand' : 'Collapse');
187
474
  }
188
- const label = this._cell(text, {}, labelLeft, top, width - (labelLeft - left), attrs);
475
+ const label = inGroup(this._cell(text, {}, labelLeft, top, width - (labelLeft - left), attrs));
189
476
  this._on(interactive, [label], (e) => this.showMenu(e.clientX, e.clientY, this.view.memberMenu(axis, node)), `${text} menu`);
477
+ return label;
190
478
  }
191
479
  }
@@ -746,6 +746,88 @@ export class PivotView {
746
746
  return layout;
747
747
  }
748
748
 
749
+ /**
750
+ * The layout keeping only the specified `values` for `dimension` (multi-selection slice & dice).
751
+ * @param {string} dimension
752
+ * @param {Array<any>|any} values
753
+ * @returns {object} layout
754
+ */
755
+ withMemberKeep(dimension, values) {
756
+ const keep = Array.isArray(values) ? values : [values];
757
+ const layout = this.layout();
758
+ layout.members = [...layout.members.filter(m => m.dimension !== dimension), { dimension, keep }];
759
+ return layout;
760
+ }
761
+
762
+ /**
763
+ * The layout excluding the specified `values` for `dimension`.
764
+ * @param {string} dimension
765
+ * @param {Array<any>|any} values
766
+ * @returns {object} layout
767
+ */
768
+ withMemberRemove(dimension, values) {
769
+ const removeList = Array.isArray(values) ? values : [values];
770
+ const layout = this.layout();
771
+ const current = layout.members.find(m => m.dimension === dimension);
772
+ const others = layout.members.filter(m => m.dimension !== dimension);
773
+ const combined = Array.from(new Set([...(current?.remove ?? []), ...removeList]));
774
+ layout.members = [...others, { dimension, remove: combined }];
775
+ return layout;
776
+ }
777
+
778
+ /**
779
+ * Returns unique member values for a given dimension across facts or rows.
780
+ * @param {string} dimension
781
+ * @returns {Array<any>}
782
+ */
783
+ uniqueMembersOf(dimension) {
784
+ const pool = this.facts ?? this.rows;
785
+ const set = new Set();
786
+ pool.forEach(r => {
787
+ const val = r[dimension];
788
+ if (val != null) set.add(val);
789
+ });
790
+ return Array.from(set);
791
+ }
792
+
793
+ /**
794
+ * Opens a multi-member slice & dice filter dialog for `dimension`.
795
+ * @param {string} dimension
796
+ */
797
+ showMemberFilterDialog(dimension) {
798
+ if (!this.onLayout || !dimension) return;
799
+ const all = this.uniqueMembersOf(dimension);
800
+ if (!all.length) return;
801
+ const currentFilter = this.memberFilterOf(dimension);
802
+ const selected = currentFilter
803
+ ? (currentFilter.keep ?? all.filter(m => !currentFilter.remove?.includes(m)))
804
+ : all;
805
+
806
+ if (typeof document !== 'undefined') {
807
+ import('../FormDialog.js').then(({ FormDialog }) => {
808
+ new FormDialog({
809
+ title: `Filter ${dimension}`,
810
+ fields: [
811
+ {
812
+ key: 'members',
813
+ label: 'Members to include',
814
+ type: 'checklist',
815
+ choices: all.map(m => [m, TableChart.cellText(m)]),
816
+ value: selected,
817
+ },
818
+ ],
819
+ onApply: ({ members }) => {
820
+ if (!members || members.length === all.length) {
821
+ this.onLayout(this.allMembers(dimension));
822
+ } else {
823
+ this.onLayout(this.withMemberKeep(dimension, members));
824
+ }
825
+ },
826
+ }).open();
827
+ }).catch(() => {});
828
+ }
829
+ }
830
+
749
831
  /** A member's keep-or-remove items, the host's through onLayout; none for a NULL member (no filter holds one). */
750
832
  _memberFilterItems(dimension, node) {
751
833
  if (!this.onLayout || !dimension) return [];