@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
@@ -0,0 +1,400 @@
1
+ /**
2
+ * AskView — the state and next-word prediction model for an interactive "Ask" bar
3
+ * (question input, scrollable predictive typeahead list with semantic sense/reference/relation
4
+ * badges, keyboard navigation, and configurable next-word prediction), kept apart from DOM
5
+ * rendering so all of it can be unit-tested under Node.js without a browser.
6
+ *
7
+ * A host can supply its own `suggest(text, view)` callback (for example, semanticengine's
8
+ * `(text) => engine.suggest(text, { sampler: (dim) => engine.members(dim) })`), or pass
9
+ * declarative vocabulary hints (`measures`, `dimensions`, `members`, `vocabulary`,
10
+ * `sampleQuestions`) to use `AskView.nextWordPredictor(...)`.
11
+ */
12
+ export class AskView {
13
+ /** Default choices for how many predicted next-word items are visible at once in the scrollable list (`0` = Off). */
14
+ static DEFAULT_PREDICT_CHOICES = [0, 3, 5, 8, 10];
15
+
16
+ /**
17
+ * @param {object} [options]
18
+ * @param {string} [options.value=''] initial question text
19
+ * @param {function} [options.suggest] `(text, view) => Array<string|{display, completion?, sense?, reference?, relation?, kind?}>`, or a promise of one
20
+ * @param {string[]} [options.measures] measure names for built-in next-word prediction
21
+ * @param {string[]} [options.dimensions] dimension names for built-in next-word prediction
22
+ * @param {function|object} [options.members] `(dimension) => Array<string|number>` or `{ dim: [...] }` for value prediction after `=`
23
+ * @param {Array<string|{display, completion?, sense?, reference?, relation?, kind?}>} [options.vocabulary] custom vocabulary entries
24
+ * @param {string[]} [options.sampleQuestions] sample questions used both as clickable pills and to learn next-word transitions
25
+ * @param {boolean} [options.predict=true] whether next-word prediction is initially enabled
26
+ * @param {number} [options.visibleCount=5] how many items are visible at once in the scrollable suggestion viewport
27
+ * @param {number[]} [options.predictChoices=[0, 3, 5, 8, 10]] configurable choices for the predict-next-word selector (`0` = Off)
28
+ * @param {number} [options.maxSuggestions=50] maximum number of suggestions kept in the scrollable list
29
+ * @param {function} [options.onAsk] `(question, view) => *` called when the user submits the question
30
+ * @param {function} [options.onComplete] `(item, view) => void` called when a suggestion is accepted
31
+ * @param {function} [options.onChange] `(view) => void` called whenever view state changes
32
+ */
33
+ constructor(options = {}) {
34
+ this.value = options.value ?? '';
35
+ this.samples = [...(options.sampleQuestions ?? options.samples ?? [])];
36
+ this.predictChoices = Array.isArray(options.predictChoices) && options.predictChoices.length > 0
37
+ ? [...options.predictChoices]
38
+ : [...AskView.DEFAULT_PREDICT_CHOICES];
39
+ const initialVisible = Number.isFinite(options.visibleCount) && options.visibleCount > 0 ? Math.floor(options.visibleCount) : 5;
40
+ this.visibleCount = initialVisible;
41
+ this.predict = options.predict !== false && options.visibleCount !== 0;
42
+ if (this.predict && !this.predictChoices.includes(this.visibleCount)) {
43
+ this.predictChoices = [...new Set([...this.predictChoices, this.visibleCount])].sort((a, b) => a - b);
44
+ }
45
+ this.maxSuggestions = Number.isFinite(options.maxSuggestions) && options.maxSuggestions > 0
46
+ ? Math.floor(options.maxSuggestions)
47
+ : 50;
48
+ this.suggestFn = typeof options.suggest === 'function'
49
+ ? options.suggest
50
+ : AskView.nextWordPredictor({
51
+ measures: options.measures,
52
+ dimensions: options.dimensions,
53
+ members: options.members,
54
+ vocabulary: options.vocabulary,
55
+ sampleQuestions: this.samples,
56
+ });
57
+ this.onAsk = options.onAsk ?? null;
58
+ this.onComplete = options.onComplete ?? null;
59
+ this.onChange = options.onChange ?? null;
60
+ this.items = [];
61
+ this.selectedIndex = -1;
62
+ this.open = false;
63
+ this.history = [];
64
+ this._ticket = 0;
65
+ }
66
+
67
+ /**
68
+ * Current predict-next-word setting as a single number (`0` when Off, else `visibleCount`),
69
+ * matching `predictChoices`.
70
+ */
71
+ get predictCount() {
72
+ return this.predict ? this.visibleCount : 0;
73
+ }
74
+
75
+ /**
76
+ * Enables or disables next-word prediction, or sets how many items show at once in the
77
+ * scrollable list (`0` or `false` turns prediction off; a positive integer enables it with
78
+ * that viewport size).
79
+ */
80
+ setPredictCount(countOrEnabled) {
81
+ if (countOrEnabled === false || countOrEnabled === 0 || countOrEnabled === '0' || countOrEnabled === 'off') {
82
+ this.predict = false;
83
+ this.items = [];
84
+ this.selectedIndex = -1;
85
+ this.open = false;
86
+ this._changed();
87
+ return;
88
+ }
89
+ if (countOrEnabled === true) {
90
+ this.predict = true;
91
+ if (this.visibleCount <= 0) this.visibleCount = 5;
92
+ } else {
93
+ const n = Number(countOrEnabled);
94
+ if (Number.isFinite(n) && n > 0) {
95
+ this.predict = true;
96
+ this.visibleCount = Math.floor(n);
97
+ }
98
+ }
99
+ this.refresh();
100
+ }
101
+
102
+ /** Updates the input text and refreshes next-word predictions if enabled. */
103
+ setInput(text, { suggest = true } = {}) {
104
+ this.value = String(text ?? '');
105
+ if (suggest && this.predict) {
106
+ return this.refresh();
107
+ } else {
108
+ if (!suggest) {
109
+ this.open = false;
110
+ this.selectedIndex = -1;
111
+ }
112
+ this._changed();
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Swaps the source of suggestions (a host whose vocabulary changed — another model or
118
+ * ontology) and refreshes the list.
119
+ */
120
+ setSuggest(suggest) {
121
+ this.suggestFn = typeof suggest === 'function' ? suggest : null;
122
+ this.refresh();
123
+ }
124
+
125
+ /**
126
+ * Recomputes suggestions for the current `value` and opens the list if any match. Nothing is
127
+ * selected until the user moves into the list, so Enter asks the question as typed.
128
+ *
129
+ * `suggest` may answer with a promise (an engine still loading, a server's vocabulary): the
130
+ * list keeps what it shows until it settles, and an answer for text since changed is dropped.
131
+ * Returns the items, or a promise of them.
132
+ */
133
+ refresh() {
134
+ const ticket = ++this._ticket;
135
+ if (!this.predict || !this.suggestFn) {
136
+ this._apply([]);
137
+ return this.items;
138
+ }
139
+ let raw;
140
+ try {
141
+ raw = this.suggestFn(this.value, this);
142
+ } catch {
143
+ this._apply([]);
144
+ return this.items;
145
+ }
146
+ if (raw && typeof raw.then === 'function') {
147
+ const asked = this.value;
148
+ return Promise.resolve(raw).then(
149
+ (items) => (ticket === this._ticket && asked === this.value ? this._apply(items) : this.items),
150
+ () => (ticket === this._ticket ? this._apply([]) : this.items),
151
+ );
152
+ }
153
+ return this._apply(raw);
154
+ }
155
+
156
+ /** Shows `raw` suggestions (anything not a list shows none). */
157
+ _apply(raw) {
158
+ this.items = Array.isArray(raw)
159
+ ? raw.slice(0, this.maxSuggestions).map((item) => AskView.normalizeItem(item, this.value))
160
+ : [];
161
+ this.selectedIndex = -1;
162
+ this.open = this.items.length > 0;
163
+ this._changed();
164
+ return this.items;
165
+ }
166
+
167
+ /**
168
+ * Moves the active highlight up (`-1`) or down (`+1`) in the scrollable list, wrapping around.
169
+ * From no selection, down goes to the first item and up to the last.
170
+ */
171
+ move(delta) {
172
+ if (!this.open || this.items.length === 0) {
173
+ this.refresh();
174
+ return this.selectedIndex;
175
+ }
176
+ const len = this.items.length;
177
+ this.selectedIndex = this.selectedIndex < 0
178
+ ? (delta > 0 ? 0 : len - 1)
179
+ : (this.selectedIndex + delta + len) % len;
180
+ this._changed();
181
+ return this.selectedIndex;
182
+ }
183
+
184
+ /**
185
+ * Applies the suggestion at `index` (defaults to `selectedIndex`, or `0`), replacing `value`
186
+ * with `item.completion` and immediately predicting the next word.
187
+ */
188
+ accept(index = this.selectedIndex >= 0 ? this.selectedIndex : 0) {
189
+ if (index < 0 || index >= this.items.length) return null;
190
+ const item = this.items[index];
191
+ this.value = item.completion;
192
+ this.onComplete?.(item, this);
193
+ if (this.predict) {
194
+ this.refresh();
195
+ } else {
196
+ this.close();
197
+ }
198
+ return item;
199
+ }
200
+
201
+ /** Closes the suggestion popup without changing `value`. */
202
+ close() {
203
+ if (!this.open && this.selectedIndex === -1) return;
204
+ this.open = false;
205
+ this.selectedIndex = -1;
206
+ this._changed();
207
+ }
208
+
209
+ /** Submits the current question (`value`), closes the suggestion list, and invokes `onAsk`. */
210
+ submit(question = this.value) {
211
+ this.value = String(question ?? '');
212
+ this.open = false;
213
+ this.selectedIndex = -1;
214
+ const trimmed = this.value.trim();
215
+ if (trimmed && this.history.at(-1) !== trimmed) {
216
+ this.history.push(trimmed);
217
+ }
218
+ this._changed();
219
+ return this.onAsk?.(this.value, this);
220
+ }
221
+
222
+ _changed() {
223
+ this.onChange?.(this);
224
+ }
225
+
226
+ /** Normalizes a string or suggestion object into `{ display, completion, sense, reference, relation, kind }`. */
227
+ static normalizeItem(item, currentText = '') {
228
+ if (typeof item === 'string') {
229
+ return {
230
+ display: item,
231
+ completion: AskView._completeWord(currentText, item),
232
+ sense: null,
233
+ reference: null,
234
+ relation: null,
235
+ kind: 'suggestion',
236
+ };
237
+ }
238
+ const display = String(item.display ?? item.word ?? item.label ?? item.completion ?? '');
239
+ const completion = item.completion !== undefined
240
+ ? String(item.completion)
241
+ : AskView._completeWord(currentText, display);
242
+ return {
243
+ display,
244
+ completion,
245
+ sense: item.sense ?? null,
246
+ reference: item.reference ?? null,
247
+ relation: item.relation ?? null,
248
+ kind: item.kind ?? 'suggestion',
249
+ };
250
+ }
251
+
252
+ /** Replaces the trailing partial token of `currentText` with `word` (followed by a space). */
253
+ static _completeWord(currentText, word) {
254
+ const text = String(currentText ?? '');
255
+ if (word.includes(' ') && word.toLowerCase().startsWith(text.trim().toLowerCase()) && text.trim().length > 0) {
256
+ return word;
257
+ }
258
+ const match = text.match(/^(.*?)(\S*)$/);
259
+ const prefix = match ? match[1] : '';
260
+ const cleanWord = String(word).trim();
261
+ return `${prefix}${cleanWord} `;
262
+ }
263
+
264
+ /**
265
+ * Builds a context-aware next-word predictor from declarative cube/table metadata and/or
266
+ * sample questions. Predicts metrics, dimensions, clauses (`by`, `and`, `where`, `top`, `vs`),
267
+ * sampled dimension members after `=`, and learned next-word bigrams from `sampleQuestions`.
268
+ */
269
+ static nextWordPredictor({
270
+ measures = [],
271
+ dimensions = [],
272
+ members = null,
273
+ vocabulary = [],
274
+ sampleQuestions = [],
275
+ } = {}) {
276
+ const catalog = [];
277
+ const addEntry = (display, kind, sense = null, reference = null, relation = null) => {
278
+ if (!display) return;
279
+ if (catalog.some(c => c.display.toLowerCase() === String(display).toLowerCase() && c.kind === kind)) return;
280
+ catalog.push({ display: String(display), kind, sense, reference, relation });
281
+ };
282
+
283
+ for (const m of measures) addEntry(m, 'metric', `Measure ${m}`, m, 'canonical');
284
+ for (const d of dimensions) addEntry(d, 'dimension', `Dimension ${d}`, d, 'canonical');
285
+ for (const v of vocabulary) {
286
+ if (typeof v === 'string') addEntry(v, 'term');
287
+ else addEntry(v.display ?? v.word ?? v.label, v.kind ?? 'term', v.sense ?? null, v.reference ?? null, v.relation ?? null);
288
+ }
289
+
290
+ const keywords = [
291
+ { display: 'by', kind: 'keyword', sense: 'Group or slice by dimension' },
292
+ { display: 'and', kind: 'keyword', sense: 'Add another measure or dimension' },
293
+ { display: 'where', kind: 'keyword', sense: 'Filter by dimension member' },
294
+ { display: 'top', kind: 'keyword', sense: 'Rank top N members' },
295
+ { display: '=', kind: 'operator', sense: 'Equals member value' },
296
+ { display: 'in', kind: 'keyword', sense: 'Match any of several members' },
297
+ { display: 'vs', kind: 'keyword', sense: 'Compare against another measure or period' },
298
+ { display: 'descending', kind: 'keyword', sense: 'Sort highest first' },
299
+ { display: 'ascending', kind: 'keyword', sense: 'Sort lowest first' },
300
+ ];
301
+ for (const kw of keywords) addEntry(kw.display, kw.kind, kw.sense);
302
+
303
+ // Learn next-word transitions (bigrams) from sampleQuestions.
304
+ const bigrams = new Map();
305
+ for (const q of sampleQuestions) {
306
+ const tokens = String(q).trim().split(/\s+/).filter(Boolean);
307
+ for (let i = 0; i < tokens.length - 1; i++) {
308
+ const prev = tokens[i].replace(/,+$/, '').toLowerCase();
309
+ const next = tokens[i + 1].replace(/,+$/, '');
310
+ if (!bigrams.has(prev)) bigrams.set(prev, new Set());
311
+ bigrams.get(prev).add(next);
312
+ addEntry(tokens[i].replace(/,+$/, ''), 'term');
313
+ }
314
+ if (tokens.length > 0) addEntry(tokens.at(-1).replace(/,+$/, ''), 'term');
315
+ }
316
+
317
+ const getMembers = (dim) => {
318
+ if (!dim || !members) return [];
319
+ if (typeof members === 'function') return members(dim) ?? [];
320
+ const foundKey = Object.keys(members).find(k => k.toLowerCase() === dim.toLowerCase());
321
+ return foundKey ? members[foundKey] ?? [] : [];
322
+ };
323
+
324
+ return (text) => {
325
+ const raw = String(text ?? '');
326
+ const match = raw.match(/^(.*?)(\S*)$/);
327
+ const before = match ? match[1] : '';
328
+ const partial = (match ? match[2] : '').toLowerCase();
329
+ const priorTokens = before.trim().split(/\s+/).filter(Boolean);
330
+ const lastToken = (priorTokens.at(-1) ?? '').replace(/,+$/, '').toLowerCase();
331
+ const prevToken = (priorTokens.at(-2) ?? '').replace(/,+$/, '').toLowerCase();
332
+
333
+ const results = [];
334
+ const push = (entry, bonus = 0) => {
335
+ const word = String(entry.display);
336
+ if (partial && !word.toLowerCase().startsWith(partial)) return;
337
+ if (results.some(r => r.display.toLowerCase() === word.toLowerCase())) return;
338
+ results.push({
339
+ display: word,
340
+ completion: AskView._completeWord(raw, word),
341
+ sense: entry.sense ?? null,
342
+ reference: entry.reference ?? null,
343
+ relation: entry.relation ?? null,
344
+ kind: entry.kind ?? 'suggestion',
345
+ _score: bonus + (word.toLowerCase() === partial ? 0 : 10),
346
+ });
347
+ };
348
+
349
+ // 1. After `<dimension> =`, suggest sampled member values of that dimension.
350
+ if (lastToken === '=' && prevToken) {
351
+ const dimName = dimensions.find(d => d.toLowerCase() === prevToken) ?? prevToken;
352
+ for (const val of getMembers(dimName)) {
353
+ push({
354
+ display: String(val),
355
+ kind: 'value',
356
+ sense: `Member of ${dimName}`,
357
+ reference: `${dimName} = ${val}`,
358
+ }, 100);
359
+ }
360
+ }
361
+
362
+ // 2. Context-sensitive grammar transitions.
363
+ if (!lastToken) {
364
+ for (const c of catalog.filter(e => e.kind === 'metric')) push(c, 80);
365
+ for (const c of catalog.filter(e => e.display === 'top')) push(c, 70);
366
+ for (const c of catalog.filter(e => e.kind === 'dimension')) push(c, 60);
367
+ } else if (lastToken === 'by' || lastToken === 'where') {
368
+ for (const c of catalog.filter(e => e.kind === 'dimension')) push(c, 90);
369
+ } else if (lastToken === 'and') {
370
+ const inByClause = priorTokens.some(t => t.toLowerCase() === 'by');
371
+ for (const c of catalog.filter(e => e.kind === (inByClause ? 'dimension' : 'metric'))) push(c, 90);
372
+ for (const c of catalog.filter(e => e.kind === (inByClause ? 'metric' : 'dimension'))) push(c, 70);
373
+ } else if (dimensions.some(d => d.toLowerCase() === lastToken)) {
374
+ const inWhere = priorTokens.some(t => t.toLowerCase() === 'where');
375
+ if (inWhere) {
376
+ for (const c of catalog.filter(e => e.display === '=')) push(c, 95);
377
+ } else {
378
+ for (const c of catalog.filter(e => ['and', 'where', 'descending', 'ascending'].includes(e.display))) push(c, 85);
379
+ }
380
+ } else if (measures.some(m => m.toLowerCase() === lastToken)) {
381
+ for (const c of catalog.filter(e => ['by', 'and', 'where', 'vs'].includes(e.display))) push(c, 90);
382
+ }
383
+
384
+ // 3. Learned bigram transitions from sample questions.
385
+ if (lastToken && bigrams.has(lastToken)) {
386
+ for (const nextWord of bigrams.get(lastToken)) {
387
+ const existing = catalog.find(c => c.display.toLowerCase() === nextWord.toLowerCase());
388
+ push(existing ?? { display: nextWord, kind: 'next word', sense: `Follows "${lastToken}"` }, 88);
389
+ }
390
+ }
391
+
392
+ // 4. All remaining catalog entries matching the partial prefix.
393
+ for (const c of catalog) push(c, 20);
394
+
395
+ return results
396
+ .sort((a, b) => b._score - a._score)
397
+ .map(({ _score, ...rest }) => rest);
398
+ };
399
+ }
400
+ }
@@ -16,8 +16,10 @@ export class AreaChart extends Chart {
16
16
  const box = ChartBox.fromRenderer(this.renderer);
17
17
  const {
18
18
  x = box.x, y = box.y, width = box.width, height = box.height,
19
- colors, title, xAxisLabel, yAxisLabel, stacked = false, legend: showLegend = false,
19
+ colors, title, xAxisLabel, yAxisLabel, stacked = false,
20
+ normalize = false, stackMode, legend: showLegend = false,
20
21
  } = options;
22
+ const isPercent = Boolean(stacked && (normalize || stackMode === 'percent'));
21
23
  const plotBox = { x, y, width, height };
22
24
  const isMultiline = Array.isArray(data[0]?.values);
23
25
  const series = isMultiline ? data : [{ name: 'default', values: data }];
@@ -26,6 +28,10 @@ export class AreaChart extends Chart {
26
28
  const palette = new ColorPalette(colors);
27
29
  const textAnchor = this.renderer.centerTextAnchor;
28
30
 
31
+ const pointTotals = isPercent
32
+ ? Array.from({ length: pointCount }, (_, i) => series.reduce((sum, s) => sum + Math.max(0, s.values[i]?.value ?? 0), 0))
33
+ : null;
34
+
29
35
  // Precompute each series' [base, top] band per point — base is 0
30
36
  // unless stacked, in which case it's the running cumulative of prior
31
37
  // series (computed once here, not re-derived during render).
@@ -33,12 +39,15 @@ export class AreaChart extends Chart {
33
39
  const bands = series.map(s => {
34
40
  const band = s.values.map((d, i) => {
35
41
  const base = stacked ? running[i] : 0;
36
- return { base, top: base + d.value };
42
+ const val = isPercent
43
+ ? (pointTotals[i] > 0 ? (Math.max(0, d.value) / pointTotals[i]) * 100 : 0)
44
+ : d.value;
45
+ return { base, top: base + val, pct: val };
37
46
  });
38
47
  if (stacked) running = band.map(b => b.top);
39
48
  return band;
40
49
  });
41
- const maxValue = Math.max(...bands.flat().map(b => b.top)) || 1;
50
+ const maxValue = isPercent ? 100 : (Math.max(...bands.flat().map(b => b.top)) || 1);
42
51
 
43
52
  new ChartLabels({ title, xAxisLabel, yAxisLabel }).draw(this.renderer, plotBox);
44
53
  this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: 'black', width: 1 });
@@ -53,6 +62,14 @@ export class AreaChart extends Chart {
53
62
  const { r, g, b: bl } = palette.hexToRgb(color);
54
63
  this.renderer.path(areaPath, `rgba(${r},${g},${bl},0.35)`, null, { tooltip: s.name });
55
64
  this.renderer.path('M' + topPts.map(p => `${p.px},${p.py}`).join(' L'), 'none', { color, width: 2 });
65
+ if (typeof this.renderer.circle === 'function') {
66
+ topPts.forEach((p, i) => {
67
+ const d = s.values[i];
68
+ this._datum(this.renderer.circle(p.px, p.py, 3, color, { color: '#fff', width: 1 }, {
69
+ tooltip: `${isMultiline ? s.name + ': ' : ''}${d.label}: ${d.value}`,
70
+ }), { label: d.label, series: isMultiline ? s.name : null });
71
+ });
72
+ }
56
73
 
57
74
  if (seriesIndex === 0) {
58
75
  const step = Axis.categoryLabelStep(s.values.map(d => d.label), width, { fontSize: 10 });
@@ -17,7 +17,7 @@ export class BarChart extends BarChartBase {
17
17
  _computeMaxValue() {
18
18
  const data = this.data;
19
19
  const rawMax = Math.max(...data.map(d => d.value)) || 1;
20
- return this._drawYAxis(rawMax);
20
+ return this._drawYAxis(rawMax, Math.min(0, ...data.map(d => d.value)));
21
21
  }
22
22
 
23
23
  _beforeBars(maxValue) {
@@ -39,12 +39,12 @@ export class BarChart extends BarChartBase {
39
39
  }
40
40
 
41
41
  const color = colors ? this._palette.colorAt(i) : barColor;
42
- const barHeight = (d.value / maxValue) * height;
42
+ const bar = this._barSpan(d.value);
43
43
  const barX = x + i * (bandWidth + margin);
44
- const barY = y + height - barHeight;
45
44
 
46
- this._datum(this.renderer.rect(barX, barY, bandWidth, barHeight, color, null, { bar: true, dataIndex: i, tooltip: `${d.label}: ${d.value}` }), { label: d.label });
47
- this.renderer.text(String(d.value), barX + bandWidth / 2, barY - 5, { textAnchor, fontSize: '10px', fill: '#333' });
45
+ this._datum(this.renderer.rect(barX, bar.top, bandWidth, bar.height, color, null, { bar: true, dataIndex: i, tooltip: `${d.label}: ${d.value}` }), { label: d.label });
46
+ // The value just past the bar's end: above a bar going up, below one going down.
47
+ this.renderer.text(String(d.value), barX + bandWidth / 2, d.value < 0 ? bar.end + 12 : bar.end - 5, { textAnchor, fontSize: '10px', fill: '#333' });
48
48
  if (i % labelStep === 0) this.renderer.text(d.label, barX + bandWidth / 2, y + height + 20, { textAnchor });
49
49
  });
50
50
  }
@@ -34,12 +34,12 @@ export class BarChart3D extends Chart {
34
34
  const barBaseY = y + height;
35
35
  const color = palette.colorAt(i);
36
36
 
37
- this.renderer.rect(
37
+ this._datum(this.renderer.rect(
38
38
  barX, barBaseY - barHeight, barWidth, barHeight,
39
39
  color,
40
40
  { color: '#333', width: 1 },
41
41
  { tooltip: `${d.label}: ${d.value}` },
42
- );
42
+ ), { label: d.label });
43
43
 
44
44
  // Top face
45
45
  const topPath = `M${barX},${barBaseY - barHeight} L${barX + barWidth},${barBaseY - barHeight} L${barX + barWidth + depth},${barBaseY - barHeight - depth} L${barX + depth},${barBaseY - barHeight - depth} Z`;
@@ -40,7 +40,9 @@ export class BarChart3DOrbit extends Chart3D {
40
40
  }).sort((a, b) => b.depth - a.depth);
41
41
 
42
42
  for (const bar of bars) {
43
- this.drawBox(this.renderer, bar.x0, bar.x1, 0, bar.y1, bar.z0, bar.z1, bar.color, toScreen);
43
+ this.drawBox(this.renderer, bar.x0, bar.x1, 0, bar.y1, bar.z0, bar.z1, bar.color, toScreen, {
44
+ label: bar.d.x, series: bar.d.z, x: bar.d.x, z: bar.d.z,
45
+ });
44
46
  }
45
47
 
46
48
  new Legend({
@@ -38,9 +38,27 @@ export class BarChartBase extends Chart {
38
38
  this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: 'black', width: 1 });
39
39
  }
40
40
 
41
- /** Draw a Y axis and return the nice-rounded max value. */
42
- _drawYAxis(rawMax) {
43
- return new Axis().drawYAxis(this.renderer, this._plotBox, rawMax);
41
+ /**
42
+ * Draw a Y axis and return the nice-rounded max value. With a negative `rawMin` the axis goes
43
+ * below zero, with a zero line; `_barSpan` then draws each bar from zero, up or down.
44
+ */
45
+ _drawYAxis(rawMax, rawMin = 0) {
46
+ this._domain = new Axis().drawYRange(this.renderer, this._plotBox, rawMin, rawMax);
47
+ return this._domain.max;
48
+ }
49
+
50
+ /**
51
+ * Where a bar of `value` goes on the axis `_drawYAxis` drew: from the zero line up to it, or
52
+ * down to it when negative.
53
+ * @returns {{top: number, height: number, end: number}} `end` is the bar's far end from zero
54
+ */
55
+ _barSpan(value) {
56
+ const { y, height } = this._plotBox;
57
+ const { min, max } = this._domain;
58
+ const yOf = (v) => y + height - ((v - min) / (max - min)) * height;
59
+ const zero = yOf(0);
60
+ const end = yOf(value);
61
+ return { top: Math.min(zero, end), height: Math.abs(end - zero), end };
44
62
  }
45
63
 
46
64
  _render() {
@@ -30,9 +30,9 @@ export class BoxPlotChart extends GroupedDistributionChart {
30
30
  this.renderer.path(`M${cx - boxWidth / 4},${this._toY(s.min)} L${cx + boxWidth / 4},${this._toY(s.min)}`, 'none', { color: '#555', width: 1 });
31
31
  this.renderer.path(`M${cx - boxWidth / 4},${this._toY(s.max)} L${cx + boxWidth / 4},${this._toY(s.max)}`, 'none', { color: '#555', width: 1 });
32
32
  const boxTop = this._toY(s.q3), boxBottom = this._toY(s.q1);
33
- this.renderer.rect(cx - boxWidth / 2, boxTop, boxWidth, Math.max(1, boxBottom - boxTop), boxColor, { color: '#333', width: 1 }, {
33
+ this._datum(this.renderer.rect(cx - boxWidth / 2, boxTop, boxWidth, Math.max(1, boxBottom - boxTop), boxColor, { color: '#333', width: 1 }, {
34
34
  tooltip: `${d.label}: median ${Axis.formatValue(s.median)}, Q1 ${Axis.formatValue(s.q1)}, Q3 ${Axis.formatValue(s.q3)}`,
35
- });
35
+ }), { label: d.label, series: d.label });
36
36
  this.renderer.path(`M${cx - boxWidth / 2},${this._toY(s.median)} L${cx + boxWidth / 2},${this._toY(s.median)}`, 'none', { color: '#fff', width: 2 });
37
37
  s.outliers.forEach(v => {
38
38
  this.renderer.circle(cx, this._toY(v), 3, 'none', { color: boxColor, width: 1.5 }, { tooltip: `${d.label} outlier: ${Axis.formatValue(v)}` });
@@ -83,7 +83,10 @@ export class BubbleChart extends Chart {
83
83
  // 3D effect: draw a line to the "floor"
84
84
  this.renderer.path(`M${floorX},${floorY} L${bubbleX},${bubbleY}`, 'none', { color: '#e0e0e0', width: 1 });
85
85
  }
86
- this.renderer.circle(bubbleX, bubbleY, radius, color, { color: 'rgba(0,0,0,0.2)', width: 1 }, { tooltip: `${d.label}: (${d.x}, ${d.y}, ${d.z})` });
86
+ const series = catField ? ChartData.readColorCol(d, catField) : null;
87
+ this._datum(this.renderer.circle(bubbleX, bubbleY, radius, color, { color: 'rgba(0,0,0,0.2)', width: 1 }, { tooltip: `${d.label}: (${d.x}, ${d.y}, ${d.z})` }), {
88
+ label: d.label ?? String(d.x), series: series != null ? String(series) : null,
89
+ });
87
90
  });
88
91
 
89
92
  // Category color legend
@@ -92,19 +92,19 @@ export class ChordDiagram extends Chart {
92
92
  const c2x = cx + (innerRadius * 0.2) * Math.cos(t.mid);
93
93
  const c2y = cy + (innerRadius * 0.2) * Math.sin(t.mid);
94
94
  const width = Math.max(1, (l.value / maxLink) * maxStroke);
95
- this.renderer.path(`M${sx},${sy} C${c1x},${c1y} ${c2x},${c2y} ${tx},${ty}`, 'none', {
95
+ this._datum(this.renderer.path(`M${sx},${sy} C${c1x},${c1y} ${c2x},${c2y} ${tx},${ty}`, 'none', {
96
96
  color: s.color, width,
97
97
  }, {
98
98
  tooltip: `${s.label} ↔ ${t.label}: ${l.value}`,
99
- });
99
+ }), { label: s.label, series: t.label });
100
100
  });
101
101
 
102
102
  // Draw node arcs.
103
103
  nodes.forEach(n => {
104
104
  const path = this._arcPath(cx, cy, radius, innerRadius, n.start, n.end);
105
- this.renderer.path(path, n.color, { color: '#fff', width: 1 }, {
105
+ this._datum(this.renderer.path(path, n.color, { color: '#fff', width: 1 }, {
106
106
  tooltip: `${n.label}: ${n.value}`,
107
- });
107
+ }), { label: n.label });
108
108
 
109
109
  // Label outside the circle.
110
110
  const lx = cx + (radius + 16) * Math.cos(n.mid);
@@ -58,7 +58,7 @@ export class DensityChart extends Chart {
58
58
  const topPath = 'M' + topPts.map(p => `${p.px},${p.py}`).join(' L');
59
59
  const areaPath = topPath + ` L${x + width},${y + height} L${x},${y + height} Z`;
60
60
  const { r, g, b } = ColorPalette.hexToRgb(color);
61
- this.renderer.path(areaPath, `rgba(${r},${g},${b},0.25)`, null, { tooltip: d.label });
61
+ this._datum(this.renderer.path(areaPath, `rgba(${r},${g},${b},0.25)`, null, { tooltip: d.label }), { label: d.label, series: d.label });
62
62
  this.renderer.path(topPath, 'none', { color, width: 2 });
63
63
  });
64
64
 
@@ -38,7 +38,7 @@ export class FunnelChart extends Chart {
38
38
  const bottomRight = bottomLeft + bottomWidth;
39
39
 
40
40
  const path = `M${topLeft},${top} L${topRight},${top} L${bottomRight},${bottom} L${bottomLeft},${bottom} Z`;
41
- this.renderer.path(path, palette.colorAt(i), { color: '#fff', width: 1 }, { tooltip: `${d.label}: ${d.value}` });
41
+ this._datum(this.renderer.path(path, palette.colorAt(i), { color: '#fff', width: 1 }, { tooltip: `${d.label}: ${d.value}` }), { label: d.label });
42
42
  this.renderer.text(`${d.label} (${d.value})`, x + width / 2, top + stageHeight / 2, {
43
43
  textAnchor, verticalAlign: 'middle', fontSize: '12px', fill: '#fff', fontWeight: 'bold',
44
44
  });
@@ -10,18 +10,17 @@ import { TableChart } from './TableChart.js';
10
10
  * Array<{name: string, values: Array<{label: string, value: number}>}>
11
11
  *
12
12
  * Each category (`label`) gets a cluster of bars side-by-side, one per series. A value a series does
13
- * not have (missing, or null) is a gap in its cluster — no bar — never a bar of zero.
13
+ * not have (missing, or null) is a gap in its cluster — no bar — never a bar of zero. A negative value
14
+ * is drawn down from a zero line, the axis taking in the smallest value too.
14
15
  */
15
16
  export class GroupedBarChart extends BarChartBase {
16
17
  static chartType = 'groupedBar';
17
18
 
18
19
  _computeMaxValue() {
19
20
  const series = this.data;
20
- const rawMax = Math.max(
21
- ...series.flatMap(s => s.values.map(v => v.value).filter(v => v != null)),
22
- 1,
23
- );
24
- return this._drawYAxis(rawMax);
21
+ const values = series.flatMap(s => s.values.map(v => v.value).filter(v => v != null));
22
+ const rawMax = Math.max(...values, 1);
23
+ return this._drawYAxis(rawMax, Math.min(0, ...values));
25
24
  }
26
25
 
27
26
  _renderBars(maxValue) {
@@ -63,12 +62,11 @@ export class GroupedBarChart extends BarChartBase {
63
62
  series.forEach((seriesData, seriesIndex) => {
64
63
  const value = valueBySeriesLabel[seriesIndex].get(label);
65
64
  if (value == null) return;
66
- const barHeight = (value / maxValue) * height;
65
+ const bar = this._barSpan(value);
67
66
  const barX = clusterX + seriesIndex * barWidth + barGap / 2;
68
- const barY = y + height - barHeight;
69
67
  const color = this._palette.colorAt(seriesIndex);
70
68
 
71
- this._datum(this.renderer.rect(barX, barY, finalBarWidth, barHeight, color, null, {
69
+ this._datum(this.renderer.rect(barX, bar.top, finalBarWidth, bar.height, color, null, {
72
70
  tooltip: `${seriesData.name} — ${label}: ${value}`,
73
71
  }), { label, series: seriesData.name });
74
72
  });