@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.
Files changed (67) hide show
  1. package/README.md +454 -296
  2. package/dist/renderengine-workspace.umd.js +4122 -1167
  3. package/dist/renderengine.umd.js +4103 -1164
  4. package/documentation/REFERENCE.md +96 -6
  5. package/package.json +8 -3
  6. package/src/chartCatalog.js +50 -3
  7. package/src/charting/Axis.js +47 -5
  8. package/src/charting/Chart3D.js +4 -2
  9. package/src/charting/DiagramEditor.js +239 -34
  10. package/src/charting/Hierarchy.js +28 -0
  11. package/src/charting/ModelFileFormat.js +47 -0
  12. package/src/charting/archimate/ArchimateCanvasEditor.js +1108 -1012
  13. package/src/charting/archimate/deleteFromModel.js +57 -0
  14. package/src/charting/archimate/elementMenuItems.js +3 -1
  15. package/src/charting/archimate/heatMaps.js +169 -0
  16. package/src/charting/archimate/index.js +1 -0
  17. package/src/charting/ask/AskBar.js +388 -0
  18. package/src/charting/ask/AskView.js +400 -0
  19. package/src/charting/charts/AreaChart.js +20 -3
  20. package/src/charting/charts/BarChart.js +5 -5
  21. package/src/charting/charts/BarChart3D.js +2 -2
  22. package/src/charting/charts/BarChart3DOrbit.js +3 -1
  23. package/src/charting/charts/BarChartBase.js +21 -3
  24. package/src/charting/charts/BoxPlotChart.js +2 -2
  25. package/src/charting/charts/BubbleChart.js +4 -1
  26. package/src/charting/charts/ChordDiagram.js +4 -4
  27. package/src/charting/charts/DensityChart.js +1 -1
  28. package/src/charting/charts/FunnelChart.js +1 -1
  29. package/src/charting/charts/GroupedBarChart.js +7 -9
  30. package/src/charting/charts/HeatmapChart.js +4 -1
  31. package/src/charting/charts/HistogramChart.js +2 -2
  32. package/src/charting/charts/IcicleChart.js +47 -0
  33. package/src/charting/charts/LollipopChart.js +104 -0
  34. package/src/charting/charts/MarimekkoChart.js +75 -0
  35. package/src/charting/charts/OutlineChart.js +10 -8
  36. package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
  37. package/src/charting/charts/ParetoChart.js +92 -0
  38. package/src/charting/charts/PipeChart.js +5 -2
  39. package/src/charting/charts/PopulationPyramidChart.js +4 -4
  40. package/src/charting/charts/RadarChart.js +5 -4
  41. package/src/charting/charts/RadialBarChart.js +2 -2
  42. package/src/charting/charts/SankeyDiagram.js +4 -4
  43. package/src/charting/charts/ScatterChart.js +6 -3
  44. package/src/charting/charts/StackedBarChart.js +82 -11
  45. package/src/charting/charts/StackedBarChart3DOrbit.js +5 -2
  46. package/src/charting/charts/StreamGraphChart.js +8 -0
  47. package/src/charting/charts/SunburstChart.js +2 -2
  48. package/src/charting/charts/TableChart.js +18 -2
  49. package/src/charting/charts/TreemapChart.js +4 -4
  50. package/src/charting/charts/ViolinChart.js +2 -2
  51. package/src/charting/charts/WaterfallChart.js +2 -2
  52. package/src/charting/charts/WordCloudChart.js +2 -2
  53. package/src/charting/diagrams/ArchimateModel.js +33 -0
  54. package/src/charting/diagrams/DeleteFromModelCommand.js +81 -0
  55. package/src/charting/diagrams/EdgeGeometry.js +16 -0
  56. package/src/charting/edgeStyle/EdgeStyleSchema.js +6 -2
  57. package/src/charting/pivot/PivotChart.js +205 -13
  58. package/src/charting/pivot/PivotGridChart.js +1 -1
  59. package/src/charting/pivot/PivotView.js +555 -23
  60. package/src/charting/table/TableView.js +10 -1
  61. package/src/charting/table/TableViewChart.js +1 -1
  62. package/src/charting/templates/Template.js +10 -0
  63. package/src/exportImage.js +45 -18
  64. package/src/index.js +115 -6
  65. package/src/workspace/parts/ArchimateCanvasPart.js +3 -0
  66. package/src/workspace/parts/DiagramEditorPart.js +2 -0
  67. package/src/workspace/parts/ModelHosted.js +15 -4
