@peter.prib/renderengine 0.4.0 → 0.7.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 (78) hide show
  1. package/README.md +4 -0
  2. package/REFERENCE.md +267 -17
  3. package/dist/renderengine-workspace.umd.js +5289 -2895
  4. package/dist/renderengine.umd.js +4243 -2120
  5. package/examples/archimate.html +8 -28
  6. package/examples/data-model.html +45 -556
  7. package/package.json +70 -70
  8. package/src/archimateArchiFormat.js +11 -3
  9. package/src/archimateExchangeFormat.js +17 -9
  10. package/src/charting/ArchimateDiagramEditor.js +1 -1
  11. package/src/charting/Axis.js +20 -0
  12. package/src/charting/DiagramEditor.js +35 -3
  13. package/src/charting/Dialog.js +195 -0
  14. package/src/charting/DuplicatesDialog.js +108 -0
  15. package/src/charting/FormDialog.js +28 -97
  16. package/src/charting/InteractionManager.js +3 -0
  17. package/src/charting/Legend.js +45 -2
  18. package/src/charting/MergeCompareDialog.js +81 -0
  19. package/src/charting/PopupMenu.js +12 -3
  20. package/src/charting/SettingsDialog.js +6 -2
  21. package/src/charting/SettingsManager.js +14 -1
  22. package/src/charting/archimate/ArchimateCanvasEditor.js +190 -15
  23. package/src/charting/archimate/ArchimatePalette.js +1 -1
  24. package/src/charting/charts/AreaChart.js +5 -2
  25. package/src/charting/charts/BarChart.js +5 -3
  26. package/src/charting/charts/BarChart3DOrbit.js +5 -7
  27. package/src/charting/charts/BubbleChart.js +5 -7
  28. package/src/charting/charts/BubbleChart3D.js +5 -7
  29. package/src/charting/charts/CandlestickChart.js +3 -1
  30. package/src/charting/charts/DensityChart.js +4 -6
  31. package/src/charting/charts/GroupedBarChart.js +2 -2
  32. package/src/charting/charts/LineChart.js +5 -2
  33. package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
  34. package/src/charting/charts/PipeChart.js +4 -6
  35. package/src/charting/charts/RadarChart.js +5 -6
  36. package/src/charting/charts/ScatterChart.js +5 -7
  37. package/src/charting/charts/StackedBarChart.js +4 -2
  38. package/src/charting/charts/StackedBarChart3DOrbit.js +5 -7
  39. package/src/charting/charts/StreamGraphChart.js +5 -2
  40. package/src/charting/charts/WaterfallChart.js +2 -1
  41. package/src/charting/content/ContentSource.js +129 -0
  42. package/src/charting/content/ContentSources.js +77 -0
  43. package/src/charting/datamodel/DataModel.js +116 -0
  44. package/src/charting/diagrams/ArchimateModel.js +38 -3
  45. package/src/charting/diagrams/ArchimateModelChecker.js +13 -4
  46. package/src/charting/diagrams/MergeElementsCommand.js +124 -101
  47. package/src/charting/diagrams/SvgZoom.js +20 -1
  48. package/src/charting/diagrams/duplicates.js +95 -13
  49. package/src/charting/duplicateSearchForm.js +58 -0
  50. package/src/charting/modelTree/ModelTree.js +239 -202
  51. package/src/charting/modelTree/ModelTreePanel.js +955 -919
  52. package/src/charting/modelTree/TreeBuilder.js +93 -45
  53. package/src/charting/modelTree/TreeViewMode.js +9 -6
  54. package/src/charting/modelTree/viewFolders.js +15 -6
  55. package/src/charting/requirements/AzureDevOpsApi.js +55 -0
  56. package/src/charting/requirements/GitLabApi.js +43 -0
  57. package/src/charting/requirements/JiraApi.js +69 -0
  58. package/src/charting/requirements/TrackerApi.js +142 -0
  59. package/src/charting/requirements/TrackerExport.js +3 -2
  60. package/src/charting/requirements/index.js +5 -0
  61. package/src/charting/requirements/trackerApis.js +21 -0
  62. package/src/icons/IconSet.js +86 -0
  63. package/src/icons/index.js +59 -0
  64. package/src/index.js +67 -10
  65. package/src/workspace/dom.js +56 -42
  66. package/src/workspace/index.js +1 -1
  67. package/src/workspace/nav/NavEntry.js +42 -0
  68. package/src/workspace/nav/NavGroup.js +43 -0
  69. package/src/workspace/nav/NavItem.js +53 -0
  70. package/src/workspace/nav/NavMenu.js +143 -0
  71. package/src/workspace/nav/NavSection.js +17 -0
  72. package/src/workspace/nav/index.js +5 -0
  73. package/src/workspace/parts/ArchimateCanvasPart.js +8 -3
  74. package/src/workspace/parts/DataModelPart.js +91 -0
  75. package/src/workspace/parts/EditorPart.js +8 -0
  76. package/src/workspace/parts/ModelHosted.js +1 -0
  77. package/src/workspace/parts/ModelsPart.js +765 -622
  78. package/src/workspace/styles.js +28 -1
@@ -33,6 +33,9 @@ import { ElementFilterRule } from './ElementFilterRule.js';
33
33
  import { ArchimateViewpoints, ArchimateNotation } from '../../archimate.js';
