@peter.prib/renderengine 0.12.0 → 0.14.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 +454 -296
- package/dist/renderengine-workspace.umd.js +4122 -1167
- package/dist/renderengine.umd.js +4103 -1164
- package/documentation/REFERENCE.md +96 -6
- package/package.json +8 -3
- package/src/chartCatalog.js +50 -3
- package/src/charting/Axis.js +47 -5
- package/src/charting/Chart3D.js +4 -2
- package/src/charting/DiagramEditor.js +239 -34
- package/src/charting/Hierarchy.js +28 -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/ask/AskBar.js +388 -0
- package/src/charting/ask/AskView.js +400 -0
- package/src/charting/charts/AreaChart.js +20 -3
- package/src/charting/charts/BarChart.js +5 -5
- package/src/charting/charts/BarChart3D.js +2 -2
- package/src/charting/charts/BarChart3DOrbit.js +3 -1
- package/src/charting/charts/BarChartBase.js +21 -3
- package/src/charting/charts/BoxPlotChart.js +2 -2
- package/src/charting/charts/BubbleChart.js +4 -1
- package/src/charting/charts/ChordDiagram.js +4 -4
- package/src/charting/charts/DensityChart.js +1 -1
- package/src/charting/charts/FunnelChart.js +1 -1
- package/src/charting/charts/GroupedBarChart.js +7 -9
- package/src/charting/charts/HeatmapChart.js +4 -1
- package/src/charting/charts/HistogramChart.js +2 -2
- package/src/charting/charts/IcicleChart.js +47 -0
- package/src/charting/charts/LollipopChart.js +104 -0
- package/src/charting/charts/MarimekkoChart.js +75 -0
- package/src/charting/charts/OutlineChart.js +10 -8
- package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
- package/src/charting/charts/ParetoChart.js +92 -0
- package/src/charting/charts/PipeChart.js +5 -2
- package/src/charting/charts/PopulationPyramidChart.js +4 -4
- package/src/charting/charts/RadarChart.js +5 -4
- package/src/charting/charts/RadialBarChart.js +2 -2
- package/src/charting/charts/SankeyDiagram.js +4 -4
- package/src/charting/charts/ScatterChart.js +6 -3
- package/src/charting/charts/StackedBarChart.js +82 -11
- package/src/charting/charts/StackedBarChart3DOrbit.js +5 -2
- package/src/charting/charts/StreamGraphChart.js +8 -0
- package/src/charting/charts/SunburstChart.js +2 -2
- package/src/charting/charts/TableChart.js +18 -2
- package/src/charting/charts/TreemapChart.js +4 -4
- package/src/charting/charts/ViolinChart.js +2 -2
- package/src/charting/charts/WaterfallChart.js +2 -2
- package/src/charting/charts/WordCloudChart.js +2 -2
- 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 +205 -13
- package/src/charting/pivot/PivotGridChart.js +1 -1
- package/src/charting/pivot/PivotView.js +555 -23
- package/src/charting/table/TableView.js +10 -1
- package/src/charting/table/TableViewChart.js +1 -1
- package/src/charting/templates/Template.js +10 -0
- package/src/exportImage.js +45 -18
- package/src/index.js +115 -6
- package/src/workspace/parts/ArchimateCanvasPart.js +3 -0
- package/src/workspace/parts/DiagramEditorPart.js +2 -0
- package/src/workspace/parts/ModelHosted.js +15 -4
|
@@ -43,13 +43,16 @@ export class StackedBarChart3DOrbit extends Chart3D {
|
|
|
43
43
|
const segHeight = bar.d.values[j] / stackMax;
|
|
44
44
|
const y0 = yCursor, y1 = yCursor + segHeight;
|
|
45
45
|
yCursor = y1;
|
|
46
|
-
|
|
46
|
+
const series = options.seriesLabels?.[j] ?? `Series ${j + 1}`;
|
|
47
|
+
this.drawBox(this.renderer, bar.x0, bar.x1, y0, y1, bar.z0, bar.z1, palette.colorAt(j), toScreen, {
|
|
48
|
+
label: bar.d.x, series, x: bar.d.x, z: bar.d.z,
|
|
49
|
+
});
|
|
47
50
|
}
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
const maxSegments = Math.max(...data.map(d => d.values.length));
|
|
51
54
|
new Legend({
|
|
52
|
-
items: Array.from({ length: maxSegments }, (_, j) => ({ label: `Series ${j + 1}`, color: palette.colorAt(j) })),
|
|
55
|
+
items: Array.from({ length: maxSegments }, (_, j) => ({ label: options.seriesLabels?.[j] ?? `Series ${j + 1}`, color: palette.colorAt(j) })),
|
|
53
56
|
x: bx + width - 100, y: by + 5, itemHeight: 18, fontSize: '11px', marker: 'circle',
|
|
54
57
|
}).draw(this.renderer);
|
|
55
58
|
}
|
|
@@ -108,6 +108,14 @@ export class StreamGraphChart extends Chart {
|
|
|
108
108
|
const path = StreamGraphChart.areaPath(topPts, bottomPts, smooth, tension);
|
|
109
109
|
const { r, g, b: bl } = palette.hexToRgb(color);
|
|
110
110
|
this.renderer.path(path, `rgba(${r},${g},${bl},0.75)`, { color: color, width: 1 }, { tooltip: s.name });
|
|
111
|
+
if (typeof this.renderer.circle === 'function') {
|
|
112
|
+
topPts.forEach((p, i) => {
|
|
113
|
+
const d = s.values[i];
|
|
114
|
+
this._datum(this.renderer.circle(p.x, p.y, 3, color, { color: '#fff', width: 1 }, {
|
|
115
|
+
tooltip: `${isMultiline ? s.name + ': ' : ''}${d.label}: ${d.value}`,
|
|
116
|
+
}), { label: d.label, series: isMultiline ? s.name : null });
|
|
117
|
+
});
|
|
118
|
+
}
|
|
111
119
|
});
|
|
112
120
|
|
|
113
121
|
// X-axis labels.
|
|
@@ -37,9 +37,9 @@ export class SunburstChart extends HierarchyChart {
|
|
|
37
37
|
const textColor = this._textColorForNode(topAncestor, depth);
|
|
38
38
|
|
|
39
39
|
const path = ShapePaths.donutSlicePath(ecx, ecy, rOuter, rInner, startAngle, endAngle);
|
|
40
|
-
this.renderer.path(path, color, { color: '#fff', width: 1 }, {
|
|
40
|
+
this._datum(this.renderer.path(path, color, { color: '#fff', width: 1 }, {
|
|
41
41
|
tooltip: `${node.label}: ${this._formatNodeValue(node)}`,
|
|
42
|
-
});
|
|
42
|
+
}), { label: node.label });
|
|
43
43
|
|
|
44
44
|
if (endAngle - startAngle > 0.35) {
|
|
45
45
|
const midAngle = (startAngle + endAngle) / 2;
|
|
@@ -46,14 +46,30 @@ export class TableChart extends Chart {
|
|
|
46
46
|
return top + (1 + shown + more) * TableChart.ROW_H + bottom;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
49
|
+
/**
|
|
50
|
+
* A cell's text: a number exactly (to at most 4 decimals), null/undefined as `nullText`. With a
|
|
51
|
+
* `format` (`formatFor`'s), a number is written by it instead.
|
|
52
|
+
*/
|
|
53
|
+
static cellText(value, nullText = '—', format = null) {
|
|
51
54
|
if (value == null) return nullText;
|
|
55
|
+
if (typeof value === 'number' && format) return String(typeof format === 'function' ? format(value) : format.format(value));
|
|
52
56
|
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(4)));
|
|
53
57
|
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
|
54
58
|
return String(value);
|
|
55
59
|
}
|
|
56
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The number format for one column or measure, from a view's `format` option: a function
|
|
63
|
+
* (number → text) or an `Intl.NumberFormat` for every number, or an object naming either per
|
|
64
|
+
* column (`{ Amount: new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' }) }`),
|
|
65
|
+
* where a column it does not name keeps the plain text. Null when there is none.
|
|
66
|
+
*/
|
|
67
|
+
static formatFor(format, name) {
|
|
68
|
+
if (!format) return null;
|
|
69
|
+
if (typeof format === 'function' || format instanceof Intl.NumberFormat) return format;
|
|
70
|
+
return format[name] ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
57
73
|
_render() {
|
|
58
74
|
const data = this.data ?? [];
|
|
59
75
|
const box = ChartBox.fromRenderer(this.renderer, TableChart.margins(this.options.title));
|
|
@@ -27,9 +27,9 @@ export class TreemapChart extends HierarchyChart {
|
|
|
27
27
|
const textColor = this._textColorForNode(topAncestor, depth);
|
|
28
28
|
|
|
29
29
|
if (isLeaf) {
|
|
30
|
-
this.renderer.rect(rx, ry, Math.max(0, rw), Math.max(0, rh), color, { color: '#fff', width: 1 }, {
|
|
30
|
+
this._datum(this.renderer.rect(rx, ry, Math.max(0, rw), Math.max(0, rh), color, { color: '#fff', width: 1 }, {
|
|
31
31
|
tooltip: `${node.label}: ${this._formatNodeValue(node)}`,
|
|
32
|
-
});
|
|
32
|
+
}), { label: node.label });
|
|
33
33
|
if (rw > 30 && rh > 14) {
|
|
34
34
|
this.renderer.text(node.label, rx + rw / 2, ry + rh / 2, {
|
|
35
35
|
textAnchor, verticalAlign: 'middle', fontSize: '10px', fill: textColor, 'pointer-events': 'none',
|
|
@@ -38,9 +38,9 @@ export class TreemapChart extends HierarchyChart {
|
|
|
38
38
|
} else {
|
|
39
39
|
const headerH = Math.min(rh, TreemapChart.HEADER_H);
|
|
40
40
|
this.renderer.rect(rx, ry, Math.max(0, rw), Math.max(0, rh), 'rgba(0,0,0,0.03)', { color: '#ddd', width: 1 });
|
|
41
|
-
this.renderer.rect(rx, ry, Math.max(0, rw), headerH, color, null, {
|
|
41
|
+
this._datum(this.renderer.rect(rx, ry, Math.max(0, rw), headerH, color, null, {
|
|
42
42
|
tooltip: `${node.label}: ${this._formatNodeValue(node)}`,
|
|
43
|
-
});
|
|
43
|
+
}), { label: node.label });
|
|
44
44
|
if (rw > 30) {
|
|
45
45
|
this.renderer.text(node.label, rx + 4, ry + headerH / 2, {
|
|
46
46
|
textAnchor: 'start', verticalAlign: 'middle', fontSize: '9px', fontWeight: 'bold', fill: textColor, 'pointer-events': 'none',
|
|
@@ -27,9 +27,9 @@ export class ViolinChart extends GroupedDistributionChart {
|
|
|
27
27
|
const leftPts = curve.map(p => ({ px: cx - (p.density / maxDensity) * halfWidth, py: this._toY(p.x) }));
|
|
28
28
|
const path = 'M' + rightPts.map(p => `${p.px},${p.py}`).join(' L')
|
|
29
29
|
+ ' L' + [...leftPts].reverse().map(p => `${p.px},${p.py}`).join(' L') + ' Z';
|
|
30
|
-
this.renderer.path(path, color, { color: '#333', width: 1 }, {
|
|
30
|
+
this._datum(this.renderer.path(path, color, { color: '#333', width: 1 }, {
|
|
31
31
|
tooltip: `${d.label}: n=${d.values.length}`,
|
|
32
|
-
});
|
|
32
|
+
}), { label: d.label, series: d.label });
|
|
33
33
|
|
|
34
34
|
const median = Statistics.quantile([...d.values].sort((a, b) => a - b), 0.5);
|
|
35
35
|
this.renderer.path(`M${cx - halfWidth},${this._toY(median)} L${cx + halfWidth},${this._toY(median)}`, 'none', { color: '#fff', width: 2 });
|
|
@@ -58,9 +58,9 @@ export class WaterfallChart extends Chart {
|
|
|
58
58
|
const top = toY(Math.max(band.from, band.to));
|
|
59
59
|
const bottom = toY(Math.min(band.from, band.to));
|
|
60
60
|
const color = band.isTotal ? totalColor : (band.to >= band.from ? upColor : downColor);
|
|
61
|
-
this.renderer.rect(barX, top, barWidth, Math.max(1, bottom - top), color, null, {
|
|
61
|
+
this._datum(this.renderer.rect(barX, top, barWidth, Math.max(1, bottom - top), color, null, {
|
|
62
62
|
tooltip: `${d.label}: ${band.isTotal ? band.to : (d.value >= 0 ? '+' : '') + d.value} (total: ${Axis.formatValue(band.to)})`,
|
|
63
|
-
});
|
|
63
|
+
}), { label: d.label });
|
|
64
64
|
if (i % labelStep === 0) this.renderer.text(d.label, barX + barWidth / 2, y + height + 20, { textAnchor, fontSize: '10px' });
|
|
65
65
|
});
|
|
66
66
|
}
|
|
@@ -66,10 +66,10 @@ export class WordCloudChart extends Chart {
|
|
|
66
66
|
if (!pos) return; // Could not place; skip.
|
|
67
67
|
placed.push({ x: pos.x, y: pos.y, w: box.w, h: box.h });
|
|
68
68
|
const color = palette.colorAt(i);
|
|
69
|
-
this.renderer.text(d.text, pos.x, pos.y + fontSize * 0.35, {
|
|
69
|
+
this._datum(this.renderer.text(d.text, pos.x, pos.y + fontSize * 0.35, {
|
|
70
70
|
textAnchor, fontSize: `${fontSize}px`, fill: color, fontFamily,
|
|
71
71
|
tooltip: `${d.text}: ${d.value}`,
|
|
72
|
-
});
|
|
72
|
+
}), { label: d.text });
|
|
73
73
|
});
|
|
74
74
|
}
|
|
75
75
|
|
|
@@ -8,6 +8,7 @@ import { ArchimateModelChecker } from './ArchimateModelChecker.js';
|
|
|
8
8
|
import { ArchimateModelReport } from './ArchimateModelReport.js';
|
|
9
9
|
import { ArchimateModelSearch } from './ArchimateModelSearch.js';
|
|
10
10
|
import { MergeElementsCommand } from './MergeElementsCommand.js';
|
|
11
|
+
import { DeleteFromModelCommand } from './DeleteFromModelCommand.js';
|
|
11
12
|
import { ElementsCompared, RelationshipsCompared } from './ConceptsCompared.js';
|
|
12
13
|
import { ArchimateViewsCompared } from './ArchimateViewsCompared.js';
|
|
13
14
|
import { roadmapPlan, parseDate } from './roadmap.js';
|
|
@@ -244,6 +245,38 @@ export class ArchimateModel extends ViewSetModel {
|
|
|
244
245
|
return new MergeElementsCommand(this, keepId, otherIds, choices);
|
|
245
246
|
}
|
|
246
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Where elements are used, so deleting them from the model can say what goes
|
|
250
|
+
* with them: the views drawing them (with how many boxes on each), and the
|
|
251
|
+
* relationships that go too — each one ending on one of them, and each one
|
|
252
|
+
* ending on such a relationship, and so on.
|
|
253
|
+
* @param {Array<string>} elementIds
|
|
254
|
+
* @returns {{views: Array<{view: ArchimateView, boxes: number}>, relationships: Array<Object>}}
|
|
255
|
+
*/
|
|
256
|
+
usage(elementIds) {
|
|
257
|
+
const gone = new Set(elementIds);
|
|
258
|
+
const relationships = [];
|
|
259
|
+
for (let grew = true; grew;) {
|
|
260
|
+
grew = false;
|
|
261
|
+
for (const rel of this.relationships()) {
|
|
262
|
+
if (!gone.has(rel.id) && (gone.has(rel.source) || gone.has(rel.target))) {
|
|
263
|
+
gone.add(rel.id);
|
|
264
|
+
relationships.push(rel);
|
|
265
|
+
grew = true;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const views = this.views()
|
|
270
|
+
.map(view => ({ view, boxes: [...(view.elementInstances?.values() ?? [])].filter(i => elementIds.includes(i.elementId)).length }))
|
|
271
|
+
.filter(v => v.boxes > 0);
|
|
272
|
+
return { views, relationships };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Take elements out of the model, with their relationships and everything drawing them (not yet executed). */
|
|
276
|
+
deleteFromModelCommand(elementIds) {
|
|
277
|
+
return new DeleteFromModelCommand(this, elementIds);
|
|
278
|
+
}
|
|
279
|
+
|
|
247
280
|
/** Two models are compared on, and merged by, their elements, relationships and views (in the order brought). */
|
|
248
281
|
comparedKinds() {
|
|
249
282
|
return [new ElementsCompared(), new RelationshipsCompared(), new ArchimateViewsCompared()];
|
|
@@ -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',
|
|
@@ -23,8 +23,182 @@ export class PivotChart {
|
|
|
23
23
|
|
|
24
24
|
/** The charts that fit what the grid shows, best first. The grid itself is the table. */
|
|
25
25
|
choices() {
|
|
26
|
-
const
|
|
27
|
-
|
|
26
|
+
const chartData = this.view.chartData();
|
|
27
|
+
const { rows, columns, complete } = chartData;
|
|
28
|
+
const base = this.chooser.choose(rows, columns, { complete }).filter(c => c.key !== 'table');
|
|
29
|
+
const seen = new Set(base.map(c => c.key));
|
|
30
|
+
const extra = this._cubeChoices(chartData).filter(c => !seen.has(c.key) && this.chooser.registry.get(c.key));
|
|
31
|
+
return [...base, ...extra];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Additional chart types suited to a cube's hierarchy, series, and matrix structure beyond the
|
|
36
|
+
* general flat-table defaults in `ChartChooser`.
|
|
37
|
+
*/
|
|
38
|
+
_cubeChoices({ rows, columns, complete, hierarchy, rowTotals = null, primaryMeasure = null }) {
|
|
39
|
+
if (!rows || rows.length === 0) return [];
|
|
40
|
+
const categories = columns.filter(c => c.kind !== 'numeric');
|
|
41
|
+
const numbers = columns.filter(c => c.kind === 'numeric');
|
|
42
|
+
if (categories.length !== 1 || numbers.length === 0) return [];
|
|
43
|
+
const category = categories[0];
|
|
44
|
+
const add = (key, reason, data, options) => ({
|
|
45
|
+
key,
|
|
46
|
+
label: this.chooser.registry.get(key)?.label ?? key,
|
|
47
|
+
reason,
|
|
48
|
+
data,
|
|
49
|
+
...(options ? { options } : {}),
|
|
50
|
+
});
|
|
51
|
+
const out = [];
|
|
52
|
+
|
|
53
|
+
if (numbers.length === 1) {
|
|
54
|
+
const number = numbers[0];
|
|
55
|
+
const points = rows.filter(r => r[number.name] != null).map(r => ({
|
|
56
|
+
label: String(r[category.name]),
|
|
57
|
+
value: r[number.name],
|
|
58
|
+
}));
|
|
59
|
+
if (points.length < 2) return out;
|
|
60
|
+
this._addSingleSeriesChoices(out, add, category, number, points, complete, hierarchy, false);
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Multi-series (2+ numeric series from column-axis members or multiple measures).
|
|
65
|
+
const gaps = rows.some(r => numbers.some(n => r[n.name] == null));
|
|
66
|
+
if (gaps) return out;
|
|
67
|
+
|
|
68
|
+
const names = numbers.map(n => n.name).join(', ');
|
|
69
|
+
const seriesData = numbers.map(n => ({
|
|
70
|
+
name: n.name,
|
|
71
|
+
values: rows.map(r => ({ label: String(r[category.name]), value: r[n.name] })),
|
|
72
|
+
}));
|
|
73
|
+
const allNonNeg = rows.every(r => numbers.every(n => r[n.name] >= 0));
|
|
74
|
+
const additiveNonNeg = allNonNeg && numbers.every(n => n.measure !== 'semi-additive' && n.measure !== 'non-additive');
|
|
75
|
+
|
|
76
|
+
if (additiveNonNeg) {
|
|
77
|
+
out.push(add('stackedBar', `${names} stacked for each ${category.name}`, seriesData));
|
|
78
|
+
out.push(add('marimekko', `${names} share and total across ${category.name}, as a Marimekko mosaic`, seriesData));
|
|
79
|
+
}
|
|
80
|
+
out.push(add('lollipop', `${names} for each ${category.name}, as stems and dots`, seriesData));
|
|
81
|
+
if (allNonNeg && rows.length >= 2) {
|
|
82
|
+
out.push(add('area', `${names} across ${category.name}, filled`, seriesData));
|
|
83
|
+
}
|
|
84
|
+
if (additiveNonNeg && rows.length >= 2) {
|
|
85
|
+
out.push(add('stackedArea', `${names} stacked across ${category.name}`, seriesData));
|
|
86
|
+
out.push(add('streamGraph', `${names} flowing across ${category.name}`, seriesData));
|
|
87
|
+
}
|
|
88
|
+
if (allNonNeg && rows.length >= 2) {
|
|
89
|
+
out.push(add('pipe', `${names} tapering across ${category.name}`, seriesData.map(s => ({
|
|
90
|
+
name: s.name,
|
|
91
|
+
values: s.values.map(v => ({ label: v.label, value: v.value, metric: v.value })),
|
|
92
|
+
}))));
|
|
93
|
+
}
|
|
94
|
+
const heatData = rows.flatMap(r => numbers.map(n => ({
|
|
95
|
+
x: String(r[category.name]),
|
|
96
|
+
y: n.name,
|
|
97
|
+
value: r[n.name],
|
|
98
|
+
})));
|
|
99
|
+
out.push(add('heatmap', `${names} for each ${category.name}, as a colour grid`, heatData));
|
|
100
|
+
if (allNonNeg && rows.length >= 3 && rows.length <= 12) {
|
|
101
|
+
out.push(add('radar', `${names} across ${category.name} axes`, seriesData.map(s => ({
|
|
102
|
+
name: s.name,
|
|
103
|
+
values: s.values.map(v => ({ axis: v.label, value: v.value })),
|
|
104
|
+
}))));
|
|
105
|
+
}
|
|
106
|
+
if (rows.length >= 2) {
|
|
107
|
+
out.push(add('parallelCoordinates', `${names} across parallel axes for each ${category.name}`,
|
|
108
|
+
rows.map(r => ({
|
|
109
|
+
label: String(r[category.name]),
|
|
110
|
+
category: String(r[category.name]),
|
|
111
|
+
...Object.fromEntries(numbers.map(n => [n.name, r[n.name]])),
|
|
112
|
+
})),
|
|
113
|
+
{ dimensions: numbers.map(n => n.name) }));
|
|
114
|
+
if (allNonNeg) {
|
|
115
|
+
out.push(add('populationPyramid', `${numbers[0].name} vs ${numbers[1].name} for each ${category.name}`,
|
|
116
|
+
rows.map(r => ({ label: String(r[category.name]), left: r[numbers[0].name], right: r[numbers[1].name] })),
|
|
117
|
+
{ leftLabel: numbers[0].name, rightLabel: numbers[1].name, showValues: true }));
|
|
118
|
+
const flowLinks = rows.flatMap(r => numbers
|
|
119
|
+
.filter(n => r[n.name] > 0)
|
|
120
|
+
.map(n => ({ source: String(r[category.name]), target: n.name, value: r[n.name] })));
|
|
121
|
+
if (flowLinks.length > 0) {
|
|
122
|
+
out.push(add('sankey', `flow from ${category.name} to ${names}`, { links: flowLinks }));
|
|
123
|
+
out.push(add('chord', `chords between ${category.name} and ${names}`, { links: flowLinks }));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (rows.length >= 3) {
|
|
127
|
+
const distData = numbers.map(n => ({ label: n.name, values: rows.map(r => r[n.name]) }));
|
|
128
|
+
out.push(add('boxPlot', `distribution of ${category.name} values for each of ${names}`, distData));
|
|
129
|
+
out.push(add('density', `density curve of ${category.name} values for each of ${names}`, distData));
|
|
130
|
+
out.push(add('violin', `violin distribution of ${category.name} values for each of ${names}`, distData));
|
|
131
|
+
}
|
|
132
|
+
out.push(add('scatter', `${numbers[0].name} against ${numbers[1].name} for each ${category.name}`,
|
|
133
|
+
rows.map(r => ({ x: r[numbers[0].name], y: r[numbers[1].name], label: String(r[category.name]) }))));
|
|
134
|
+
if (numbers.length >= 3 || (rowTotals && rowTotals.length === rows.length)) {
|
|
135
|
+
out.push(add('bubble', `${numbers[0].name} and ${numbers[1].name} sized by ${numbers[2]?.name ?? (primaryMeasure?.name || 'Total')}`,
|
|
136
|
+
rows.map((r, idx) => ({
|
|
137
|
+
x: r[numbers[0].name],
|
|
138
|
+
y: r[numbers[1].name],
|
|
139
|
+
z: Math.max(1, numbers[2] ? r[numbers[2].name] : rowTotals[idx].value),
|
|
140
|
+
label: String(r[category.name]),
|
|
141
|
+
}))));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (allNonNeg) {
|
|
145
|
+
out.push(add('bar3DOrbit', `${names} for each ${category.name}, in 3D`,
|
|
146
|
+
rows.flatMap(r => numbers.map(n => ({ x: String(r[category.name]), z: n.name, value: r[n.name] })))));
|
|
147
|
+
}
|
|
148
|
+
if (additiveNonNeg) {
|
|
149
|
+
out.push(add('stackedBar3DOrbit', `${names} stacked for each ${category.name}, in 3D`,
|
|
150
|
+
rows.map(r => ({ x: String(r[category.name]), z: category.name, values: numbers.map(n => r[n.name]) })),
|
|
151
|
+
{ seriesLabels: numbers.map(n => n.name) }));
|
|
152
|
+
}
|
|
153
|
+
if (rowTotals && rowTotals.length >= 2 && primaryMeasure) {
|
|
154
|
+
this._addSingleSeriesChoices(out, add, category, primaryMeasure, rowTotals, complete, hierarchy, true);
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
_addSingleSeriesChoices(out, add, category, number, points, complete, hierarchy, includeBase = false) {
|
|
160
|
+
const nonNeg = points.every(d => d.value >= 0);
|
|
161
|
+
if (includeBase) {
|
|
162
|
+
out.push(add('bar', `total ${number.name} for each ${category.name}`, points));
|
|
163
|
+
if (nonNeg && complete && number.measure === 'additive' && points.length <= 8) {
|
|
164
|
+
out.push(add('pie', `share of total ${number.name} across ${category.name}`, points));
|
|
165
|
+
out.push(add('donut', `share of total ${number.name} across ${category.name}, as a ring`, points));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (hierarchy) {
|
|
169
|
+
out.push(add('treemap', `${number.name} across the ${category.name} hierarchy`, hierarchy.tree));
|
|
170
|
+
out.push(add('sunburst', `${number.name} across the ${category.name} hierarchy, in rings`, hierarchy.tree));
|
|
171
|
+
out.push(add('icicle', `${number.name} across the ${category.name} hierarchy, as an icicle partition`, hierarchy.tree));
|
|
172
|
+
out.push(add('outline', `${number.name} across the ${category.name} hierarchy, as an outline`, hierarchy.tree));
|
|
173
|
+
}
|
|
174
|
+
if (number.measure === 'additive') {
|
|
175
|
+
out.push(add('waterfall', `how each ${category.name} adds to ${number.name}`, points));
|
|
176
|
+
if (nonNeg && complete) {
|
|
177
|
+
out.push(add('pareto', `cumulative share of ${number.name} across ${category.name}`, points));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (!includeBase) {
|
|
181
|
+
out.push(add('lollipop', `${number.name} for each ${category.name}, as stems and dots`, points));
|
|
182
|
+
if (nonNeg && points.length >= 2) {
|
|
183
|
+
out.push(add('pipe', `${number.name} tapering across ${category.name}`,
|
|
184
|
+
points.map(d => ({ label: d.label, value: d.value, metric: d.value }))));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (nonNeg && points.length <= 12) {
|
|
188
|
+
out.push(add('funnel', `${number.name} across ${category.name} stages`, points));
|
|
189
|
+
out.push(add('radialBar', `${number.name} for each ${category.name}, around a circle`, points));
|
|
190
|
+
if (!includeBase && points.length >= 3) {
|
|
191
|
+
out.push(add('radar', `${number.name} across ${category.name} axes`, points.map(d => ({ axis: d.label, value: d.value }))));
|
|
192
|
+
}
|
|
193
|
+
out.push(add('wordCloud', `${category.name} sized by ${number.name}`, points.map(d => ({ text: d.label, value: d.value }))));
|
|
194
|
+
out.push(add('bar3D', `${number.name} for each ${category.name}, in 3D`, points));
|
|
195
|
+
}
|
|
196
|
+
if (points.length >= 3) {
|
|
197
|
+
const sample = points.map(d => d.value);
|
|
198
|
+
out.push(add('histogram', `frequency distribution of ${number.name} across ${category.name}`, sample));
|
|
199
|
+
out.push(add('qq', `normal Q-Q plot of ${number.name} across ${category.name}`, sample));
|
|
200
|
+
out.push(add('lag', `lag-1 autocorrelation of ${number.name} across ${category.name}`, sample));
|
|
201
|
+
}
|
|
28
202
|
}
|
|
29
203
|
|
|
30
204
|
/** The chart to draw: the one asked for while it still fits, else the best; null when none fits. */
|
|
@@ -33,18 +207,36 @@ export class PivotChart {
|
|
|
33
207
|
return choices.find(c => c.key === this.type) ?? choices[0] ?? null;
|
|
34
208
|
}
|
|
35
209
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
210
|
+
/** Sets the preferred chart type key (e.g. `'stackedBar'`, `'line'`, `'bar'`, `'pareto'`) and redraws if attached. */
|
|
211
|
+
setType(type) {
|
|
212
|
+
this.type = type;
|
|
213
|
+
this.redraw?.();
|
|
214
|
+
return this.chosen();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The chart's menu: "Chart as" each chart that fits (plus quick top-level items), the one showing ticked. */
|
|
218
|
+
menu(redraw = () => this.redraw?.()) {
|
|
38
219
|
const current = this.chosen()?.key;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
220
|
+
const all = this.choices();
|
|
221
|
+
const toItem = (c) => ({
|
|
222
|
+
label: `${c.label}${c.key === current ? ' ✓' : ''}`,
|
|
223
|
+
action: () => { this.type = c.key; redraw(); },
|
|
224
|
+
});
|
|
225
|
+
const quickKeys = new Set([
|
|
226
|
+
'groupedBar', 'stackedBar', 'bar', 'line', 'area', 'stackedArea',
|
|
227
|
+
'pareto', 'waterfall', 'marimekko', 'lollipop', 'heatmap', 'radar',
|
|
228
|
+
'treemap', 'sunburst', 'icicle', 'sankey', 'pie', 'donut',
|
|
229
|
+
]);
|
|
230
|
+
const quickItems = all.filter(c => quickKeys.has(c.key)).map(toItem);
|
|
231
|
+
return [
|
|
232
|
+
{
|
|
233
|
+
label: 'Chart as',
|
|
234
|
+
items: all.map(toItem),
|
|
235
|
+
},
|
|
236
|
+
...(quickItems.length > 0 ? [{ separator: true }, ...quickItems] : []),
|
|
237
|
+
];
|
|
46
238
|
}
|
|
47
239
|
|
|
48
|
-
/** A click on a charted value: drill the grid at its member. */
|
|
49
|
-
click(datum) { return this.view.drillFrom(datum.label); }
|
|
240
|
+
/** A click on a charted value: drill the grid at its member (or its series' column member). */
|
|
241
|
+
click(datum) { return this.view.drillFrom(datum.label, datum.series ?? null); }
|
|
50
242
|
}
|
|
@@ -110,7 +110,7 @@ export class PivotGridChart extends TableViewChart {
|
|
|
110
110
|
const lit = view.cellFill(node, c, cuts);
|
|
111
111
|
if (lit) this.renderer.rect(x0 + i * colW + 1, top + 1, colW - 2, ROW_H - 2, lit, null);
|
|
112
112
|
const row = view.cell(node, c.node);
|
|
113
|
-
const text = row === null ? '' :
|
|
113
|
+
const text = view.cellText ? view.cellText(node, c, nullText) : (row === null ? '' : view.valueText(c.measure, row[c.measure], nullText));
|
|
114
114
|
const shown = this._cell(text, end, x0 + i * colW, top, colW, { fill: row?.[c.measure] == null ? '#999' : '#222', ...weight });
|
|
115
115
|
// A cell with a value opens its menu (drill-through) where the host gives one.
|
|
116
116
|
if (row !== null && view.cellHasMenu()) {
|