@peter.prib/renderengine 0.10.0 → 0.13.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 +10 -4
- package/dist/renderengine-workspace.umd.js +5779 -3731
- package/dist/renderengine.umd.js +5757 -3725
- package/documentation/REFERENCE.md +3291 -3077
- package/package.json +2 -2
- package/src/charting/Axis.js +47 -5
- package/src/charting/Chart.js +25 -0
- package/src/charting/ChartChooser.js +5 -4
- package/src/charting/DiagramEditor.js +239 -34
- package/src/charting/Legend.js +16 -0
- package/src/charting/ModelFileFormat.js +47 -0
- package/src/charting/archimate/ArchimateCanvasEditor.js +1108 -1012
- package/src/charting/archimate/deleteFromModel.js +57 -0
- package/src/charting/archimate/elementMenuItems.js +3 -1
- package/src/charting/archimate/heatMaps.js +169 -0
- package/src/charting/archimate/index.js +1 -0
- package/src/charting/charts/BarChart.js +5 -5
- package/src/charting/charts/BarChartBase.js +21 -3
- package/src/charting/charts/GroupedBarChart.js +42 -22
- package/src/charting/charts/LineChart.js +9 -8
- package/src/charting/charts/PieChart.js +2 -2
- package/src/charting/charts/TableChart.js +34 -5
- package/src/charting/diagrams/ArchimateModel.js +33 -0
- package/src/charting/diagrams/DeleteFromModelCommand.js +81 -0
- package/src/charting/diagrams/EdgeGeometry.js +16 -0
- package/src/charting/edgeStyle/EdgeStyleSchema.js +6 -2
- package/src/charting/pivot/PivotChart.js +50 -0
- package/src/charting/pivot/PivotGridChart.js +191 -0
- package/src/charting/pivot/PivotView.js +740 -0
- package/src/charting/table/TableView.js +84 -15
- package/src/charting/table/TableViewChart.js +5 -3
- package/src/charting/templates/Template.js +10 -0
- package/src/exportImage.js +45 -18
- package/src/index.js +2899 -2807
- package/src/workspace/parts/ArchimateCanvasPart.js +3 -0
- package/src/workspace/parts/DiagramEditorPart.js +2 -0
- package/src/workspace/parts/ModelHosted.js +15 -4
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Command } from '../CommandHistory.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DeleteFromModelCommand — elements taken out of the model (Archi's "Delete
|
|
5
|
+
* from Model"), not just off one view: their relationships go too (and any
|
|
6
|
+
* relationship ending on one of those), and with them every box and line
|
|
7
|
+
* drawing them on every view, and every view's own line (a connection) to
|
|
8
|
+
* one of those boxes. Undo puts everything back as it was, ids and all.
|
|
9
|
+
*
|
|
10
|
+
* What goes is ArchimateModel#usage's: say it before asking.
|
|
11
|
+
*/
|
|
12
|
+
export class DeleteFromModelCommand extends Command {
|
|
13
|
+
/**
|
|
14
|
+
* @param {ArchimateModel} model
|
|
15
|
+
* @param {Array<string>} elementIds
|
|
16
|
+
*/
|
|
17
|
+
constructor(model, elementIds) {
|
|
18
|
+
const ids = elementIds.filter(id => model.getElement(id));
|
|
19
|
+
const first = model.getElement(ids[0]);
|
|
20
|
+
super(ids.length === 1 ? `Delete "${first?.name || ids[0]}" from model` : `Delete ${ids.length} elements from model`);
|
|
21
|
+
this.model = model;
|
|
22
|
+
this.elementIds = ids;
|
|
23
|
+
this._before = null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
execute() {
|
|
27
|
+
const { model } = this;
|
|
28
|
+
if (!this.elementIds.length) return;
|
|
29
|
+
this._before = this._snapshot();
|
|
30
|
+
const { relationships } = model.usage(this.elementIds);
|
|
31
|
+
const goneElements = new Set(this.elementIds);
|
|
32
|
+
const goneRelationships = new Set(relationships.map(r => r.id));
|
|
33
|
+
for (const view of model.views()) {
|
|
34
|
+
if (Array.isArray(view.elementIds)) view.elementIds = view.elementIds.filter(id => !goneElements.has(id));
|
|
35
|
+
const goneBoxes = new Set();
|
|
36
|
+
for (const inst of [...(view.elementInstances?.values() ?? [])]) {
|
|
37
|
+
if (goneElements.has(inst.elementId)) { goneBoxes.add(inst.id); view.elementInstances.delete(inst.id); }
|
|
38
|
+
}
|
|
39
|
+
for (const inst of [...(view.relationshipInstances?.values() ?? [])]) {
|
|
40
|
+
if (goneRelationships.has(inst.relationshipId)) view.relationshipInstances.delete(inst.id);
|
|
41
|
+
}
|
|
42
|
+
for (const line of [...(view.viewConnections?.values() ?? [])]) {
|
|
43
|
+
if (goneBoxes.has(line.sourceInstanceId) || goneBoxes.has(line.targetInstanceId)) view.viewConnections.delete(line.id);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const id of goneRelationships) model.removeRelationship(id);
|
|
47
|
+
for (const id of goneElements) model.removeElement(id);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
undo() {
|
|
51
|
+
if (!this._before) return;
|
|
52
|
+
const { model } = this;
|
|
53
|
+
const b = this._before;
|
|
54
|
+
model._elements = new Map(b.elements);
|
|
55
|
+
model._elementFolder = new Map(b.folders);
|
|
56
|
+
model._relationships = new Map(b.relationships);
|
|
57
|
+
for (const v of b.views) {
|
|
58
|
+
v.view.elementIds = v.elementIds;
|
|
59
|
+
if (v.instances) v.view.elementInstances = new Map(v.instances);
|
|
60
|
+
if (v.lines) v.view.relationshipInstances = new Map(v.lines);
|
|
61
|
+
if (v.connections) v.view.viewConnections = new Map(v.connections);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Everything the delete changes, to put back (the objects themselves, so ids and state are kept). @private */
|
|
66
|
+
_snapshot() {
|
|
67
|
+
const { model } = this;
|
|
68
|
+
return {
|
|
69
|
+
elements: [...model._elements],
|
|
70
|
+
folders: [...model._elementFolder],
|
|
71
|
+
relationships: [...model._relationships],
|
|
72
|
+
views: model.views().map(view => ({
|
|
73
|
+
view,
|
|
74
|
+
elementIds: Array.isArray(view.elementIds) ? [...view.elementIds] : view.elementIds,
|
|
75
|
+
instances: view.elementInstances ? [...view.elementInstances] : null,
|
|
76
|
+
lines: view.relationshipInstances ? [...view.relationshipInstances] : null,
|
|
77
|
+
connections: view.viewConnections ? [...view.viewConnections] : null,
|
|
78
|
+
})),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -91,6 +91,22 @@ export class EdgeGeometry {
|
|
|
91
91
|
return denom > 0 ? 1 / denom : Math.max(hw, hh);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/** Pure. Where two boxes ({left, top, right, bottom}) face each other
|
|
95
|
+
* across a gap with their x ranges (or y ranges) overlapping, the
|
|
96
|
+
* shortest line between them runs straight across, at the middle of the
|
|
97
|
+
* overlap: `{axis: 'v', at: x, forward}` (a vertical line, `forward` when
|
|
98
|
+
* `a` is above `b`) or `{axis: 'h', at: y, forward}` (`a` left of `b`).
|
|
99
|
+
* Null when they overlap neither way, or each other. */
|
|
100
|
+
static acrossOverlap(a, b) {
|
|
101
|
+
const left = Math.max(a.left, b.left), right = Math.min(a.right, b.right);
|
|
102
|
+
const top = Math.max(a.top, b.top), bottom = Math.min(a.bottom, b.bottom);
|
|
103
|
+
const apartV = a.bottom <= b.top || b.bottom <= a.top;
|
|
104
|
+
const apartH = a.right <= b.left || b.right <= a.left;
|
|
105
|
+
if (left < right && apartV) return { axis: 'v', at: (left + right) / 2, forward: a.bottom <= b.top };
|
|
106
|
+
if (top < bottom && apartH) return { axis: 'h', at: (top + bottom) / 2, forward: a.right <= b.left };
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
/** Pure. Anchor for one end of an edge. A node that knows its own outline
|
|
95
111
|
* supplies `info.boundaryPoint(dx, dy) → {x, y}` (DiagramEditor templates
|
|
96
112
|
* do, rotation included), and the anchor is where the ray toward the other
|
|
@@ -20,8 +20,12 @@ export const EDGE_STYLE_PROPERTIES = Object.freeze([
|
|
|
20
20
|
choices: [['straight', 'Straight'], ['orthogonal', 'Orthogonal'], ['curved', 'Curved'], ['polyline', 'Around shapes']],
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
|
-
key: 'attach', label: 'Attach to', type: 'select', default: '
|
|
24
|
-
choices: [
|
|
23
|
+
key: 'attach', label: 'Attach to', type: 'select', default: 'shortest',
|
|
24
|
+
choices: [
|
|
25
|
+
['shortest', 'Shortest: straight across where the shapes overlap, else aimed at the other end'],
|
|
26
|
+
['floating', 'Outline, aimed at the other end'],
|
|
27
|
+
['sides', 'Middle of the facing side'],
|
|
28
|
+
],
|
|
25
29
|
},
|
|
26
30
|
{
|
|
27
31
|
key: 'lineStyle', label: 'Line', type: 'select', default: 'solid',
|
|
@@ -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 ? '' : 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 });
|
|
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
|
+
}
|