34
34
  import { PropertyListEditor } from '../PropertyListEditor.js';
35
35
  import { FormDialog } from '../FormDialog.js';
36
+ import { duplicateSearchFields, duplicateSearchOptions, validateDuplicateSearch } from '../duplicateSearchForm.js';
37
+ import { DuplicatesDialog } from '../DuplicatesDialog.js';
38
+ import { MergeCompareDialog } from '../MergeCompareDialog.js';
36
39
  import { elementPropertyFields, elementEditCommand, conceptEditCommand, manyElementsFields, manyElementsEditCommands } from './elementProperties.js';
37
40
 
38
41
  /**
@@ -130,7 +133,10 @@ export class ArchimateCanvasEditor extends DiagramEditor {
130
133
  const items = this.templateMenu?.saveAsTemplateItems(this.view.id, { itemIds: [...this.arrangement.movers(ids)] }) ?? [];
131
134
  const element = this._elementOf(elementId);
132
135
  const missing = element ? this.missingRelationshipsOf(elementId) : [];
136
+ // Two or more elements chosen: merge them into one (choose the one kept).
137
+ const merging = this.elementIdsOf(ids);
133
138
  return [
139
+ ...(merging.length > 1 ? [{ label: `Merge ${merging.length} elements…`, action: () => this.askMerge(merging) }] : []),
134
140
  ...(element ? [{ label: 'Generate View For This', action: () => this.model.generateView([element.id]) }] : []),
135
141
  ...(missing.length ? [{ label: `Add Missing Relationships (${missing.length})`, action: () => this.addMissingRelationships(elementId) }] : []),
136
142
  ...hintMenuItems(this.view, elementId, { onChange: () => this.render() }),
@@ -168,7 +174,94 @@ export class ArchimateCanvasEditor extends DiagramEditor {
168
174
 
169
175
  /** The View menu, and Generate roadmap… (elements with dates on a time line). */
170
176
  viewMenuItems() {
171
- return [...super.viewMenuItems(), { label: 'Generate roadmap…', action: () => this.askRoadmap() }];
177
+ const repeated = this.view?.repeatedBoxes?.() ?? [];
178
+ return [
179
+ ...super.viewMenuItems(),
180
+ { label: `Remove repeated boxes${repeated.length ? ` (${repeated.length})` : ''}`, disabled: !repeated.length || undefined, action: () => this.removeRepeatedBoxes() },
181
+ { label: 'Generate roadmap…', action: () => this.askRoadmap() },
182
+ ];
183
+ }
184
+
185
+ /** The model's own: Find likely duplicates…. */
186
+ modelMenuItems() {
187
+ return [...super.modelMenuItems(), { label: 'Find likely duplicates…', action: () => this.askDuplicates() }];
188
+ }
189
+
190
+ /**
191
+ * Take off this view the boxes that only repeat another box of their
192
+ * element (ArchimateView#repeatedBoxes: same label, same lines), with
193
+ * their lines, as one undo step. Returns the command, or null with none.
194
+ */
195
+ removeRepeatedBoxes() {
196
+ const ids = this.view.repeatedBoxes();
197
+ if (!ids.length) return null;
198
+ const cmd = this.deleteTemplatesCommand(ids);
199
+ cmd.label = `Remove ${ids.length} repeated box${ids.length === 1 ? '' : 'es'}`;
200
+ this._executeCommand(cmd);
201
+ return cmd;
202
+ }
203
+
204
+ /**
205
+ * Where Find likely duplicates… can look: the whole model, the elements on
206
+ * the view in front, the elements selected (two or more). [key, label, element ids or null].
207
+ */
208
+ duplicateScopes() {
209
+ const onView = this.elementIdsOf([...(this.view?.elementInstances?.keys() ?? [])]);
210
+ const selected = this.elementIdsOf(this.getSelectedIds());
211
+ return [
212
+ ['model', 'The whole model', null],
213
+ ...(onView.length > 1 ? [['view', `This view (${this.view.name || this.view.id})`, onView]] : []),
214
+ ...(selected.length > 1 ? [['selection', `The ${selected.length} elements selected`, selected]] : []),
215
+ ];
216
+ }
217
+
218
+ /**
219
+ * Find likely duplicates…: where to look (duplicateScopes), how to compare
220
+ * (names, documentation or a property's value; exactly or by their words)
221
+ * and what noise to leave out (duplicateSearchFields), then the groups
222
+ * found, each with Merge… (showDuplicates).
223
+ */
224
+ askDuplicates() {
225
+ const names = [...new Set(this.model.elements().flatMap(e => (e.properties ?? []).map(p => p.name ?? p.key)).filter(Boolean))].sort();
226
+ const specializations = [...new Set(this.model.elements().map(e => e.specialization).filter(Boolean))].sort();
227
+ const scopes = this.duplicateScopes();
228
+ return new FormDialog({
229
+ title: 'Find likely duplicates',
230
+ description: 'Elements are compared by their words: case, punctuation, word order and the noise words do not count.',
231
+ fields: duplicateSearchFields({ scope: { label: 'Look in', choices: scopes.map(([key, label]) => [key, label]) }, properties: names, specializations }),
232
+ submitLabel: 'Find',
233
+ idPrefix: 'duplicates-',
234
+ document: this.document ?? globalThis.document,
235
+ validate: validateDuplicateSearch,
236
+ onApply: (values) => {
237
+ const { options, scope } = duplicateSearchOptions(values);
238
+ this.showDuplicates(options, { within: scopes.find(([key]) => key === scope)?.[2] ?? null });
239
+ },
240
+ }).open();
241
+ }
242
+
243
+ /**
244
+ * The elements that look like one thing twice (the model check's likely
245
+ * duplicates, compared as `options` say: DUPLICATE_OPTIONS; `within`: only
246
+ * among these element ids), each group with Merge….
247
+ */
248
+ showDuplicates(options = {}, { within = null } = {}) {
249
+ const find = () => this.model.checker(this.checkerOptions()).possibleDuplicates(options, { within });
250
+ const groups = find();
251
+ new DuplicatesDialog({
252
+ groups,
253
+ refind: find,
254
+ merge: async (group) => !!(await this.askMerge(group.ids)),
255
+ onReveal: (group) => this.revealFinding(group),
256
+ empty: 'No elements look like the same thing twice.',
257
+ document: this.document ?? globalThis.document,
258
+ }).open();
259
+ return groups;
260
+ }
261
+
262
+ /** The model elements of some boxes, each once (a box that is not an element is left out). */
263
+ elementIdsOf(instanceIds) {
264
+ return [...new Set(instanceIds.map(id => this._elementOf(id)?.id).filter(Boolean))];
172
265
  }
