@peter.prib/renderengine 0.9.0 → 0.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peter.prib/renderengine",
3
- "version": "0.9.0",
3
+ "version": "0.12.0",
4
4
  "description": "ArchiMate modeller, diagram editor and chart library for the browser, with no runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -20,7 +20,7 @@
20
20
  "examples/diagram-editor.html",
21
21
  "examples/data-model.html",
22
22
  "README.md",
23
- "REFERENCE.md",
23
+ "documentation/REFERENCE.md",
24
24
  "LICENSE"
25
25
  ],
26
26
  "scripts": {
@@ -28,7 +28,7 @@
28
28
  "archimate": "node scripts/open.mjs examples/archimate.html",
29
29
  "diagram": "node scripts/open.mjs examples/diagram-editor.html",
30
30
  "datamodel": "node scripts/open.mjs examples/data-model.html",
31
- "test": "node --test --test-timeout=10000 ./test/*.js ./test/charting/*.js ./test/charting/charts/*.js ./test/charting/table/*.js ./test/charting/diagrams/*.js ./test/charting/shapes/*.js",
31
+ "test": "node --test --test-timeout=10000 ./test/*.js ./test/charting/*.js ./test/charting/charts/*.js ./test/charting/table/*.js ./test/charting/pivot/*.js ./test/charting/diagrams/*.js ./test/charting/shapes/*.js",
32
32
  "build": "rollup -c",
33
33
  "prepublishOnly": "npm test && npm run build",
34
34
  "screenshots": "node scripts/screenshots/screenshots.mjs"
package/src/bian.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * (github.com/bian-official/public, Apache-2.0) for the names control records
13
13
  * have and, where a pattern aggregates two creating terms (Monitor: evaluate
14
14
  * and initiate), the one its APIs use. Every value is BIAN's; `inferred` is
15
- * kept for a value that is not. See BIAN_ALIGNMENT.md.
15
+ * kept for a value that is not. See documentation/BIAN_ALIGNMENT.md.
16
16
  */
17
17
 
18
18
  /** One way a service domain behaves: what it does to its asset, and the record it keeps of it. */
@@ -1,7 +1,7 @@
1
1
  // Copyright 2026 Jaroslav Peter Prib, Purveyor of Excellence
2
2
  /**
3
3
  * Single source of truth for "what chart types does RenderEngine support" —
4
- * see ARCHITECTURE.md's "Chart-type catalog" section for the full story.
4
+ * see documentation/ARCHITECTURE.md's "Chart-type catalog" section for the full story.
5
5
  *
6
6
  * This is not just a flat {key,label} list. Chart types share real,
7
7
  * composable capabilities — every one of them takes a title; several accept
@@ -4,6 +4,10 @@
4
4
  * Subclasses implement `_render()` and are instantiated by RenderEngine's
5
5
  * chart methods. Keeping each chart in its own class removes the giant
6
6
  * RenderEngine God class one chart at a time.
7
+ *
8
+ * A chart that draws one mark per value (a bar, a slice, a point) records it with `_datum()`; when
9
+ * the options carry `onDatumClick(datum, event)` and the renderer's shapes take clicks (SVG), each
10
+ * such mark answers a click with its datum, `{ label, series }` — how a host drills from a chart.
7
11
  */
8
12
  export class Chart {
9
13
  /** Registry key / catalog key for this chart type. Subclasses override. */
@@ -16,13 +20,34 @@ export class Chart {
16
20
  this.renderer = renderer;
17
21
  this.data = data;
18
22
  this.options = options;
23
+ /** The marks drawn for values, `{ shape, datum }`, as the last render() drew them. */
24
+ this.datums = [];
19
25
  }
20
26
 
21
27
  /** Render the chart. Subclasses implement `_render()`. */
22
28
  render() {
23
29
  if (!this.renderer) return;
24
30
  this.renderer.clear();
31
+ this.datums = [];
25
32
  this._render();
33
+ this._wireDatums();
34
+ }
35
+
36
+ /** Records that `shape` (what the renderer returned) shows one value, `datum` = { label, series? }. */
37
+ _datum(shape, datum) {
38
+ this.datums.push({ shape, datum: { series: null, ...datum } });
39
+ return shape;
40
+ }
41
+
42
+ /** Gives each recorded mark the host's onDatumClick, where the renderer's shapes take clicks. */
43
+ _wireDatums() {
44
+ const handler = this.options?.onDatumClick;
45
+ if (typeof handler !== 'function' || this.renderer.supportsSceneGraph !== true) return;
46
+ for (const { shape, datum } of this.datums) {
47
+ if (typeof shape?.addEventListener !== 'function') continue;
48
+ shape.addEventListener('click', (event) => { event.stopPropagation?.(); handler(datum, event); });
49
+ if (shape.style) shape.style.cursor = 'pointer';
50
+ }
26
51
  }
27
52
 
28
53
  /** Subclasses implement the actual drawing here. */
@@ -186,14 +186,15 @@ class CategorySeries extends ResultShape {
186
186
  const { category } = this;
187
187
  const time = category.kind === 'time';
188
188
  const rows = time ? inOrder(this.rows, category.name) : this.rows;
189
- // lineChart and groupedBarChart have no gap for a missing value: a null would have to be drawn
190
- // as 0, a figure the data does not hold. So a result with one is not offered as series.
191
- if (rows.some(r => this.numbers.some(n => r[n.name] == null))) return [];
189
+ // groupedBarChart leaves a gap for a missing value; lineChart has none (a null would have to be
190
+ // drawn as 0, a figure the data does not hold), so a result with one is offered as bars only.
191
+ const gaps = rows.some(r => this.numbers.some(n => r[n.name] == null));
192
192
  const data = this.numbers.map(n => ({
193
193
  name: n.name,
194
- values: rows.map(r => ({ label: label(r[category.name]), value: r[n.name] })),
194
+ values: rows.map(r => ({ label: label(r[category.name]), value: r[n.name] ?? null })),
195
195
  }));
196
196
  const names = this.numbers.map(n => n.name).join(', ');
197
+ if (gaps) return [{ key: 'groupedBar', reason: `${names} for each ${category.name} (a gap where there is no value)`, data }];
197
198
  return time
198
199
  ? [{ key: 'line', reason: `${names} over ${category.name}`, data }, { key: 'groupedBar', reason: `${names} for each ${category.name}`, data }]
199
200
  : [{ key: 'groupedBar', reason: `${names} for each ${category.name}`, data }, { key: 'line', reason: `${names} across ${category.name}`, data }];
@@ -36,6 +36,19 @@ export class Legend {
36
36
  * @param {number} drawingWidth - The renderer's width
37
37
  * @param {object} options - As the constructor's, without x and y
38
38
  */
39
+ /**
40
+ * The plot width that leaves room for this legend beside the plot, so it never covers the data:
41
+ * narrower by as much as the legend would run off the drawing — unless that would leave the plot
42
+ * under MIN_PLOT_SHARE of its width; then the plot keeps its width and beside() insets the legend.
43
+ */
44
+ static plotWidthBeside(box, drawingWidth, options) {
45
+ const { width } = new Legend({ ...options, x: 0, y: 0 }).size();
46
+ const over = box.x + box.width + 20 + width - drawingWidth;
47
+ if (!(drawingWidth > 0) || over <= 0) return box.width;
48
+ const narrower = box.width - over;
49
+ return narrower >= box.width * Legend.MIN_PLOT_SHARE ? narrower : box.width;
50
+ }
51
+
39
52
  static beside(box, drawingWidth, options) {
40
53
  const legend = new Legend({ ...options, x: 0, y: 0 });
41
54
  const { width } = legend.size();
@@ -99,3 +112,6 @@ export class Legend {
99
112
  }
100
113
  }
101
114
  }
115
+
116
+ /** The least share of its width a plot keeps when it narrows for a legend beside it. */
117
+ Legend.MIN_PLOT_SHARE = 0.65;
@@ -43,7 +43,7 @@ export class BarChart extends BarChartBase {
43
43
  const barX = x + i * (bandWidth + margin);
44
44
  const barY = y + height - barHeight;
45
45
 
46
- this.renderer.rect(barX, barY, bandWidth, barHeight, color, null, { bar: true, dataIndex: i, tooltip: `${d.label}: ${d.value}` });
46
+ this._datum(this.renderer.rect(barX, barY, bandWidth, barHeight, color, null, { bar: true, dataIndex: i, tooltip: `${d.label}: ${d.value}` }), { label: d.label });
47
47
  this.renderer.text(String(d.value), barX + bandWidth / 2, barY - 5, { textAnchor, fontSize: '10px', fill: '#333' });
48
48
  if (i % labelStep === 0) this.renderer.text(d.label, barX + bandWidth / 2, y + height + 20, { textAnchor });
49
49
  });
@@ -1,5 +1,7 @@
1
+ import { Axis } from '../Axis.js';
1
2
  import { Legend } from '../Legend.js';
2
3
  import { BarChartBase } from './BarChartBase.js';
4
+ import { TableChart } from './TableChart.js';
3
5
 
4
6
  /**
5
7
  * GroupedBarChart — vertical grouped (multi-set) bar chart.
@@ -7,7 +9,8 @@ import { BarChartBase } from './BarChartBase.js';
7
9
  * Data shape:
8
10
  * Array<{name: string, values: Array<{label: string, value: number}>}>
9
11
  *
10
- * Each category (`label`) gets a cluster of bars side-by-side, one per series.
12
+ * Each category (`label`) gets a cluster of bars side-by-side, one per series. A value a series does
13
+ * not have (missing, or null) is a gap in its cluster — no bar — never a bar of zero.
11
14
  */
12
15
  export class GroupedBarChart extends BarChartBase {
13
16
  static chartType = 'groupedBar';
@@ -15,7 +18,7 @@ export class GroupedBarChart extends BarChartBase {
15
18
  _computeMaxValue() {
16
19
  const series = this.data;
17
20
  const rawMax = Math.max(
18
- ...series.flatMap(s => s.values.map(v => v.value)),
21
+ ...series.flatMap(s => s.values.map(v => v.value).filter(v => v != null)),
19
22
  1,
20
23
  );
21
24
  return this._drawYAxis(rawMax);
@@ -32,7 +35,7 @@ export class GroupedBarChart extends BarChartBase {
32
35
  const labels = [...new Set(series.flatMap(s => s.values.map(v => v.label)))];
33
36
  const categoryCount = labels.length;
34
37
 
35
- // Build a value lookup so missing labels per series render as 0.
38
+ // A value lookup per series; a label a series lacks is a gap.
36
39
  const valueBySeriesLabel = series.map(s => {
37
40
  const map = new Map();
38
41
  for (const v of s.values) map.set(v.label, v.value);
@@ -47,26 +50,50 @@ export class GroupedBarChart extends BarChartBase {
47
50
  const finalBarWidth = barWidth - barGap;
48
51
 
49
52
  const textAnchor = this.renderer.centerTextAnchor;
53
+ // Labels that would overlap: each cut to its cluster ("…") while a cluster has room for a few
54
+ // characters, so every cluster stays named; past that, every nth label, as BarChart does.
55
+ const measure = (s) => this.renderer.measureText?.(s, { fontSize: '12px' }) ?? s.length * 12 * 0.55;
56
+ const room = clusterWidth - 4;
57
+ const cut = room >= measure('x'.repeat(GroupedBarChart.MIN_LABEL_CHARS));
58
+ const labelStep = cut ? 1 : Axis.categoryLabelStep(labels, width);
50
59
 
51
60
  labels.forEach((label, categoryIndex) => {
52
61
  const clusterX = x + categoryIndex * clusterWidth + groupGap / 2;
53
62
 
54
63
  series.forEach((seriesData, seriesIndex) => {
55
- const value = valueBySeriesLabel[seriesIndex].get(label) ?? 0;
64
+ const value = valueBySeriesLabel[seriesIndex].get(label);
65
+ if (value == null) return;
56
66
  const barHeight = (value / maxValue) * height;
57
67
  const barX = clusterX + seriesIndex * barWidth + barGap / 2;
58
68
  const barY = y + height - barHeight;
59
69
  const color = this._palette.colorAt(seriesIndex);
60
70
 
61
- this.renderer.rect(barX, barY, finalBarWidth, barHeight, color, null, {
71
+ this._datum(this.renderer.rect(barX, barY, finalBarWidth, barHeight, color, null, {
62
72
  tooltip: `${seriesData.name} — ${label}: ${value}`,
63
- });
73
+ }), { label, series: seriesData.name });
64
74
  });
65
75
 
66
- this.renderer.text(label, clusterX + availableWidth / 2, y + height + 20, { textAnchor });
76
+ if (categoryIndex % labelStep === 0) {
77
+ this.renderer.text(cut ? TableChart.fit(String(label), room, measure) : label, clusterX + availableWidth / 2, y + height + 20, { textAnchor });
78
+ }
67
79
  });
68
80
  }
69
81
 
82
+ /** With a legend, the plot leaves room for it beside, so it does not cover the last bars. */
83
+ _setup() {
84
+ super._setup();
85
+ if (this.options.legend && this.options.width === undefined) {
86
+ this._plotBox.width = Legend.plotWidthBeside(this._plotBox, this.renderer.width, this._legendOptions());
87
+ }
88
+ }
89
+
90
+ _legendOptions() {
91
+ return {
92
+ items: this.data.map((s, i) => ({ label: s.name, color: this._palette.colorAt(i), dataIndex: i })),
93
+ itemHeight: 20, markerSize: 10,
94
+ };
95
+ }
96
+
70
97
  _afterBars() {
71
98
  const series = this.data;
72
99
  const options = this.options;
@@ -74,15 +101,10 @@ export class GroupedBarChart extends BarChartBase {
74
101
  const { legend: showLegend = false } = options;
75
102
 
76
103
  if (showLegend) {
77
- const legendItems = series.map((s, i) => ({
78
- label: s.name,
79
- color: this._palette.colorAt(i),
80
- dataIndex: i,
81
- }));
82
- Legend.beside({ x, y, width }, this.renderer.width, {
83
- items: legendItems,
84
- itemHeight: 20, markerSize: 10,
85
- }).draw(this.renderer);
104
+ Legend.beside({ x, y, width }, this.renderer.width, this._legendOptions()).draw(this.renderer);
86
105
  }
87
106
  }
88
107
  }
108
+
109
+ /** The fewest characters a cut label keeps; clusters narrower than that thin their labels instead. */
110
+ GroupedBarChart.MIN_LABEL_CHARS = 6;
@@ -17,15 +17,19 @@ export class LineChart extends Chart {
17
17
  const options = this.options;
18
18
  const box = ChartBox.fromRenderer(this.renderer);
19
19
  const {
20
- x = box.x, y = box.y, width = box.width, height = box.height,
20
+ x = box.x, y = box.y, height = box.height,
21
21
  lineColor = 'steelblue', pointColor = '#fff', title, xAxisLabel, yAxisLabel,
22
22
  colors, zAxis = false, legend: showLegend = false, depth: zDepth = 20,
23
23
  } = options;
24
- const plotBox = { x, y, width, height };
25
24
 
26
25
  // Check if data is multiline (array of series) or single line
27
26
  const isMultiline = Array.isArray(data[0]?.values);
28
27
  const series = isMultiline ? data : [{ name: 'default', values: data }];
28
+ const legendOptions = { items: series.map(s => ({ name: s.name })), itemHeight: 20, markerSize: 12 };
29
+ // With a legend, the plot leaves room for it beside, so it does not cover the last points.
30
+ const width = options.width ?? (isMultiline && showLegend
31
+ ? Legend.plotWidthBeside({ x, width: box.width }, this.renderer.width, legendOptions) : box.width);
32
+ const plotBox = { x, y, width, height };
29
33
 
30
34
  // Determine max value across all series
31
35
  const maxValue = Math.max(...series.flatMap(s =>
@@ -82,13 +86,13 @@ export class LineChart extends Chart {
82
86
  const pointY = y + height - (d.value / maxValue) * height - seriesDepth;
83
87
  const textAnchor = this.renderer.centerTextAnchor;
84
88
 
85
- this.renderer.circle(
89
+ this._datum(this.renderer.circle(
86
90
  pointX, pointY,
87
91
  3,
88
92
  seriesColor,
89
93
  { color: pointColor, width: 1.5 },
90
94
  { tooltip: `${isMultiline ? seriesData.name + ': ' : ''}${d.label}: ${d.value}` }
91
- );
95
+ ), { label: d.label, series: isMultiline ? seriesData.name : null });
92
96
 
93
97
  const errMinus = d.errorMinus != null ? d.errorMinus : d.error != null ? d.error : null;
94
98
  const errPlus = d.errorPlus != null ? d.errorPlus : d.error != null ? d.error : null;
@@ -118,10 +122,7 @@ export class LineChart extends Chart {
118
122
  // Draw legend for multiline
119
123
  if (isMultiline && showLegend) {
120
124
  const legendItems = series.map((s, i) => ({ name: s.name, color: palette.colorAt(i) }));
121
- Legend.beside({ x, y, width }, this.renderer.width, {
122
- items: legendItems,
123
- itemHeight: 20, markerSize: 12,
124
- }).draw(this.renderer);
125
+ Legend.beside({ x, y, width }, this.renderer.width, { ...legendOptions, items: legendItems }).draw(this.renderer);
125
126
  }
126
127
  }
127
128
  }
@@ -33,9 +33,9 @@ export class PieChart extends Chart {
33
33
  const slice = (d.value / total) * Math.PI * 2;
34
34
  const color = palette.colorAt(i);
35
35
  const path = ShapePaths.donutSlicePath(cx, cy, radius, rInner, angle, angle + slice);
36
- this.renderer.path(path, color, { color: '#fff', width: 1 }, {
36
+ this._datum(this.renderer.path(path, color, { color: '#fff', width: 1 }, {
37
37
  tooltip: `${d.label}: ${d.value} (${(d.value / total * 100).toFixed(1)}%)`,
38
- });
38
+ }), { label: d.label });
39
39
  angle += slice;
40
40
  });
41
41
 
@@ -85,11 +85,24 @@ export class TableChart extends Chart {
85
85
  }
86
86
  }
87
87
 
88
+ /**
89
+ * `text` cut to fit `room` px, ending in "…" when cut, so a long value never draws over the next
90
+ * column. `measure(s)` gives a string's width; the whole text comes back when it fits.
91
+ */
92
+ static fit(text, room, measure) {
93
+ if (measure(text) <= room) return text;
94
+ let keep = text.length - 1;
95
+ while (keep > 0 && measure(text.slice(0, keep) + '…') > room) keep--;
96
+ return keep > 0 ? text.slice(0, keep) + '…' : '';
97
+ }
98
+
88
99
  _cell(text, column, left, top, colW, attrs) {
89
100
  const end = column.align === 'end';
90
- return this.renderer.text(text, end ? left + colW - TableChart.PAD_X : left + TableChart.PAD_X, top + TableChart.ROW_H / 2, {
91
- textAnchor: end ? 'end' : 'start', verticalAlign: 'middle', fontSize: '12px', ...attrs,
92
- });
101
+ const style = { textAnchor: end ? 'end' : 'start', verticalAlign: 'middle', fontSize: '12px', ...attrs };
102
+ const font = { fontSize: style.fontSize, fontWeight: style.fontWeight };
103
+ const measure = (s) => this.renderer.measureText?.(s, font) ?? s.length * (parseFloat(font.fontSize) || 12) * 0.55;
104
+ const shown = TableChart.fit(text, colW - 2 * TableChart.PAD_X, measure);
105
+ return this.renderer.text(shown, end ? left + colW - TableChart.PAD_X : left + TableChart.PAD_X, top + TableChart.ROW_H / 2, style);
93
106
  }
94
107
 
95
108
  _rule(x, y, width) {
@@ -0,0 +1,50 @@
1
+ import { ChartChooser } from '../ChartChooser.js';
2
+
3
+ /**
4
+ * PivotChart — a chart linked to a pivot grid (Alphablox's ChartBlox): it charts what the grid shows
5
+ * (PivotView.chartData: the members not broken down further, no totals), follows every change to the
6
+ * grid, and a click on a bar, point or slice drills the grid there (PivotView.drillFrom: expand a
7
+ * collapsed member, or ask the host to drill a level down). Which chart is ChartChooser's, from the
8
+ * charts that fit; the chart's own menu changes it. Kept apart from drawing, as PivotView is;
9
+ * RenderEngine.pivotChart draws it.
10
+ */
11
+ export class PivotChart {
12
+ /**
13
+ * @param {PivotView} view the grid's state
14
+ * @param {object} [options]
15
+ * @param {string} [options.type] a catalog key to prefer while it fits (else ChartChooser's best)
16
+ * @param {ChartChooser} [options.chooser]
17
+ */
18
+ constructor(view, { type = null, chooser = null } = {}) {
19
+ this.view = view;
20
+ this.type = type;
21
+ this.chooser = chooser ?? new ChartChooser();
22
+ }
23
+
24
+ /** The charts that fit what the grid shows, best first. The grid itself is the table. */
25
+ choices() {
26
+ const { rows, columns, complete } = this.view.chartData();
27
+ return this.chooser.choose(rows, columns, { complete }).filter(c => c.key !== 'table');
28
+ }
29
+
30
+ /** The chart to draw: the one asked for while it still fits, else the best; null when none fits. */
31
+ chosen() {
32
+ const choices = this.choices();
33
+ return choices.find(c => c.key === this.type) ?? choices[0] ?? null;
34
+ }
35
+
36
+ /** The chart's menu: "Chart as" each chart that fits, the one showing ticked. */
37
+ menu(redraw) {
38
+ const current = this.chosen()?.key;
39
+ return [{
40
+ label: 'Chart as',
41
+ items: this.choices().map(c => ({
42
+ label: `${c.label}${c.key === current ? ' ✓' : ''}`,
43
+ action: () => { this.type = c.key; redraw(); },
44
+ })),
45
+ }];
46
+ }
47
+
48
+ /** A click on a charted value: drill the grid at its member. */
49
+ click(datum) { return this.view.drillFrom(datum.label); }
50
+ }
@@ -0,0 +1,191 @@
1
+ import { TableChart } from '../charts/TableChart.js';
2
+ import { TableViewChart } from '../table/TableViewChart.js';
3
+ import { ChartBox, ChartLabels } from '../ChartBox.js';
4
+ import { PivotView } from './PivotView.js';
5
+
6
+ /**
7
+ * PivotGridChart — a pivot grid drawn from a PivotView's state, whose parts answer clicks: the
8
+ * column-axis members across the top (spanning the members beneath them), the row-axis members down
9
+ * the side (indented by level), and the measures in the cells. A member's ▸ / ▾ collapses or expands
10
+ * it; its label opens its menu (expand, collapse, drill down or up); the corner ⋮ opens the grid's
11
+ * menu (swap the axes, expand all, copy). Each change goes through the view, whose `onChange` redraws.
12
+ * With the view's `onLayout`, a layout strip above the grid shows the rows, the columns, the page
13
+ * filters and the dimensions that may be added, each a chip whose menu moves, filters or removes it.
14
+ *
15
+ * It shares the interactive table's drawing and click plumbing (TableViewChart). Interaction needs a
16
+ * renderer whose shapes are DOM elements (SVG); anywhere else the same grid is drawn without clicks.
17
+ */
18
+ export class PivotGridChart extends TableViewChart {
19
+ static INDENT = 14;
20
+ /** A value column is never narrower: past it the grid is drawn wider than its container, which scrolls. */
21
+ static MIN_COL_W = 88;
22
+ /** What a container that scrolls sideways adds for its scrollbar (and its border). */
23
+ static SCROLLBAR_H = 20;
24
+ static TOTAL_FILL = '#eef2f8';
25
+ static EXPANDED = '▾';
26
+ static COLLAPSED = '▸';
27
+
28
+ /** Lines the layout strip takes, when the view's layout is the host's to change. */
29
+ static layoutLines(view) { return view.onLayout ? 2 : 0; }
30
+
31
+ /** The height this draws: title, layout strip, the column headers' lines, a line per row, and a note if any. */
32
+ static heightFor(view, { title } = {}) {
33
+ const { top, bottom } = TableChart.margins(title);
34
+ const notes = (view.unplaced > 0 ? 1 : 0) + (view.lights ? 1 : 0);
35
+ const lines = PivotGridChart.layoutLines(view) + view.columnHeaders().length + view.visibleRows().length + notes;
36
+ return top + lines * TableChart.ROW_H + bottom;
37
+ }
38
+
39
+ /** The row headers' width: room for the deepest indent and a label, within a third of the grid. */
40
+ static rowHeaderWidth(view, width) {
41
+ const depth = Math.max(0, view.rowAxis.length - 1);
42
+ return Math.min(Math.max(120, 110 + depth * PivotGridChart.INDENT), 240, Math.max(90, width / 3));
43
+ }
44
+
45
+ /**
46
+ * The width this draws in a container `available` wide: that width, or wider when the value
47
+ * columns would otherwise be narrower than MIN_COL_W (a value cut short would mislead).
48
+ */
49
+ static widthFor(view, available) {
50
+ const { left, right } = TableChart.margins();
51
+ const columns = view.visibleColumns().length;
52
+ let width = available;
53
+ for (let pass = 0; pass < 2; pass++) { // the side's width depends on the total; twice settles it
54
+ width = Math.max(available, left + right + PivotGridChart.rowHeaderWidth(view, width) + columns * PivotGridChart.MIN_COL_W);
55
+ }
56
+ return width;
57
+ }
58
+
59
+ _render() {
60
+ const { view } = this;
61
+ const { title, nullText = '—' } = this.options;
62
+ const { ROW_H, PAD_X } = TableChart;
63
+ const box = ChartBox.fromRenderer(this.renderer, TableChart.margins(title));
64
+ const interactive = this.renderer.supportsSceneGraph === true;
65
+ const lines = view.columnHeaders();
66
+ const columns = view.visibleColumns();
67
+ const sideW = PivotGridChart.rowHeaderWidth(view, box.width);
68
+ const colW = Math.max(PivotGridChart.MIN_COL_W, columns.length > 0 ? (box.width - sideW) / columns.length : box.width - sideW);
69
+ const x0 = box.x + sideW;
70
+ const end = { align: 'end' };
71
+
72
+ new ChartLabels({ title }).draw(this.renderer, { x: box.x, y: box.y, width: box.width, height: box.height });
73
+
74
+ // The corner: the grid's menu, and what each axis holds.
75
+ 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' });
79
+ this._on(interactive, [corner, glyph], (e) => this.showMenu(e.clientX, e.clientY, view.gridMenu()), 'Grid menu');
80
+ 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' });
82
+ }
83
+ 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' });
85
+ }
86
+
87
+ // The column headers: a line per level, a member spanning the columns beneath it.
88
+ for (const line of lines) {
89
+ let left = x0;
90
+ for (const cell of line) {
91
+ 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' });
94
+ left += w;
95
+ }
96
+ top += ROW_H;
97
+ this._rule(box.x, top, box.width);
98
+ }
99
+
100
+ // The rows: a member (indented, with its ▸ / ▾) and its cells; members with members below are totals.
101
+ const cuts = view.lights && view.lights !== 'rules' ? view.thirds() : null; // once per drawing, not per cell
102
+ view.visibleRows().forEach(({ node, depth }, r) => {
103
+ 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);
106
+ const indent = node.path.length === 0 ? 0 : depth * PivotGridChart.INDENT;
107
+ 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 });
109
+ columns.forEach((c, i) => {
110
+ 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);
112
+ const row = view.cell(node, c.node);
113
+ const text = row === null ? '' : TableChart.cellText(row[c.measure], nullText);
114
+ const shown = this._cell(text, end, x0 + i * colW, top, colW, { fill: row?.[c.measure] == null ? '#999' : '#222', ...weight });
115
+ // A cell with a value opens its menu (drill-through) where the host gives one.
116
+ if (row !== null && view.cellHasMenu()) {
117
+ const hit = this.renderer.rect(x0 + i * colW, top, colW, ROW_H, 'transparent', null);
118
+ this._on(interactive, [hit, shown], (e) => this.showMenu(e.clientX, e.clientY, view.cellMenu(node, c)), 'Cell menu');
119
+ }
120
+ });
121
+ top += ROW_H;
122
+ this._rule(box.x, top, box.width);
123
+ });
124
+
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;
129
+ }
130
+ const legend = view.lightsLegend();
131
+ if (legend) this._text(legend, box.x + PAD_X, top, { textAnchor: 'start', fill: '#666', fontSize: '11px' });
132
+ }
133
+
134
+ /**
135
+ * The layout strip: "Rows" and "Columns" with their dimensions, then "Filters" (each held to a
136
+ * member, or All) and "Add" (the dimensions not placed). Each dimension is a chip; a click opens
137
+ * its layout menu. Returns where the grid starts.
138
+ */
139
+ _layoutStrip(box, top, interactive) {
140
+ const { view } = this;
141
+ const zones = [
142
+ [['Rows', view.rowAxis.map(d => [d, d + view.memberFilterText(d)])], ['Columns', view.columnAxis.map(d => [d, d + view.memberFilterText(d)])]],
143
+ [['Filters', view.pageAxis.map(p => [p.dimension, `${p.dimension}: ${p.value === null ? 'All' : TableChart.cellText(p.value)}`])],
144
+ ['Add', view.addable().map(d => [d, `+ ${d}`])]],
145
+ ];
146
+ const measure = (s) => this.renderer.measureText?.(s, { fontSize: '12px' }) ?? s.length * 12 * 0.55;
147
+ for (const line of zones) {
148
+ let x = box.x + TableChart.PAD_X;
149
+ for (const [name, chips] of line) {
150
+ this._text(name, x, top, { textAnchor: 'start', fill: '#666', fontSize: '11px', fontWeight: 'bold' });
151
+ x += measure(name) + 8;
152
+ if (chips.length === 0) {
153
+ this._text('—', x, top, { textAnchor: 'start', fill: '#aaa' });
154
+ x += 16;
155
+ }
156
+ for (const [dimension, text] of chips) {
157
+ const label = `${text} ▾`;
158
+ 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' });
161
+ this._on(interactive, [chip, t], (e) => this.showMenu(e.clientX, e.clientY, view.layoutMenu(dimension)), `${dimension}: move, filter or remove`);
162
+ x += w + 6;
163
+ }
164
+ x += 18;
165
+ }
166
+ top += TableChart.ROW_H;
167
+ }
168
+ return top;
169
+ }
170
+
171
+ /**
172
+ * A member's header: ▸ / ▾ when it can collapse onto its total (a click toggles it), then its label
173
+ * (a click opens its menu). Without a member (a total's or a blank header) just the text.
174
+ */
175
+ _member(axis, node, text, left, top, width, interactive, attrs) {
176
+ const { PAD_X } = TableChart;
177
+ if (node === null) {
178
+ this._cell(text, {}, left, top, width, attrs);
179
+ return;
180
+ }
181
+ // Room for the ▸ / ▾ on every member, so a level down is indented whether or not it has one.
182
+ const labelLeft = left + 14;
183
+ if (this.view.collapsible(node)) {
184
+ 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' });
186
+ this._on(interactive, [toggle], () => this.view.toggle(axis, node), this.view.isCollapsed(axis, node) ? 'Expand' : 'Collapse');
187
+ }
188
+ const label = this._cell(text, {}, labelLeft, top, width - (labelLeft - left), attrs);
189
+ this._on(interactive, [label], (e) => this.showMenu(e.clientX, e.clientY, this.view.memberMenu(axis, node)), `${text} menu`);
190
+ }
191
+ }