@@ -30,7 +30,7 @@ const SHAPE_FIELD_KEYS = Object.freeze(['name', 'x', 'y', 'zIndex', 'fillColor',
30
30
  const WARNING_COLOR = '#e74c3c';
31
31
  const WARNING_DASH = '4 3';
32
32
  import { EMPHASIS_OPACITY, strongerEmphasis } from './emphasis/EmphasisRule.js';
33
- import { downloadPng, downloadSvg, copyImage, prepareSvgMarkup } from '../exportImage.js';
33
+ import { downloadPng, downloadSvg, copyImage, prepareSvgMarkup, svgMarkupToPngBlob, copyPngBlob, downloadSvgMarkup, downloadBlob, imageFileName } from '../exportImage.js';
34
34
  import { el } from '../workspace/dom.js';
35
35
  import { applyViewDecorations } from './viewDecorations.js';
36
36
  import { ToolbarButton, ToolbarMenu, ToolbarText } from './toolbar/index.js';
@@ -59,6 +59,15 @@ import {
59
59
  * toolbar container) to itself.
60
60
  */
61
61
  export class DiagramEditor {
62
+ /** An exported picture's defaults: the whole drawing, at 100%. */
63
+ static EXPORT_DEFAULTS = Object.freeze({ area: 'drawing', scale: 1 });
64
+
65
+ /** Space kept clear round an exported drawing, so nothing touches the picture's edge. */
66
+ static EXPORT_MARGIN = 20;
67
+
68
+ /** How far an arrowhead (or other line-end marker) reaches past its line's end. */
69
+ static MARKER_REACH = 10;
70
+
62
71
  /** How a selected shape (besides the one with handles) is marked: an outline `pad` outside it, tinted. */
63
72
  static SELECTION_OUTLINE = Object.freeze({ pad: 4, stroke: '#0066cc', strokeWidth: 2, dash: '5,3', fill: 'rgba(0, 102, 204, 0.08)' });
64
73
 
@@ -122,6 +131,10 @@ export class DiagramEditor {
122
131
  /** Off unless asked for (the `showGrid` option, or ⚙ Settings → Grid), as the settings' default says. */
123
132
  this.showGrid = options.showGrid ?? false;
124
133
  this.gridSize = 20;
134
+ /** A faint box where an exported picture's edges fall (⚙ Settings → Export area); never exported itself. */
135
+ this.showExportArea = options.showExportArea ?? false;
136
+ /** What an exported picture takes in, and at what size (Export image… remembers the last choice). */
137
+ this.exportOptions = { ...DiagramEditor.EXPORT_DEFAULTS };
125
138
 
126
139
  // Zoom and pan
127
140
  this.zoomLevel = 1.0;
@@ -429,6 +442,17 @@ export class DiagramEditor {
429
442
  return [];
430
443
  }
431
444
 
445
+ /**
446
+ * The whole menu a right-click on empty canvas at `at` opens: paste here,
447
+ * canvasMenuItems, then Export image (imageMenuItems), each set apart.
448
+ */
449
+ canvasContextMenu(at) {
450
+ const paste = this.canClipboardPaste() ? [{ label: '📋 Paste here', action: () => this.paste({ at }) }] : [];
451
+ const image = this.imageMenuItems();
452
+ const sections = [paste, this.canvasMenuItems(at), image.length ? [{ label: 'Export image', items: image }] : []].filter(s => s.length);
453
+ return sections.flatMap((s, i) => (i ? [{ separator: true }, ...s] : s));
454
+ }
455
+
432
456
  /**
433
457
  * The menu for empty canvas at `at` (drawing coordinates); none here, a
434
458
  * subclass offers its own (place a template, ...).
@@ -670,6 +694,7 @@ export class DiagramEditor {
670
694
 
671
695
  // Render edges
672
696
  this._renderEdges(group);
697
+ if (this.showExportArea && container.tagName === 'svg') this._renderExportArea(group);
673
698
 
674
699
  // The selection's groups and other selected shapes, then the primary shape's handles.
675
700
  this._renderArrangement(group);
@@ -768,9 +793,13 @@ export class DiagramEditor {
768
793
  ];
769
794
  }
770
795
 
771
- /** Export the current view as a picture: PNG, SVG, or a copy to the clipboard. */
796
+ /**
797
+ * Export the current view as a picture: PNG, SVG, or a copy to the clipboard,
798
+ * as Export image… last chose (the whole drawing at 100% to begin with).
799
+ */
772
800
  imageMenuItems() {
773
801
  return [
802
+ { label: 'Export image…', action: () => this.askExportImage() },
774
803
  { label: 'Export image (PNG)', action: () => this.exportImage('png') },
775
804
  { label: 'Export image (SVG)', action: () => this.exportImage('svg') },
776
805
  { label: 'Copy image', action: () => this.exportImage('copy') },
@@ -800,17 +829,113 @@ export class DiagramEditor {
800
829
  * The current view as a picture: 'png' or 'svg' downloads it, 'copy' puts a
801
830
  * PNG on the clipboard (or downloads it where the browser cannot). The grid,
802
831
  * the selection and its handles are left out.
832
+ *
833
+ * `area` 'drawing' (the default) takes in everything drawn, wherever the view
834
+ * is scrolled to: every shape and line, labels and arrowheads included, with
835
+ * a margin clear of the edge (drawingBounds). 'view' is what is in sight.
836
+ * `scale` sizes the picture: 1 is 100%.
803
837
  * @param {'png'|'svg'|'copy'} format
838
+ * @param {{area?: 'drawing'|'view', scale?: number}} [options] - Export image…'s last choice by default
804
839
  */
805
- exportImage(format = 'png') {
840
+ exportImage(format = 'png', { area = this.exportOptions.area, scale = this.exportOptions.scale } = {}) {
806
841
  const host = this.drawingHost();
807
842
  if (!host) return null;
808
843
  const name = this.imageName();
809
- return this._withPlainDrawing(() => {
810
- if (format === 'svg') return downloadSvg(host, { name });
811
- if (format === 'copy') return copyImage(host, { name });
812
- return downloadPng(host, { name });
844
+ if (area === 'view' || host.tagName?.toLowerCase() !== 'svg') {
845
+ return this._withPlainDrawing(() => {
846
+ if (format === 'svg') return downloadSvg(host, { name, scale });
847
+ if (format === 'copy') return copyImage(host, { name, scale });
848
+ return downloadPng(host, { name, scale });
849
+ });
850
+ }
851
+ const viewBox = this.drawingBounds();
852
+ if (!viewBox) return null;
853
+ const svg = this._withPlainDrawing(() => this._croppedSvg(host, viewBox, { scale }));
854
+ if (format === 'svg') return downloadSvgMarkup(svg, { name });
855
+ const png = svgMarkupToPngBlob(svg, { width: viewBox.width, height: viewBox.height, scale });
856
+ if (format === 'copy') return png.then(blob => copyPngBlob(blob, { name }));
857
+ return png.then(blob => downloadBlob(blob, imageFileName(name, 'png')));
858
+ }
859
+
860
+ /** Ask what to export: the format, the whole drawing or what is in sight, and the scale (%). */
861
+ askExportImage() {
862
+ const { area, scale } = this.exportOptions;
863
+ new FormDialog({
864
+ title: 'Export image',
865
+ description: 'The whole drawing takes in every shape and line, with a margin; the view, only what is in sight.',
866
+ fields: [
867
+ { key: 'format', label: 'Format', type: 'select', value: 'png', choices: [['png', 'PNG'], ['svg', 'SVG'], ['copy', 'Copy to the clipboard (PNG)']] },
868
+ { key: 'area', label: 'Take in', type: 'select', value: area, choices: [['drawing', 'The whole drawing'], ['view', 'What is in sight']] },
869
+ { key: 'scale', label: 'Scale (%)', type: 'number', value: Math.round(scale * 100), min: 10, max: 800, step: 10 },
870
+ ],
871
+ submitLabel: 'Export',
872
+ document: this.document ?? globalThis.document,
873
+ onApply: (values) => this.exportImage(values.format, this.setExportOptions(values)),
874
+ }).open();
875
+ }
876
+
877
+ /**
878
+ * Remember an export's area and scale (the scale in %), for the next one.
879
+ * A scale that is not a positive number keeps the last. Returns the options.
880
+ */
881
+ setExportOptions({ area, scale } = {}) {
882
+ if (area === 'drawing' || area === 'view') this.exportOptions.area = area;
883
+ const pct = Number(scale);
884
+ if (Number.isFinite(pct) && pct > 0) this.exportOptions.scale = pct / 100;
885
+ return { ...this.exportOptions };
886
+ }
887
+
888
+ /**
889
+ * Where an exported picture of the whole drawing ends (in drawing units):
890
+ * every shape and line shown, with line labels and arrowheads, and
891
+ * `margin` clear all round, so nothing touches the picture's edge. Null
892
+ * when nothing is drawn.
893
+ * @returns {{x, y, width, height}|null}
894
+ */
895
+ drawingBounds({ margin = DiagramEditor.EXPORT_MARGIN } = {}) {
896
+ const boxes = [...this.templates.values()]
897
+ .filter(t => t.visible !== false && this.templateEmphasis(t) !== 'hidden')
898
+ .map(t => ({ left: t.left, top: t.top, right: t.right, bottom: t.bottom }));
899
+ const cascade = this.edgeStyleCascade();
900
+ for (const edge of this.getAllEdges()) {
901
+ if (!edge.visible || this.edgeEmphasis(edge) === 'hidden') continue;
902
+ const { style } = cascade.resolve(edge);
903
+ const path = this._edgePath(edge, style);
904
+ if (!path) continue;
905
+ // Every point the path names (a curve's control points bound it); half the stroke and an arrowhead past them.
906
+ const numbers = path.d.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi)?.map(Number) ?? [];
907
+ const reach = (Number(style.strokeWidth) || 1) / 2 + DiagramEditor.MARKER_REACH;
908
+ for (let i = 0; i + 1 < numbers.length; i += 2) {
909
+ boxes.push({ left: numbers[i] - reach, top: numbers[i + 1] - reach, right: numbers[i] + reach, bottom: numbers[i + 1] + reach });
910
+ }
911
+ if (edge.label) {
912
+ // As _renderEdges writes it: centred over the middle, 12px text (about 7px a character).
913
+ const cx = (path.x1 + path.x2) / 2, base = (path.y1 + path.y2) / 2 - 8;
914
+ const half = String(edge.label).length * 7 / 2;
915
+ boxes.push({ left: cx - half, top: base - 12, right: cx + half, bottom: base + 4 });
916
+ }
917
+ }
918
+ const bounds = ViewFit.union(boxes);
919
+ if (!bounds) return null;
920
+ return { x: bounds.x - margin, y: bounds.y - margin, width: bounds.width + 2 * margin, height: bounds.height + 2 * margin };
921
+ }
922
+
923
+ /** The faint box where an exported picture of the whole drawing ends (drawingBounds). @protected */
924
+ _renderExportArea(group) {
925
+ const area = this.drawingBounds();
926
+ if (!area) return;
927
+ const rect = this._svg('rect', {
928
+ id: 'export-area', x: area.x, y: area.y, width: area.width, height: area.height,
929
+ fill: 'rgba(43, 95, 217, 0.03)', stroke: 'rgba(43, 95, 217, 0.25)', 'stroke-dasharray': '6 4', 'stroke-width': 1,
930
+ 'pointer-events': 'none',
813
931
  });
932
+ group.insertBefore(rect, group.firstChild);
933
+ }
934
+
935
+ /** Show or hide the export area. */
936
+ setShowExportArea(shown) {
937
+ this.showExportArea = !!shown;
938
+ this.render();
814
939
  }
815
940
 
816
941
  /**
@@ -819,8 +944,8 @@ export class DiagramEditor {
819
944
  * @protected
820
945
  */
821
946
  _withPlainDrawing(fn) {
822
- const saved = { showGrid: this.showGrid, selectedTemplateId: this.selectedTemplateId, selectedIds: new Set(this.selectedIds), selectedEdgeId: this.selectedEdgeId };
823
- Object.assign(this, { showGrid: false, selectedTemplateId: null, selectedIds: new Set(), selectedEdgeId: null });
947
+ const saved = { showGrid: this.showGrid, showExportArea: this.showExportArea, selectedTemplateId: this.selectedTemplateId, selectedIds: new Set(this.selectedIds), selectedEdgeId: this.selectedEdgeId };
948
+ Object.assign(this, { showGrid: false, showExportArea: false, selectedTemplateId: null, selectedIds: new Set(), selectedEdgeId: null });
824
949
  this.render();
825
950
  try {
826
951
  return fn();
@@ -843,13 +968,7 @@ export class DiagramEditor {
843
968
  if (host?.tagName?.toLowerCase() !== 'svg' || !bounds) return null;
844
969
  const viewBox = { x: bounds.x - margin, y: bounds.y - margin, width: bounds.width + 2 * margin, height: bounds.height + 2 * margin };
845
970
  return this._withPlainDrawing(() => {
846
- const copy = host.cloneNode(true);
847
- copy.querySelector('#grid-layer')?.remove();
848
- copy.querySelector('#elements')?.removeAttribute('transform');
849
- copy.removeAttribute('style');
850
- copy.removeAttribute('id');
851
- copy.setAttribute('viewBox', `${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`);
852
- const svg = prepareSvgMarkup(new (this.document?.defaultView ?? globalThis).XMLSerializer().serializeToString(copy), { width: viewBox.width, height: viewBox.height, background });
971
+ const svg = this._croppedSvg(host, viewBox, { background });
853
972
  const areas = [...this.templates.values()]
854
973
  .filter(t => t.visible !== false && this.templateEmphasis(t) !== 'hidden')
855
974
  .sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0))
@@ -865,6 +984,22 @@ export class DiagramEditor {
865
984
  }
866
985
 
867
986
  /** The drawing's extent in drawing coordinates (ViewFit.union of the visible shapes), or null. */
987
+ /**
988
+ * The drawing as standalone SVG markup cut to `viewBox` (drawing units),
989
+ * unpanned and unzoomed, sized at `scale`. Call it inside _withPlainDrawing.
990
+ * @protected
991
+ */
992
+ _croppedSvg(host, viewBox, { background = '#ffffff', scale = 1 } = {}) {
993
+ const copy = host.cloneNode(true);
994
+ copy.querySelector('#grid-layer')?.remove();
995
+ copy.querySelector('#elements')?.removeAttribute('transform');
996
+ copy.removeAttribute('style');
997
+ copy.removeAttribute('id');
998
+ copy.setAttribute('viewBox', `${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`);
999
+ const markup = new (this.document?.defaultView ?? globalThis).XMLSerializer().serializeToString(copy);
1000
+ return prepareSvgMarkup(markup, { width: viewBox.width * scale, height: viewBox.height * scale, background });
1001
+ }
1002
+
868
1003
  contentBounds() {
869
1004
  return ViewFit.union([...this.templates.values()].filter(t => t.visible !== false)
870
1005
  .map(t => ({ left: t.left, top: t.top, right: t.right, bottom: t.bottom })));
@@ -968,6 +1103,7 @@ export class DiagramEditor {
968
1103
  return [
969
1104
  { label: `Grid: ${this.showGrid ? 'on' : 'off'}`, action: () => { this.showGrid = !this.showGrid; this.render(); } },
970
1105
  { label: `Grid size: ${this.gridSize}px…`, action: () => this.editGridSize() },
1106
+ { label: `Export area: ${this.showExportArea ? 'shown' : 'hidden'}`, action: () => this.setShowExportArea(!this.showExportArea) },
971
1107
  ...(this.edgeStyleLevels.length ? [{ separator: true }] : []),
972
1108
  ...this.edgeStyleLevels.map(level => ({
973
1109
  label: `Line style: ${level.label} defaults…`,
@@ -1328,13 +1464,17 @@ export class DiagramEditor {
1328
1464
  /**
1329
1465
  * Geometry of an edge between its templates' outlines, or null if an end is
1330
1466
  * missing. `style.attach === 'sides'` attaches each unpinned end at the middle
1331
- * of the side facing the other end (or the nearest waypoint); 'polyline'
1467
+ * of the side facing the other end (or the nearest waypoint); 'shortest' (the
1468
+ * default, as Archi draws) runs a straight line straight across where the
1469
+ * shapes overlap, else aims at the other end ('floating'); 'polyline'
1332
1470
  * routing bends around the other shapes.
1333
1471
  */
1334
1472
  _edgePath(edge, style = this.resolveEdgeStyle(edge)) {
1335
1473
  const source = this.getTemplate(edge.sourceId);
1336
1474
  const target = this.getTemplate(edge.targetId);
1337
1475
  if (!source || !target) return null;
1476
+ const across = style.attach === 'shortest' ? this._acrossPath(edge, source, target, style) : null;
1477
+ if (across) return across;
1338
1478
  const box = (t) => ({ w: t.width, h: t.height });
1339
1479
  let sourceSide = edge.sourceSide || 'auto';
1340
1480
  let targetSide = edge.targetSide || 'auto';
@@ -1354,6 +1494,25 @@ export class DiagramEditor {
1354
1494
  );
1355
1495
  }
1356
1496
 
1497
+ /**
1498
+ * The shortest straight line between two shapes that face each other across
1499
+ * a gap (EdgeGeometry.acrossOverlap): straight across the middle of their
1500
+ * overlap, from outline to outline. Null where that does not apply: a line
1501
+ * with bends, a pinned end, other routing, a rotated shape, or no overlap.
1502
+ * @protected
1503
+ */
1504
+ _acrossPath(edge, source, target, style) {
1505
+ if (edge.waypoints.length || (edge.sourceSide || 'auto') !== 'auto' || (edge.targetSide || 'auto') !== 'auto') return null;
1506
+ if ((style.routing ?? 'straight') !== 'straight' || source.rotation || target.rotation) return null;
1507
+ const across = EdgeGeometry.acrossOverlap(source, target);
1508
+ if (!across) return null;
1509
+ const step = across.forward ? 1 : -1;
1510
+ const [a, b] = across.axis === 'v'
1511
+ ? [source.exitPoint(across.at, source.y, 0, step), target.exitPoint(across.at, target.y, 0, -step)]
1512
+ : [source.exitPoint(source.x, across.at, step, 0), target.exitPoint(target.x, across.at, -step, 0)];
1513
+ return { x1: a.x, y1: a.y, x2: b.x, y2: b.y, d: `M${a.x},${a.y} L${b.x},${b.y}` };
1514
+ }
1515
+
1357
1516
  /** Anchors and waypoints in order: the polyline a waypointed edge follows. */
1358
1517
  _edgeVertices(edge, edgePath = this._edgePath(edge)) {
1359
1518
  return [{ x: edgePath.x1, y: edgePath.y1 }, ...edge.waypoints, { x: edgePath.x2, y: edgePath.y2 }];
@@ -1848,12 +2007,9 @@ export class DiagramEditor {
1848
2007
  e.preventDefault();
1849
2008
  this._startMarquee(e, container);
1850
2009
  });
1851
- // Empty canvas: what canvasMenuItems offers there (a shape's own menu stops the event first).
2010
+ // Empty canvas: canvasContextMenu there (a shape's own menu stops the event first).
1852
2011
  container.addEventListener('contextmenu', (e) => {
1853
- const at = this._clientToDiagram(e.clientX, e.clientY);
1854
- const paste = this.canClipboardPaste() ? [{ label: '📋 Paste here', action: () => this.paste({ at }) }] : [];
1855
- const own = this.canvasMenuItems(at);
1856
- const items = [...paste, ...(paste.length && own.length ? [{ separator: true }] : []), ...own];
2012
+ const items = this.canvasContextMenu(this._clientToDiagram(e.clientX, e.clientY));
1857
2013
  if (!items.length) return;
1858
2014
  e.preventDefault();
1859
2015
  this._showMenu(e.clientX, e.clientY, items);
@@ -2308,11 +2464,27 @@ export class DiagramEditor {
2308
2464
  return [['kind', 'Kind of shape']];
2309
2465
  }
2310
2466
 
2467
+ /** What a colour key is called (colourKeys' label; a key not listed there, itself). */
2468
+ colourKeyLabel(key) {
2469
+ return this.colourKeys().find(([k]) => k === key)?.[1] ?? key;
2470
+ }
2471
+
2472
+ /** The colours offered for a key's values, in order: the palette's, round again when there are more. */
2473
+ colourDefaults(key, values) {
2474
+ const palette = this.constructor.COLOUR_PALETTE;
2475
+ return values.map((_, i) => palette[i % palette.length]);
2476
+ }
2477
+
2311
2478
  /** A shape's value for a colourKeys key, as text (undefined: it has none, and is left as it is). */
2312
2479
  colourValue(template, key) {
2313
2480
  return key === 'kind' ? this.layoutCategory(template) : undefined;
2314
2481
  }
2315
2482
 
2483
+ /** A line's value for a colourKeys key (undefined: lines are not coloured by it, as here; a subclass's may be). */
2484
+ colourEdgeValue(edge, key) {
2485
+ return undefined;
2486
+ }
2487
+
2316
2488
  /** The values of `key` among the shapes, in order of first appearance, each with its shapes. */
2317
2489
  colourValues(key, templates = [...this.templates.values()].filter(t => t.visible !== false)) {
2318
2490
  const values = new Map();
@@ -2328,19 +2500,26 @@ export class DiagramEditor {
2328
2500
 
2329
2501
  /**
2330
2502
  * Colour the shapes by what `key` says of them (a heat map): each value's
2331
- * shapes filled with its colour (`colours`, by value; a value left out or
2503
+ * shapes filled with its colour, and lines with a value of their own
2504
+ * (colourEdgeValue) drawn in it (`colours`, by value; a value left out or
2332
2505
  * empty is left as it is), and with `legend` a legend of the colours
2333
2506
  * under the drawing. One undo step; locked shapes are left as they are.
2334
2507
  * @returns {Command|null}
2335
2508
  */
2336
2509
  colourByCommand(key, colours, { legend = false } = {}) {
2337
- const label = this.colourKeys().find(([k]) => k === key)?.[1] ?? key;
2510
+ const label = this.colourKeyLabel(key);
2338
2511
  const steps = [];
2339
2512
  for (const [value, templates] of this.colourValues(key)) {
2340
2513
  const fillColor = colours[value];
2341
2514
  if (!fillColor) continue;
2342
2515
  for (const t of templates) if (!this.isFixed(t.id) && t.fillColor !== fillColor) steps.push(new SetPropertiesCommand(t, { fillColor }, 'Colour'));
2343
2516
  }
2517
+ // Lines with a value of their own take its colour too (colourEdgeValue).
2518
+ for (const edge of this.getAllEdges()) {
2519
+ if (edge.editable === false) continue;
2520
+ const strokeColor = colours[this.colourEdgeValue(edge, key)];
2521
+ if (strokeColor && edge.strokeColor !== strokeColor) steps.push(new SetStyleCommand(edge, { strokeColor }, 'Colour'));
2522
+ }
2344
2523
  if (legend) {
2345
2524
  const entries = [...this.colourValues(key).keys()].filter(v => colours[v]).map(v => ({ label: v, fillColor: colours[v] }));
2346
2525
  const cmd = entries.length && this.legendCommand(entries, label);
@@ -2366,13 +2545,13 @@ export class DiagramEditor {
2366
2545
  /** The colour of each value of `key` (the palette's by default), then colour by it. @protected */
2367
2546
  askColours(key) {
2368
2547
  const values = [...this.colourValues(key).keys()];
2369
- const palette = this.constructor.COLOUR_PALETTE;
2370
- const label = this.colourKeys().find(([k]) => k === key)?.[1] ?? key;
2548
+ const defaults = this.colourDefaults(key, values);
2549
+ const label = this.colourKeyLabel(key);
2371
2550
  if (!values.length) { (globalThis.alert ?? (() => {}))(`No shape here has a value for ${label}.`); return; }
2372
2551
  new FormDialog({
2373
2552
  title: `Colour by ${label}`,
2374
2553
  fields: [
2375
- ...values.map((value, i) => ({ key: `value-${i}`, label: value, type: 'color', value: palette[i % palette.length] })),
2554
+ ...values.map((value, i) => ({ key: `value-${i}`, label: value, type: 'color', value: defaults[i] })),
2376
2555
  { key: 'legend', label: 'Add a legend', type: 'checkbox', value: true },
2377
2556
  ],
2378
2557
  submitLabel: 'Colour',
@@ -2412,19 +2591,45 @@ export class DiagramEditor {
2412
2591
  }
2413
2592
 
2414
2593
  /**
2415
- * The command that adds a legend: a title and a box per entry, in a row
2416
- * under the drawing (legendPosition). Here, notes; a subclass makes its own
2417
- * kind of box. Null with no entries.
2594
+ * Where a legend's boxes go, its top-left at `{x, y}`: a frame titled at its
2595
+ * top-left that holds every entry, the entries in rows of up to
2596
+ * `perRow` inside it. Centres and sizes, as boxes are placed.
2597
+ * @returns {{frame: {x, y, width, height}, items: Array<{x, y, width, height}>}}
2598
+ */
2599
+ static legendLayout(count, { x, y }, { perRow = 4, itemWidth = 120, itemHeight = 40, gap = 10, pad = 10, titleHeight = 24 } = {}) {
2600
+ const columns = Math.max(1, Math.min(count, perRow));
2601
+ const rows = Math.max(1, Math.ceil(count / columns));
2602
+ const width = 2 * pad + columns * itemWidth + (columns - 1) * gap;
2603
+ const height = titleHeight + rows * itemHeight + (rows - 1) * gap + pad;
2604
+ const items = Array.from({ length: count }, (_, i) => ({
2605
+ x: x + pad + (i % columns) * (itemWidth + gap) + itemWidth / 2,
2606
+ y: y + titleHeight + Math.floor(i / columns) * (itemHeight + gap) + itemHeight / 2,
2607
+ width: itemWidth, height: itemHeight,
2608
+ }));
2609
+ return { frame: { x: x + width / 2, y: y + height / 2, width, height }, items };
2610
+ }
2611
+
2612
+ /** The legend frame's text: its title, bold at the top-left (as Archi's legends are). */
2613
+ static LEGEND_TITLE_STYLE = Object.freeze({ textAlign: 'left', textPosition: 'top', fontWeight: 'bold' });
2614
+
2615
+ /**
2616
+ * The command that adds a legend under the drawing (legendPosition): a
2617
+ * frame titled `title` with a box per entry inside it, grouped so they move
2618
+ * as one. Here, notes; a subclass makes its own kind of box. Null with no
2619
+ * entries.
2418
2620
  * @param {Array<{label: string, fillColor: string}>} entries
2419
2621
  * @param {string} title
2420
2622
  */
2421
2623
  legendCommand(entries, title) {
2422
2624
  if (!entries.length) return null;
2423
- const { x, y } = this.legendPosition();
2625
+ const { frame, items } = DiagramEditor.legendLayout(entries.length, this.legendPosition());
2424
2626
  const stamp = Date.now().toString(36);
2425
- const note = (i, name, fillColor, width = 120) => new NoteTemplate(`legend-${stamp}-${i}`, name, { x: x + width / 2 + i * 130, y: y + 25, width, height: 40, fillColor });
2426
- const boxes = [note(0, title, '#ffffff'), ...entries.map((e, i) => note(i + 1, e.label, e.fillColor))];
2427
- return new CompositeCommand(`Add legend: ${title}`, boxes.map(t => new CreateTemplateCommand(this, t)));
2627
+ const note = (i, name, box, more) => new NoteTemplate(`legend-${stamp}-${i}`, name, { ...box, ...more });
2628
+ const boxes = [note(0, title, frame, { fillColor: '#ffffff', ...DiagramEditor.LEGEND_TITLE_STYLE }), ...entries.map((e, i) => note(i + 1, e.label, items[i], { fillColor: e.fillColor }))];
2629
+ return new CompositeCommand(`Add legend: ${title}`, [
2630
+ ...boxes.map(t => new CreateTemplateCommand(this, t)),
2631
+ new GroupCommand(this.arrangement, boxes.map(t => t.id), { name: title }),
2632
+ ]);
2428
2633
  }
2429
2634
 
2430
2635
  // ===== Format painter =====
@@ -145,6 +145,34 @@ export class Hierarchy {
145
145
  return result;
146
146
  }
147
147
 
148
+ /**
149
+ * Recursive rectangular partition (icicle / flame graph) layout over a node tree.
150
+ * Partitions `width` proportionally to node value at each level and steps down by `bandHeight` per depth.
151
+ * @returns {Array<{node: object, x: number, y: number, width: number, height: number, depth: number, isLeaf: boolean, topAncestor: object}>}
152
+ */
153
+ static icicleLayout(nodes, x, y, width, bandHeight, depth = 0, topAncestors = null) {
154
+ const items = (nodes ?? [])
155
+ .map(node => ({ node, value: Hierarchy.value(node) }))
156
+ .filter(it => it.value > 0);
157
+ const total = items.reduce((s, it) => s + it.value, 0) || 1;
158
+
159
+ const result = [];
160
+ let cursorX = x;
161
+ for (const it of items) {
162
+ const { node } = it;
163
+ const spanW = (it.value / total) * width;
164
+ const isLeaf = !node.children?.length;
165
+ const topAncestor = topAncestors ? topAncestors.get(node) : node;
166
+ result.push({ node, x: cursorX, y, width: spanW, height: bandHeight, depth, isLeaf, topAncestor });
167
+ if (!isLeaf) {
168
+ const childTopAncestors = new Map(node.children.map(c => [c, topAncestor]));
169
+ result.push(...Hierarchy.icicleLayout(node.children, cursorX, y + bandHeight, spanW, bandHeight, depth + 1, childTopAncestors));
170
+ }
171
+ cursorX += spanW;
172
+ }
173
+ return result;
174
+ }
175
+
148
176
  /**
149
177
  * Flat, pre-order list of *visible* rows for an outline view. A branch
150
178
  * present in `collapsed` contributes its own row but none of its
@@ -1,5 +1,6 @@
1
1
  import { pickFile } from './toolbar/pickFile.js';
2
2
  import { downloadBlob } from '../exportImage.js';
3
+ import { FormDialog } from './FormDialog.js';
3
4
 
4
5
  /**
5
6
  * ModelFileFormat — one file format a model is exported to and imported
@@ -100,6 +101,52 @@ export function modelFileMenuItems(formats, {
100
101
  return groups.flatMap((group, i) => (i ? [{ separator: true }, ...group] : group));
101
102
  }
102
103
 
104
+ /**
105
+ * The part of pasted text that is a model in `format`: the text as it is when
106
+ * the format recognizes it; else, as an assistant or a page would wrap it, a
107
+ * fenced block (```json … ```), or the text from its first { (or [) to its
108
+ * last } (or ]). Null when none is.
109
+ * @param {ModelFileFormat} format - one that reads text
110
+ * @param {string} text
111
+ * @returns {string|null}
112
+ */
113
+ export function findPayload(format, text) {
114
+ const candidates = [String(text ?? '').trim()];
115
+ for (const m of candidates[0].matchAll(/```[\w-]*[ \t]*\r?\n([\s\S]*?)```/g)) candidates.push(m[1].trim());
116
+ for (const [open, close] of [['{', '}'], ['[', ']']]) {
117
+ const from = candidates[0].indexOf(open), to = candidates[0].lastIndexOf(close);
118
+ if (from >= 0 && to > from) candidates.push(candidates[0].slice(from, to + 1));
119
+ }
120
+ return candidates.find(c => c && format.recognizes(c)) ?? null;
121
+ }
122
+
123
+ /**
124
+ * Import a model from pasted text rather than a file: a dialog with a text
125
+ * box; what is pasted may be wrapped in other text (findPayload). The new
126
+ * model goes to `onImport`; text with no model in it is refused in the dialog.
127
+ * @param {ModelFileFormat} format - one that reads text
128
+ * @returns {FormDialog}
129
+ */
130
+ export function askPasteImport(format, { onImport, document: doc = globalThis.document } = {}) {
131
+ let model = null;
132
+ const dialog = new FormDialog({
133
+ title: `Paste ${format.label}`,
134
+ description: `Paste the ${format.label} text: a file's contents, or an answer or page that has it in it (in a \`\`\` block, say).`,
135
+ fields: [{ key: 'text', label: 'Text', type: 'textarea', rows: 14, value: '' }],
136
+ submitLabel: 'Import',
137
+ idPrefix: 'paste-import-',
138
+ document: doc,
139
+ validate: ({ text }) => {
140
+ const payload = findPayload(format, text);
141
+ if (!payload) return `No ${format.label} found in what is pasted`;
142
+ try { model = format.import(payload); return null; } catch (err) { return `It could not be read: ${err.message}`; }
143
+ },
144
+ onApply: () => onImport(model),
145
+ });
146
+ dialog.open();
147
+ return dialog;
148
+ }
149
+
103
150
  /**
104
151
  * Ask for a file and import it in `format`; resolves to the new model, or
105
152
  * null when no file was chosen or it could not be read (reported).