173
266
 
174
267
  /**
@@ -302,11 +395,73 @@ export class ArchimateCanvasEditor extends DiagramEditor {
302
395
  }
303
396
 
304
397
  /**
305
- * Ask which of some elements to keep, then merge the others into it
306
- * (ArchimateModel#mergeCommand), one undo step. Each choice says where the
307
- * element is used, to help pick.
398
+ * What differs among some elements, for the merge to choose: their
399
+ * documentation (when two or more have some, and it differs), each property
400
+ * with different values, and the specialization.
401
+ * @returns {{documentation: Array<[string, string]>, properties: Map<string, Array<[string, string]>>, specializations: string[]}}
402
+ * each choice [value, label]
403
+ */
404
+ mergeChoices(elements) {
405
+ const name = (e) => e.name || e.id;
406
+ const documented = elements.filter(e => (e.documentation ?? '').trim());
407
+ const texts = [...new Set(documented.map(e => e.documentation.trim()))];
408
+ const properties = new Map();
409
+ for (const e of elements) {
410
+ for (const p of e.properties ?? []) {
411
+ const key = p.name ?? p.key;
412
+ if (!properties.has(key)) properties.set(key, new Map());
413
+ const values = properties.get(key);
414
+ values.set(String(p.value ?? ''), [...(values.get(String(p.value ?? '')) ?? []), name(e)]);
415
+ }
416
+ }
417
+ const specializations = [...new Set(elements.map(e => e.specialization).filter(Boolean))];
418
+ return {
419
+ documentation: texts.length > 1 ? texts.map(t => [t, `${documented.filter(e => e.documentation.trim() === t).map(name).join(', ')}: ${t.length > 50 ? `${t.slice(0, 50)}…` : t}`]) : [],
420
+ properties: new Map([...properties].filter(([, values]) => values.size > 1)
421
+ .map(([key, values]) => [key, [...values].map(([value, from]) => [value, `${value || '(empty)'} (${from.join(', ')})`])])),
422
+ specializations: specializations.length > 1 ? specializations : [],
423
+ };
424
+ }
425
+
426
+ /**
427
+ * Some elements side by side, for a merge (MergeCompareDialog's rows): their
428
+ * names, types, specializations, documentation, each property, their
429
+ * relationships (kind, direction, the element at the other end) and the
430
+ * views they are on.
308
431
  */
309
- askMerge(elementIds) {
432
+ mergeComparison(elements) {
433
+ const nameOf = (id) => this.model.getElement(id)?.name || id;
434
+ const valueOf = (e, key) => (e.properties ?? []).filter(p => (p.name ?? p.key) === key).map(p => p.value ?? '').join(', ');
435
+ const keys = [...new Set(elements.flatMap(e => (e.properties ?? []).map(p => p.name ?? p.key)).filter(Boolean))];
436
+ const lines = (e) => {
437
+ const { incoming, outgoing } = this.model.relationshipsFor(e.id);
438
+ return [
439
+ ...outgoing.map(r => `${r.kind} → ${nameOf(r.target)}`),
440
+ ...incoming.map(r => `${r.kind} ← ${nameOf(r.source)}`),
441
+ ].sort().join('\n');
442
+ };
443
+ const views = (e) => this.model.views().filter(v => v.elementInstancesFor?.(e.id).length).map(v => v.name || v.id).join('\n');
444
+ return [
445
+ { label: 'Name', values: elements.map(e => e.name ?? '') },
446
+ { label: 'Type', values: elements.map(e => e.type ?? '') },
447
+ ...(elements.some(e => e.specialization) ? [{ label: 'Specialization', values: elements.map(e => e.specialization ?? '') }] : []),
448
+ { label: 'Documentation', values: elements.map(e => (e.documentation ?? '').trim()) },
449
+ ...keys.map(key => ({ label: `${key} (property)`, values: elements.map(e => valueOf(e, key)) })),
450
+ { label: 'Relationships', values: elements.map(lines) },
451
+ { label: 'Views', values: elements.map(views) },
452
+ ];
453
+ }
454
+
455
+ /**
456
+ * Merge some elements into one, one undo step (ArchimateModel#mergeCommand).
457
+ * Their relationships always come together. When they differ in anything
458
+ * but the views they are on, they are shown side by side
459
+ * (MergeCompareDialog) to choose which is kept and, where they differ, the
460
+ * documentation (one, or all joined), each property's value, the
461
+ * specialization; OK merges, Cancel leaves them. Alike, the first is kept
462
+ * without asking. Resolves to the merge's command, or null.
463
+ */
464
+ async askMerge(elementIds) {
310
465
  const elements = elementIds.map(id => this.model.getElement(id)).filter(Boolean);
311
466
  if (elements.length < 2) return null;
312
467
  const used = (e) => {
@@ -314,20 +469,40 @@ export class ArchimateCanvasEditor extends DiagramEditor {
314
469
  const { incoming, outgoing } = this.model.relationshipsFor(e.id);
315
470
  return `${views} view${views === 1 ? '' : 's'}, ${incoming.length + outgoing.length} relationship${incoming.length + outgoing.length === 1 ? '' : 's'}`;
316
471
  };
317
- return new FormDialog({
472
+ const ids = elements.map(e => e.id);
473
+ const rows = this.mergeComparison(elements);
474
+ const compare = new MergeCompareDialog({
318
475
  title: `Merge ${elements.length} elements`,
319
- description: 'The one kept takes the others\' relationships, their boxes on every view, and the documentation and properties it lacks; the others go.',
320
- fields: [{ key: 'keep', label: 'Keep', type: 'select', value: elements[0].id, choices: elements.map(e => [e.id, `${e.name || e.id} (${used(e)})`]) }],
321
- submitLabel: 'Merge',
322
- idPrefix: 'merge-',
476
+ description: 'The one kept takes the others\' relationships, their boxes on every view, and the properties it lacks; the others go. Where they differ (marked ≠), choose.',
477
+ columns: elements.map(e => `${e.name || e.id} (${used(e)})`),
478
+ rows,
323
479
  document: this.document ?? globalThis.document,
324
- onApply: ({ keep }) => this.mergeElements(keep, elementIds.filter(id => id !== keep)),
325
- }).open();
480
+ });
481
+ // Views apart (their boxes all come to the kept one), nothing to choose: the first is kept.
482
+ if (!rows.some(row => row.label !== 'Views' && MergeCompareDialog.differs(row))) return this.mergeElements(ids[0], ids.slice(1));
483
+ const { documentation, properties, specializations } = this.mergeChoices(elements);
484
+ const JOINED = '\u0000joined';
485
+ const propertyKeys = [...properties.keys()];
486
+ compare.fields = [
487
+ { key: 'keep', label: 'Keep', type: 'select', value: ids[0], choices: elements.map(e => [e.id, `${e.name || e.id} (${used(e)})`]) },
488
+ ...(documentation.length ? [{ key: 'documentation', label: 'Documentation', type: 'select', value: JOINED, choices: [[JOINED, 'All of them, joined'], ...documentation] }] : []),
489
+ ...(specializations.length ? [{ key: 'specialization', label: 'Specialization', type: 'select', value: specializations[0], choices: specializations.map(s => [s, s]) }] : []),
490
+ ...propertyKeys.map((key, i) => ({ key: `property-${i}`, label: key, type: 'select', value: properties.get(key)[0][0], choices: properties.get(key) })),
491
+ ];
492
+ const values = await compare.ask();
493
+ if (!values) return null;
494
+ const { keep } = values;
495
+ const joined = () => [...new Set(elements.map(e => (e.documentation ?? '').trim()).filter(Boolean))].join('\n\n');
496
+ return this.mergeElements(keep, ids.filter(id => id !== keep), {
497
+ ...(documentation.length && { documentation: values.documentation === JOINED ? joined() : values.documentation }),
498
+ ...(specializations.length && { specialization: values.specialization }),
499
+ properties: Object.fromEntries(propertyKeys.map((key, i) => [key, values[`property-${i}`]])),
500
+ });
326
501
  }
327
502
 
328
- /** Merge elements into one (`keepId`), one undo step; the canvas and tree redraw. */
329
- mergeElements(keepId, otherIds) {
330
- const cmd = this.model.mergeCommand(keepId, otherIds);
503
+ /** Merge elements into one (`keepId`), one undo step; the canvas and tree redraw. `choices`: MergeElementsCommand's. */
504
+ mergeElements(keepId, otherIds, choices = {}) {
505
+ const cmd = this.model.mergeCommand(keepId, otherIds, choices);
331
506
  this._executeCommand(cmd);
332
507
  return cmd;
333
508
  }
@@ -17,7 +17,7 @@ export class ArchimatePalette {
17
17
  * Tiles (one place, for every palette): a picture only, `columns` to a row,
18
18
  * its name on hover. A tile is there to say which type to drag, so it is small.
19
19
  */
20
- static TILE = Object.freeze({ icon: 22, columns: 4, padding: '3px 1px', gap: '3px' });
20
+ static TILE = Object.freeze({ icon: 12, columns: 6, padding: '2px 1px', gap: '2px' });
21
21
 
22
22
  /**
23
23
  * The groups for a version: each layer that has types, then ArchiMate 4's
@@ -1,3 +1,4 @@
1
+ import { Axis } from '../Axis.js';
1
2
  import { Chart } from '../Chart.js';
2
3
  import { ChartBox, ChartLabels } from '../ChartBox.js';
3
4
  import { Legend } from '../Legend.js';
@@ -54,7 +55,9 @@ export class AreaChart extends Chart {
54
55
  this.renderer.path('M' + topPts.map(p => `${p.px},${p.py}`).join(' L'), 'none', { color, width: 2 });
55
56
 
56
57
  if (seriesIndex === 0) {
58
+ const step = Axis.categoryLabelStep(s.values.map(d => d.label), width, { fontSize: 10 });
57
59
  s.values.forEach((d, i) => {
60
+ if (i % step) return;
58
61
  this.renderer.text(d.label, x + i * pointMargin, y + height + 20, { textAnchor, fontSize: '10px' });
59
62
  });
60
63
  }
@@ -62,8 +65,8 @@ export class AreaChart extends Chart {
62
65
 
63
66
  if (isMultiline && showLegend) {
64
67
  const legendItems = series.map((s, i) => ({ name: s.name, color: palette.colorAt(i) }));
65
- new Legend({
66
- items: legendItems, x: x + width + 20, y,
68
+ Legend.beside({ x, y, width }, this.renderer.width, {
69
+ items: legendItems,
67
70
  itemHeight: 20, markerSize: 12,
68
71
  }).draw(this.renderer);
69
72
  }
@@ -1,3 +1,4 @@
1
+ import { Axis } from '../Axis.js';
1
2
  import { Legend } from '../Legend.js';
2
3
  import { DataFilter } from '../DataFilter.js';
3
4
  import { BarChartBase } from './BarChartBase.js';
@@ -27,6 +28,7 @@ export class BarChart extends BarChartBase {
27
28
  const data = this.data;
28
29
  const options = this.options;
29
30
  const { x, y, width, height } = this._plotBox;
31
+ const labelStep = Axis.categoryLabelStep(data.map(d => d.label), width);
30
32
  const { barColor = 'steelblue', colors } = options;
31
33
  const { bandWidth, margin } = this._bandScale(data.length);
32
34
  const textAnchor = this.renderer.centerTextAnchor;
@@ -43,7 +45,7 @@ export class BarChart extends BarChartBase {
43
45
 
44
46
  this.renderer.rect(barX, barY, bandWidth, barHeight, color, null, { bar: true, dataIndex: i, tooltip: `${d.label}: ${d.value}` });
45
47
  this.renderer.text(String(d.value), barX + bandWidth / 2, barY - 5, { textAnchor, fontSize: '10px', fill: '#333' });
46
- this.renderer.text(d.label, barX + bandWidth / 2, y + height + 20, { textAnchor });
48
+ if (i % labelStep === 0) this.renderer.text(d.label, barX + bandWidth / 2, y + height + 20, { textAnchor });
47
49
  });
48
50
  }
49
51
 
@@ -60,8 +62,8 @@ export class BarChart extends BarChartBase {
60
62
  hidden: this._dataFilter.has(i),
61
63
  dataIndex: i,
62
64
  }));
63
- new Legend({
64
- items: legendItems, x: x + width + 20, y,
65
+ Legend.beside({ x, y, width }, this.renderer.width, {
66
+ items: legendItems,
65
67
  itemHeight: 20, markerSize: 10,
66
68
  }).draw(this.renderer);
67
69
  }
@@ -1,3 +1,4 @@
1
+ import { Legend } from '../Legend.js';
1
2
  import { ColorPalette } from '../ColorPalette.js';
2
3
  import { Chart3D } from '../Chart3D.js';
3
4
 
@@ -42,12 +43,9 @@ export class BarChart3DOrbit extends Chart3D {
42
43
  this.drawBox(this.renderer, bar.x0, bar.x1, 0, bar.y1, bar.z0, bar.z1, bar.color, toScreen);
43
44
  }
44
45
 
45
- let legY = by + 10;
46
- const legX = bx + width - 100;
47
- zLabels.forEach((label, i) => {
48
- this.renderer.circle(legX + 5, legY, 5, palette.colorAt(i), null, {});
49
- this.renderer.text(String(label), legX + 16, legY + 4, { textAnchor: 'start', fontSize: '11px', fill: '#374151' });
50
- legY += 18;
51
- });
46
+ new Legend({
47
+ items: zLabels.map((label, i) => ({ label: String(label), color: palette.colorAt(i) })),
48
+ x: bx + width - 100, y: by + 5, itemHeight: 18, fontSize: '11px', marker: 'circle',
49
+ }).draw(this.renderer);
52
50
  }
53
51
  }
@@ -1,3 +1,4 @@
1
+ import { Legend } from '../Legend.js';
1
2
  import { Chart } from '../Chart.js';
2
3
  import { ChartBox, ChartLabels } from '../ChartBox.js';
3
4
  import { ColorPalette } from '../ColorPalette.js';
@@ -87,13 +88,10 @@ export class BubbleChart extends Chart {
87
88
 
88
89
  // Category color legend
89
90
  if (catField && catColors.size > 0) {
90
- let legY = y + 10;
91
- const legX = x + width + 20;
92
- for (const [cat, color] of catColors) {
93
- this.renderer.circle(legX + 5, legY, 5, color, null, {});
94
- this.renderer.text(String(cat), legX + 16, legY + 4, { textAnchor: 'start', fontSize: '11px', fill: '#374151' });
95
- legY += 18;
96
- }
91
+ Legend.beside({ x, y, width }, this.renderer.width, {
92
+ items: [...catColors].map(([cat, color]) => ({ label: String(cat), color })),
93
+ itemHeight: 18, fontSize: '11px', marker: 'circle',
94
+ }).draw(this.renderer);
97
95
  }
98
96
  }
99
97
  }
@@ -1,3 +1,4 @@
1
+ import { Legend } from '../Legend.js';
1
2
  import { ColorPalette } from '../ColorPalette.js';
2
3
  import { Chart3D } from '../Chart3D.js';
3
4
  import { ChartData } from '../ChartData.js';
@@ -66,13 +67,10 @@ export class BubbleChart3D extends Chart3D {
66
67
  }
67
68
 
68
69
  if (catField && catColors.size > 0) {
69
- let legY = by + 10;
70
- const legX = bx + width - 90;
71
- for (const [cat, color] of catColors) {
72
- this.renderer.circle(legX + 5, legY, 5, color, null, {});
73
- this.renderer.text(String(cat), legX + 16, legY + 4, { textAnchor: 'start', fontSize: '11px', fill: '#374151' });
74
- legY += 18;
75
- }
70
+ new Legend({
71
+ items: [...catColors].map(([cat, color]) => ({ label: String(cat), color })),
72
+ x: bx + width - 90, y: by + 5, itemHeight: 18, fontSize: '11px', marker: 'circle',
73
+ }).draw(this.renderer);
76
74
  }
77
75
  }
78
76
  }
@@ -1,3 +1,4 @@
1
+ import { Axis } from '../Axis.js';
1
2
  import { Chart } from '../Chart.js';
2
3
  import { ChartBox, ChartLabels } from '../ChartBox.js';
3
4
 
@@ -22,6 +23,7 @@ export class CandlestickChart extends Chart {
22
23
  const vMin = Math.min(...data.map(d => d.low));
23
24
  const range = (vMax - vMin) || 1;
24
25
  const slotWidth = width / data.length;
26
+ const labelStep = Axis.categoryLabelStep(data.map(d => d.label), width, { fontSize: 10 });
25
27
  const bodyWidth = slotWidth * 0.6;
26
28
  const toY = (v) => y + height - ((v - vMin) / range) * height;
27
29
  const textAnchor = this.renderer.centerTextAnchor;
@@ -39,7 +41,7 @@ export class CandlestickChart extends Chart {
39
41
  this.renderer.rect(cx - bodyWidth / 2, bodyTop, bodyWidth, Math.max(1, bodyBottom - bodyTop), color, null, {
40
42
  tooltip: `${d.label}: O ${d.open} H ${d.high} L ${d.low} C ${d.close}`,
41
43
  });
42
- if (i % Math.ceil(data.length / 10 || 1) === 0) {
44
+ if (i % labelStep === 0) {
43
45
  this.renderer.text(d.label, cx, y + height + 20, { textAnchor, fontSize: '10px' });
44
46
  }
45
47
  });
@@ -1,3 +1,4 @@
1
+ import { Legend } from '../Legend.js';
1
2
  import { Chart } from '../Chart.js';
2
3
  import { ChartBox, ChartLabels } from '../ChartBox.js';
3
4
  import { ColorPalette } from '../ColorPalette.js';
@@ -62,12 +63,9 @@ export class DensityChart extends Chart {
62
63
  });
63
64
 
64
65
  if (showLegend) {
65
- data.forEach((d, i) => {
66
- const legendX = x + width + 20;
67
- const legendY = y + i * 20;
68
- this.renderer.rect(legendX, legendY, 12, 12, palette.colorAt(i), null);
69
- this.renderer.text(d.label, legendX + 20, legendY + 6, { fontSize: '12px', fill: '#333' });
70
- });
66
+ Legend.beside({ x, y, width }, this.renderer.width, {
67
+ items: data.map((d, i) => ({ label: d.label, color: palette.colorAt(i) })), markerSize: 12,
68
+ }).draw(this.renderer);
71
69
  }
72
70
  }
73
71
  }
@@ -79,8 +79,8 @@ export class GroupedBarChart extends BarChartBase {
79
79
  color: this._palette.colorAt(i),
80
80
  dataIndex: i,
81
81
  }));
82
- new Legend({
83
- items: legendItems, x: x + width + 20, y,
82
+ Legend.beside({ x, y, width }, this.renderer.width, {
83
+ items: legendItems,
84
84
  itemHeight: 20, markerSize: 10,
85
85
  }).draw(this.renderer);
86
86
  }
@@ -1,3 +1,4 @@
1
+ import { Axis } from '../Axis.js';
1
2
  import { Chart } from '../Chart.js';
2
3
  import { ChartBox, ChartLabels } from '../ChartBox.js';
3
4
  import { Legend } from '../Legend.js';
@@ -104,7 +105,9 @@ export class LineChart extends Chart {
104
105
  // series' own seriesDepth (not the raw max `depth`) for the x-shift
105
106
  // so labels line up under series 0's actual (depth-shifted) points.
106
107
  if (seriesIndex === 0) {
108
+ const step = Axis.categoryLabelStep(seriesValues.map(d => d.label), width);
107
109
  seriesValues.forEach((d, i) => {
110
+ if (i % step) return;
108
111
  const pointX = x + i * pointMargin + seriesDepth;
109
112
  const textAnchor = this.renderer.centerTextAnchor;
110
113
  this.renderer.text(d.label, pointX, y + height + 20, { textAnchor: textAnchor });
@@ -115,8 +118,8 @@ export class LineChart extends Chart {
115
118
  // Draw legend for multiline
116
119
  if (isMultiline && showLegend) {
117
120
  const legendItems = series.map((s, i) => ({ name: s.name, color: palette.colorAt(i) }));
118
- new Legend({
119
- items: legendItems, x: x + width + 20, y,
121
+ Legend.beside({ x, y, width }, this.renderer.width, {
122
+ items: legendItems,
120
123
  itemHeight: 20, markerSize: 12,
121
124
  }).draw(this.renderer);
122
125
  }
@@ -94,8 +94,8 @@ export class ParallelCoordinatesChart extends Chart {
94
94
 
95
95
  if (legendVisible && hasCategories) {
96
96
  const legendItems = categories.map((c, i) => ({ name: c, color: palette.colorAt(i) }));
97
- new Legend({
98
- items: legendItems, x: x + width + 20, y,
97
+ Legend.beside({ x, y, width }, this.renderer.width, {
98
+ items: legendItems,
99
99
  itemHeight: 20, markerSize: 12,
100
100
  }).draw(this.renderer);
101
101
  }
@@ -1,3 +1,4 @@
1
+ import { Legend } from '../Legend.js';
1
2
  import { Chart } from '../Chart.js';
2
3
  import { ChartBox, ChartLabels } from '../ChartBox.js';
3
4
  import { ColorPalette } from '../ColorPalette.js';
@@ -77,12 +78,9 @@ export class PipeChart extends Chart {
77
78
  });
78
79
 
79
80
  if (isMultiline && showLegend) {
80
- series.forEach((s, i) => {
81
- const legendX = x + width + 20;
82
- const legendY = y + i * 20;
83
- this.renderer.rect(legendX, legendY, 12, 12, palette.colorAt(i), null);
84
- this.renderer.text(s.name, legendX + 20, legendY + 6, { fontSize: '12px', fill: '#333' });
85
- });
81
+ Legend.beside({ x, y, width }, this.renderer.width, {
82
+ items: series.map((s, i) => ({ name: s.name, color: palette.colorAt(i) })), markerSize: 12,
83
+ }).draw(this.renderer);
86
84
  }
87
85
  }
88
86
  }
@@ -1,3 +1,4 @@
1
+ import { Legend } from '../Legend.js';
1
2
  import { Chart } from '../Chart.js';
2
3
  import { ColorPalette } from '../ColorPalette.js';
3
4
 
@@ -65,12 +66,10 @@ export class RadarChart extends Chart {
65
66
  });
66
67
 
67
68
  if (isMulti && showLegend) {
68
- series.forEach((s, i) => {
69
- const legendX = cx + radius + 40;
70
- const legendY = cy - radius + i * 20;
71
- this.renderer.rect(legendX, legendY, 12, 12, palette.colorAt(i), null);
72
- this.renderer.text(s.name, legendX + 18, legendY + 6, { fontSize: '12px', fill: '#333', verticalAlign: 'middle' });
73
- });
69
+ new Legend({
70
+ items: series.map((s, i) => ({ name: s.name, color: palette.colorAt(i) })),
71
+ x: cx + radius + 40, y: cy - radius, markerSize: 12,
72
+ }).draw(this.renderer);
74
73
  }
75
74
  }
76
75
  }
@@ -1,3 +1,4 @@
1
+ import { Legend } from '../Legend.js';
1
2
  import { Chart } from '../Chart.js';
2
3
  import { ChartBox, ChartLabels } from '../ChartBox.js';
3
4
  import { ColorPalette } from '../ColorPalette.js';
@@ -92,13 +93,10 @@ export class ScatterChart extends Chart {
92
93
  // Category color legend — same inline pattern as bubbleChart (not the
93
94
  // generic showLegend mechanism other 2D charts use).
94
95
  if (catField && catColors.size > 0) {
95
- let legY = y + 10;
96
- const legX = x + width + 20;
97
- for (const [cat, color] of catColors) {
98
- this.renderer.circle(legX + 5, legY, 5, color, null, {});
99
- this.renderer.text(String(cat), legX + 16, legY + 4, { textAnchor: 'start', fontSize: '11px', fill: '#374151' });
100
- legY += 18;
101
- }
96
+ Legend.beside({ x, y, width }, this.renderer.width, {
97
+ items: [...catColors].map(([cat, color]) => ({ label: String(cat), color })),
98
+ itemHeight: 18, fontSize: '11px', marker: 'circle',
99
+ }).draw(this.renderer);
102
100
  }
103
101
  }
104
102
  }
@@ -1,3 +1,4 @@
1
+ import { Axis } from '../Axis.js';
1
2
  import { BarChartBase } from './BarChartBase.js';
2
3
 
3
4
  /**
@@ -13,7 +14,8 @@ export class StackedBarChart extends BarChartBase {
13
14
 
14
15
  _renderBars(maxStackValue) {
15
16
  const data = this.data;
16
- const { x, y, height } = this._plotBox;
17
+ const { x, y, width, height } = this._plotBox;
18
+ const labelStep = Axis.categoryLabelStep(data.map(d => d.label), width);
17
19
  const { bandWidth, margin } = this._bandScale(data.length);
18
20
  const textAnchor = this.renderer.centerTextAnchor;
19
21
 
@@ -27,7 +29,7 @@ export class StackedBarChart extends BarChartBase {
27
29
  tooltip: `${d.label} - Series ${j + 1}: ${value}`,
28
30
  });
29
31
  });
30
- this.renderer.text(d.label, barX + bandWidth / 2, y + height + 20, { textAnchor });
32
+ if (i % labelStep === 0) this.renderer.text(d.label, barX + bandWidth / 2, y + height + 20, { textAnchor });
31
33
  });
32
34
  }
33
35
  }
@@ -1,3 +1,4 @@
1
+ import { Legend } from '../Legend.js';
1
2
  import { ColorPalette } from '../ColorPalette.js';
2
3
  import { Chart3D } from '../Chart3D.js';
3
4
 
@@ -47,12 +48,9 @@ export class StackedBarChart3DOrbit extends Chart3D {
47
48
  }
48
49
 
49
50
  const maxSegments = Math.max(...data.map(d => d.values.length));
50
- let legY = by + 10;
51
- const legX = bx + width - 100;
52
- for (let j = 0; j < maxSegments; j++) {
53
- this.renderer.circle(legX + 5, legY, 5, palette.colorAt(j), null, {});
54
- this.renderer.text(`Series ${j + 1}`, legX + 16, legY + 4, { textAnchor: 'start', fontSize: '11px', fill: '#374151' });
55
- legY += 18;
56
- }
51
+ new Legend({
52
+ items: Array.from({ length: maxSegments }, (_, j) => ({ label: `Series ${j + 1}`, color: palette.colorAt(j) })),
53
+ x: bx + width - 100, y: by + 5, itemHeight: 18, fontSize: '11px', marker: 'circle',
54
+ }).draw(this.renderer);
57
55
  }
58
56
  }
@@ -1,3 +1,4 @@
1
+ import { Axis } from '../Axis.js';
1
2
  import { Chart } from '../Chart.js';
2
3
  import { ColorPalette } from '../ColorPalette.js';
3
4
  import { Legend } from '../Legend.js';
@@ -110,7 +111,9 @@ export class StreamGraphChart extends Chart {
110
111
  });
111
112
 
112
113
  // X-axis labels.
114
+ const labelStep = Axis.categoryLabelStep(series[0].values.map(d => d.label), width, { fontSize: 10 });
113
115
  series[0].values.forEach((d, i) => {
116
+ if (i % labelStep) return;
114
117
  this.renderer.text(d.label, x + i * pointMargin, y + height + 16, { textAnchor, fontSize: '10px', fill: '#666' });
115
118
  });
116
119
 
@@ -122,8 +125,8 @@ export class StreamGraphChart extends Chart {
122
125
 
123
126
  if (isMultiline && showLegend) {
124
127
  const legendItems = series.map((s, i) => ({ label: s.name, color: palette.colorAt(i) }));
125
- new Legend({
126
- items: legendItems, x: x + width + 20, y,
128
+ Legend.beside({ x, y, width }, this.renderer.width, {
129
+ items: legendItems,
127
130
  itemHeight: 20, markerSize: 12,
128
131
  }).draw(this.renderer);
129
132
  }
@@ -38,6 +38,7 @@ export class WaterfallChart extends Chart {
38
38
  const range = (vMax - vMin) || 1;
39
39
  const toY = (v) => y + height - ((v - vMin) / range) * height;
40
40
  const barWidth = width / data.length * 0.7;
41
+ const labelStep = Axis.categoryLabelStep(data.map(d => d.label), width, { fontSize: 10 });
41
42
  const barMargin = width / data.length * 0.3;
42
43
  const textAnchor = this.renderer.centerTextAnchor;
43
44
 
@@ -60,7 +61,7 @@ export class WaterfallChart extends Chart {
60
61
  this.renderer.rect(barX, top, barWidth, Math.max(1, bottom - top), color, null, {
61
62
  tooltip: `${d.label}: ${band.isTotal ? band.to : (d.value >= 0 ? '+' : '') + d.value} (total: ${Axis.formatValue(band.to)})`,
62
63
  });
63
- this.renderer.text(d.label, barX + barWidth / 2, y + height + 20, { textAnchor, fontSize: '10px' });
64
+ if (i % labelStep === 0) this.renderer.text(d.label, barX + barWidth / 2, y + height + 20, { textAnchor, fontSize: '10px' });
64
65
  });
65
66
  }
66
67
  }