@internetstiftelsen/charts 0.9.2 → 0.10.1

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 (46) hide show
  1. package/README.md +137 -3
  2. package/dist/area.d.ts +2 -0
  3. package/dist/area.js +39 -31
  4. package/dist/bar.d.ts +20 -1
  5. package/dist/bar.js +395 -519
  6. package/dist/base-chart.d.ts +21 -1
  7. package/dist/base-chart.js +166 -93
  8. package/dist/chart-group.d.ts +137 -0
  9. package/dist/chart-group.js +1155 -0
  10. package/dist/chart-interface.d.ts +1 -1
  11. package/dist/donut-center-content.d.ts +1 -0
  12. package/dist/donut-center-content.js +21 -38
  13. package/dist/donut-chart.js +30 -15
  14. package/dist/gauge-chart.d.ts +20 -0
  15. package/dist/gauge-chart.js +229 -133
  16. package/dist/legend-state.d.ts +19 -0
  17. package/dist/legend-state.js +81 -0
  18. package/dist/legend.d.ts +5 -2
  19. package/dist/legend.js +45 -38
  20. package/dist/line.js +3 -1
  21. package/dist/pie-chart.d.ts +3 -0
  22. package/dist/pie-chart.js +45 -19
  23. package/dist/scatter.d.ts +16 -0
  24. package/dist/scatter.js +165 -0
  25. package/dist/tooltip.d.ts +2 -1
  26. package/dist/tooltip.js +21 -25
  27. package/dist/types.d.ts +19 -1
  28. package/dist/utils.js +11 -19
  29. package/dist/validation.d.ts +4 -0
  30. package/dist/validation.js +19 -0
  31. package/dist/x-axis.d.ts +10 -0
  32. package/dist/x-axis.js +190 -149
  33. package/dist/xy-chart.d.ts +40 -1
  34. package/dist/xy-chart.js +488 -165
  35. package/dist/y-axis.d.ts +7 -2
  36. package/dist/y-axis.js +99 -10
  37. package/docs/chart-group.md +213 -0
  38. package/docs/components.md +321 -0
  39. package/docs/donut-chart.md +193 -0
  40. package/docs/gauge-chart.md +175 -0
  41. package/docs/getting-started.md +311 -0
  42. package/docs/pie-chart.md +123 -0
  43. package/docs/theming.md +162 -0
  44. package/docs/word-cloud-chart.md +98 -0
  45. package/docs/xy-chart.md +517 -0
  46. package/package.json +6 -4
package/dist/xy-chart.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { max, min, scaleBand, scaleLinear, scaleLog, scaleTime, } from 'd3';
2
2
  import { BaseChart, } from './base-chart.js';
3
- import { ChartValidator } from './validation.js';
3
+ import { ChartValidationError, ChartValidator } from './validation.js';
4
4
  import { GROUPED_GAP_TICK_PREFIX, GROUPED_GROUP_LABEL_KEY, } from './grouped-data.js';
5
5
  import { resolveScaleValue } from './scale-utils.js';
6
+ import { mergeDeep } from './utils.js';
6
7
  const DEFAULT_SERIES_COLOR = '#8884d8';
7
8
  function isXYSeries(component) {
8
9
  return (component.type === 'line' ||
10
+ component.type === 'scatter' ||
9
11
  component.type === 'bar' ||
10
12
  component.type === 'area');
11
13
  }
@@ -48,6 +50,12 @@ export class XYChart extends BaseChart {
48
50
  writable: true,
49
51
  value: void 0
50
52
  });
53
+ Object.defineProperty(this, "scaleConfigOverride", {
54
+ enumerable: true,
55
+ configurable: true,
56
+ writable: true,
57
+ value: null
58
+ });
51
59
  this.orientation = config.orientation ?? 'vertical';
52
60
  this.barStackMode = config.barStack?.mode ?? 'normal';
53
61
  this.barStackGap = config.barStack?.gap ?? 0.1;
@@ -82,7 +90,7 @@ export class XYChart extends BaseChart {
82
90
  width: this.configuredWidth,
83
91
  height: this.configuredHeight,
84
92
  theme: this.theme,
85
- scales: this.scaleConfig,
93
+ scales: this.resolvedScaleConfig,
86
94
  responsive: this.responsiveConfig,
87
95
  orientation: this.orientation,
88
96
  barStack: {
@@ -106,6 +114,7 @@ export class XYChart extends BaseChart {
106
114
  prepareLayout(context) {
107
115
  super.prepareLayout(context);
108
116
  this.xAxis?.clearEstimatedSpace?.();
117
+ this.yAxis?.clearEstimatedSpace?.();
109
118
  if (this.xAxis) {
110
119
  const xKey = this.getXKey();
111
120
  const labelKey = this.xAxis.labelKey;
@@ -117,32 +126,59 @@ export class XYChart extends BaseChart {
117
126
  });
118
127
  this.xAxis.estimateLayoutSpace?.(labels, this.renderTheme, context.svgNode);
119
128
  }
129
+ if (this.yAxis) {
130
+ this.yAxis.estimateLayoutSpace?.(this.getYAxisEstimateLabels(), this.renderTheme, context.svgNode);
131
+ }
120
132
  }
121
- renderChart({ svg, plotGroup, plotArea, }) {
122
- this.validateSeriesOrientation();
123
- this.series.forEach((series) => {
124
- const typeName = series.type === 'line'
125
- ? 'Line'
126
- : series.type === 'bar'
127
- ? 'Bar'
128
- : 'Area';
129
- ChartValidator.validateDataKey(this.data, series.dataKey, typeName);
130
- ChartValidator.validateNumericData(this.data, series.dataKey, typeName);
131
- });
132
- const valueScaleType = this.scaleConfig.y?.type ?? 'linear';
133
- if (valueScaleType === 'log') {
134
- this.series.forEach((series) => {
135
- const typeName = series.type === 'line'
136
- ? 'Line'
137
- : series.type === 'bar'
138
- ? 'Bar'
139
- : 'Area';
140
- ChartValidator.validatePositiveData(this.data, series.dataKey, typeName);
133
+ getYAxisEstimateLabels() {
134
+ if (!this.yAxis) {
135
+ return [];
136
+ }
137
+ const yAxisConfig = this.yAxis.getExportConfig();
138
+ const { y: yConfig } = this.getResolvedAxisConfigs();
139
+ const tickFormat = yAxisConfig.tickFormat;
140
+ if (yConfig.type === 'band') {
141
+ const tickValues = this.resolveScaleDomain(yConfig, this.isHorizontalOrientation() ? this.getXKey() : null);
142
+ return tickValues.map((value) => {
143
+ if (typeof tickFormat === 'function') {
144
+ return tickFormat(value);
145
+ }
146
+ return String(value);
141
147
  });
142
148
  }
143
- if (this.xAxis?.dataKey) {
144
- ChartValidator.validateDataKey(this.data, this.xAxis.dataKey, 'XAxis');
149
+ const scale = this.createContinuousScaleForLayoutEstimate(yConfig);
150
+ const tickValues = scale.ticks(5);
151
+ if (typeof tickFormat === 'function') {
152
+ return tickValues.map((value) => tickFormat(value));
145
153
  }
154
+ const tickFormatter = scale.tickFormat(5, typeof tickFormat === 'string' ? tickFormat : undefined);
155
+ return tickValues.map((value) => tickFormatter(value));
156
+ }
157
+ createContinuousScaleForLayoutEstimate(config) {
158
+ const domain = this.resolveScaleDomain(config, null);
159
+ switch (config.type) {
160
+ case 'linear': {
161
+ const scale = scaleLinear()
162
+ .domain(domain)
163
+ .rangeRound([100, 0]);
164
+ return config.nice === false ? scale : scale.nice();
165
+ }
166
+ case 'time': {
167
+ const scale = scaleTime()
168
+ .domain(domain)
169
+ .range([100, 0]);
170
+ return config.nice === false ? scale : scale.nice();
171
+ }
172
+ case 'log': {
173
+ const scale = scaleLog()
174
+ .domain(domain)
175
+ .rangeRound([100, 0]);
176
+ return config.nice === false ? scale : scale.nice();
177
+ }
178
+ }
179
+ }
180
+ renderChart({ svg, plotGroup, plotArea, }) {
181
+ this.validateRenderState();
146
182
  const xKey = this.getXKey();
147
183
  const categoryScaleType = this.getCategoryScaleType();
148
184
  const visibleSeries = this.getVisibleSeries();
@@ -152,20 +188,8 @@ export class XYChart extends BaseChart {
152
188
  this.grid.render(plotGroup, this.x, this.y, this.renderTheme, this.isHorizontalOrientation());
153
189
  }
154
190
  this.renderSeries(visibleSeries);
155
- if (this.x && this.y) {
156
- if (this.xAxis) {
157
- this.xAxis.render(svg, this.x, this.renderTheme, plotArea.bottom, this.data);
158
- }
159
- if (this.yAxis) {
160
- this.yAxis.render(svg, this.y, this.renderTheme, plotArea.left);
161
- }
162
- }
163
- if (this.tooltip && this.x && this.y) {
164
- const visibleAreaSeries = visibleSeries.filter((series) => series.type === 'area');
165
- const areaStackingContextBySeries = this.computeAreaStackingContexts(this.data, xKey, visibleAreaSeries);
166
- this.tooltip.initialize(this.renderTheme);
167
- this.tooltip.attachToArea(svg, this.data, visibleSeries, xKey, this.x, this.y, this.renderTheme, plotArea, this.parseValue.bind(this), this.isHorizontalOrientation(), categoryScaleType, (series, dataPoint) => this.getSeriesTooltipValue(series, dataPoint, xKey, areaStackingContextBySeries));
168
- }
191
+ this.renderAxes(svg, plotArea);
192
+ this.attachTooltip(svg, plotArea, visibleSeries, xKey, categoryScaleType);
169
193
  this.renderInlineLegend(svg);
170
194
  }
171
195
  getXKey() {
@@ -177,7 +201,7 @@ export class XYChart extends BaseChart {
177
201
  getLegendSeries() {
178
202
  const displaySeries = this.getDisplaySeries();
179
203
  return displaySeries.map((series) => {
180
- if (series.type === 'line') {
204
+ if (series.type === 'line' || series.type === 'scatter') {
181
205
  return {
182
206
  dataKey: series.dataKey,
183
207
  stroke: series.stroke,
@@ -190,7 +214,45 @@ export class XYChart extends BaseChart {
190
214
  });
191
215
  }
192
216
  getCategoryScaleType() {
193
- return this.scaleConfig.x?.type || 'band';
217
+ return this.resolvedScaleConfig.x?.type || 'band';
218
+ }
219
+ getOrientation() {
220
+ return this.orientation;
221
+ }
222
+ getValueAxisScaleType() {
223
+ if (this.orientation !== 'vertical') {
224
+ return null;
225
+ }
226
+ return this.resolvedScaleConfig.y?.type ?? 'linear';
227
+ }
228
+ getValueAxisDomain() {
229
+ return this.resolveValueAxisDomain(this.resolvedScaleConfig);
230
+ }
231
+ getBaseValueAxisDomain() {
232
+ return this.resolveValueAxisDomain(this.scaleConfig);
233
+ }
234
+ setScaleConfigOverride(override, rerender = true) {
235
+ this.scaleConfigOverride = override;
236
+ if (rerender) {
237
+ this.rerender();
238
+ }
239
+ return this;
240
+ }
241
+ resolveValueAxisDomain(scaleConfig) {
242
+ if (this.orientation !== 'vertical') {
243
+ return null;
244
+ }
245
+ const yConfig = this.getAxisConfigsForScaleConfig(scaleConfig).y;
246
+ if (yConfig.type !== 'linear' && yConfig.type !== 'log') {
247
+ return null;
248
+ }
249
+ const domain = this.resolveScaleDomain(yConfig, null);
250
+ if (domain.length < 2 ||
251
+ typeof domain[0] !== 'number' ||
252
+ typeof domain[1] !== 'number') {
253
+ return null;
254
+ }
255
+ return [domain[0], domain[1]];
194
256
  }
195
257
  getVisibleSeries() {
196
258
  return this.filterVisibleItems(this.getDisplaySeries(), (series) => {
@@ -228,6 +290,13 @@ export class XYChart extends BaseChart {
228
290
  : series.stroke,
229
291
  });
230
292
  }
293
+ if (series.type === 'scatter') {
294
+ return this.cloneSeriesWithOverride(series, {
295
+ stroke: this.shouldReplaceSeriesColor(series.stroke)
296
+ ? paletteColor
297
+ : series.stroke,
298
+ });
299
+ }
231
300
  if (series.type === 'bar') {
232
301
  return this.cloneSeriesWithOverride(series, {
233
302
  fill: this.shouldReplaceSeriesColor(series.fill)
@@ -253,23 +322,59 @@ export class XYChart extends BaseChart {
253
322
  }
254
323
  setupScales() {
255
324
  const xKey = this.getXKey();
256
- const isHorizontal = this.orientation === 'horizontal';
257
- let xConfig;
258
- let yConfig;
259
- if (isHorizontal) {
260
- xConfig = this.scaleConfig.y || { type: 'linear' };
261
- yConfig = this.scaleConfig.x || { type: 'band' };
262
- }
263
- else {
264
- xConfig = this.scaleConfig.x || { type: 'band' };
265
- yConfig = this.scaleConfig.y || { type: 'linear' };
266
- }
325
+ const isHorizontal = this.isHorizontalOrientation();
326
+ const { x: xConfig, y: yConfig } = this.getResolvedAxisConfigs();
267
327
  this.x = this.createScale(xConfig, isHorizontal ? null : xKey, 'x');
268
328
  this.y = this.createScale(yConfig, isHorizontal ? xKey : null, 'y');
269
329
  }
330
+ get resolvedScaleConfig() {
331
+ if (!this.scaleConfigOverride) {
332
+ return this.scaleConfig;
333
+ }
334
+ return mergeDeep(this.scaleConfig, this.scaleConfigOverride);
335
+ }
336
+ getResolvedAxisConfigs() {
337
+ return this.getAxisConfigsForScaleConfig(this.resolvedScaleConfig);
338
+ }
339
+ getAxisConfigsForScaleConfig(scaleConfig) {
340
+ if (this.isHorizontalOrientation()) {
341
+ return {
342
+ x: {
343
+ type: 'linear',
344
+ ...(scaleConfig.y ?? {}),
345
+ },
346
+ y: {
347
+ type: 'band',
348
+ ...(scaleConfig.x ?? {}),
349
+ },
350
+ };
351
+ }
352
+ return {
353
+ x: {
354
+ type: 'band',
355
+ ...(scaleConfig.x ?? {}),
356
+ },
357
+ y: {
358
+ type: 'linear',
359
+ ...(scaleConfig.y ?? {}),
360
+ },
361
+ };
362
+ }
270
363
  isHorizontalOrientation() {
271
364
  return this.orientation === 'horizontal';
272
365
  }
366
+ getSeriesTypeName(series) {
367
+ switch (series.type) {
368
+ case 'line':
369
+ return 'Line';
370
+ case 'scatter':
371
+ return 'Scatter';
372
+ case 'bar':
373
+ return 'Bar';
374
+ case 'area':
375
+ return 'Area';
376
+ }
377
+ }
273
378
  validateSeriesOrientation() {
274
379
  if (this.orientation !== 'horizontal') {
275
380
  return;
@@ -277,10 +382,13 @@ export class XYChart extends BaseChart {
277
382
  const hasLineSeries = this.series.some((series) => {
278
383
  return series.type === 'line';
279
384
  });
385
+ const hasScatterSeries = this.series.some((series) => {
386
+ return series.type === 'scatter';
387
+ });
280
388
  const hasAreaSeries = this.series.some((series) => {
281
389
  return series.type === 'area';
282
390
  });
283
- if (hasLineSeries || hasAreaSeries) {
391
+ if (hasLineSeries || hasScatterSeries || hasAreaSeries) {
284
392
  throw new Error('XYChart: horizontal orientation currently supports Bar series only');
285
393
  }
286
394
  }
@@ -296,6 +404,35 @@ export class XYChart extends BaseChart {
296
404
  });
297
405
  return values;
298
406
  }
407
+ getBarPercentDomain(positiveTotalData, negativeTotalData) {
408
+ const hasPositive = Array.from(positiveTotalData.values()).some((value) => value > 0);
409
+ const hasNegative = Array.from(negativeTotalData.values()).some((value) => value > 0);
410
+ if (hasPositive && hasNegative) {
411
+ return [-100, 100];
412
+ }
413
+ if (hasNegative) {
414
+ return [-100, 0];
415
+ }
416
+ return [0, 100];
417
+ }
418
+ getBarValueDomain(xKey, barSeries) {
419
+ if (barSeries.length === 0) {
420
+ return [0, 0];
421
+ }
422
+ const { positiveTotalData, negativeTotalData, rawValuesBySeriesIndex } = this.computeStackingData(this.data, xKey, barSeries);
423
+ if (this.barStackMode === 'percent') {
424
+ return this.getBarPercentDomain(positiveTotalData, negativeTotalData);
425
+ }
426
+ if (this.barStackMode === 'normal') {
427
+ const maxPositiveTotal = max(Array.from(positiveTotalData.values())) ?? 0;
428
+ const maxNegativeTotal = max(Array.from(negativeTotalData.values())) ?? 0;
429
+ return [-maxNegativeTotal, maxPositiveTotal];
430
+ }
431
+ const rawValues = Array.from(rawValuesBySeriesIndex.values()).flatMap((seriesValues) => Array.from(seriesValues.values()));
432
+ const minValue = min(rawValues) ?? 0;
433
+ const maxValue = max(rawValues) ?? 0;
434
+ return [Math.min(minValue, 0), Math.max(maxValue, 0)];
435
+ }
299
436
  getStackedAreaGroups(areaSeries) {
300
437
  const groups = new Map();
301
438
  if (this.areaStackMode === 'none') {
@@ -346,149 +483,292 @@ export class XYChart extends BaseChart {
346
483
  });
347
484
  return domain;
348
485
  }
349
- createScale(config, dataKey, axis) {
486
+ getScaleRange(axis, scaleType, reverse) {
350
487
  if (!this.plotArea) {
351
488
  throw new Error('Plot area not calculated');
352
489
  }
353
- const scaleType = config.type || (axis === 'x' ? 'band' : 'linear');
354
- const isXAxis = axis === 'x';
355
490
  const plotPadding = 10;
356
- const rangeStart = isXAxis
357
- ? this.plotArea.left + plotPadding
358
- : this.plotArea.bottom - plotPadding;
359
- const rangeEnd = isXAxis ? this.plotArea.right : this.plotArea.top;
360
- let domain;
361
- if (config.domain) {
362
- domain = config.domain;
491
+ let rangeStart;
492
+ let rangeEnd;
493
+ if (axis === 'x') {
494
+ rangeStart = this.plotArea.left + plotPadding;
495
+ rangeEnd = this.plotArea.right;
363
496
  }
364
- else if (scaleType === 'band' && dataKey) {
365
- domain = this.buildBandDomainWithGroupGaps(dataKey, config.groupGap ?? 0);
497
+ else if (scaleType === 'band') {
498
+ rangeStart = this.plotArea.top;
499
+ rangeEnd = this.plotArea.bottom - plotPadding;
366
500
  }
367
- else if (scaleType === 'time' && dataKey) {
368
- domain = [
369
- min(this.data, (d) => resolveScaleValue(d[dataKey], 'time')),
370
- max(this.data, (d) => resolveScaleValue(d[dataKey], 'time')),
371
- ];
501
+ else {
502
+ rangeStart = this.plotArea.bottom - plotPadding;
503
+ rangeEnd = this.plotArea.top;
372
504
  }
373
- else if ((scaleType === 'linear' || scaleType === 'log') && dataKey) {
374
- const values = this.data
375
- .map((d) => this.parseValue(d[dataKey]))
376
- .filter((value) => Number.isFinite(value));
377
- const minVal = config.min ?? min(values) ?? 0;
378
- const maxVal = config.max ?? max(values) ?? 100;
379
- domain = [minVal, maxVal];
505
+ return reverse ? [rangeEnd, rangeStart] : [rangeStart, rangeEnd];
506
+ }
507
+ createScale(config, dataKey, axis) {
508
+ if (!this.plotArea) {
509
+ throw new Error('Plot area not calculated');
380
510
  }
381
- else {
382
- const barSeries = this.series.filter((s) => s.type === 'bar');
383
- const lineSeries = this.series.filter((s) => s.type === 'line');
384
- const areaSeries = this.series.filter((s) => s.type === 'area');
385
- const stackedAreaGroups = this.getStackedAreaGroups(areaSeries);
386
- if ((this.barStackMode === 'percent' && barSeries.length > 0) ||
387
- (this.areaStackMode === 'percent' && stackedAreaGroups.size > 0)) {
388
- domain = [0, 100];
389
- }
390
- else {
391
- const values = this.collectSeriesValues([
392
- ...lineSeries,
393
- ...barSeries,
394
- ...areaSeries,
395
- ]);
396
- const stackedValues = [];
397
- const minCandidates = [...values];
398
- if (this.barStackMode === 'normal' && barSeries.length > 1) {
399
- this.data.forEach((dataPoint) => {
400
- const total = barSeries.reduce((sum, series) => {
401
- const value = this.parseValue(dataPoint[series.dataKey]);
402
- return Number.isFinite(value) ? sum + value : sum;
403
- }, 0);
404
- stackedValues.push(total);
405
- });
406
- minCandidates.push(0);
407
- }
408
- const stackedAreaKeys = new Set();
409
- stackedAreaGroups.forEach((stackSeries) => {
410
- stackSeries.forEach((series) => {
411
- stackedAreaKeys.add(series.dataKey);
412
- });
413
- this.data.forEach((dataPoint) => {
414
- const total = stackSeries.reduce((sum, series) => {
415
- const value = this.parseValue(dataPoint[series.dataKey]);
416
- return Number.isFinite(value) ? sum + value : sum;
417
- }, 0);
418
- stackedValues.push(total);
419
- });
420
- minCandidates.push(0);
421
- });
422
- areaSeries
423
- .filter((series) => !stackedAreaKeys.has(series.dataKey))
424
- .forEach((series) => {
425
- minCandidates.push(series.baseline);
426
- });
427
- const minVal = config.min ?? min(minCandidates) ?? 0;
428
- const maxVal = config.max ?? max([...values, ...stackedValues]) ?? 100;
429
- domain = [minVal, maxVal];
430
- }
511
+ const scaleType = config.type;
512
+ const [rangeStart, rangeEnd] = this.getScaleRange(axis, scaleType, config.reverse ?? false);
513
+ const domain = this.resolveScaleDomain(config, dataKey);
514
+ this.validateExplicitBarValueDomain(config, dataKey, scaleType, domain);
515
+ this.validateScaleDomain(scaleType, domain);
516
+ return this.buildScale(scaleType, domain, [rangeStart, rangeEnd], config);
517
+ }
518
+ resolveScaleDomain(config, dataKey) {
519
+ const buckets = this.getSeriesBuckets();
520
+ const stackedAreaGroups = this.getStackedAreaGroups(buckets.areaSeries);
521
+ if (this.isBarValueScale(config.type, dataKey, buckets.barSeries) &&
522
+ config.type === 'log') {
523
+ throw new ChartValidationError('XYChart: bar series require a linear value axis because bars need a zero baseline');
524
+ }
525
+ const domain = this.resolveRawScaleDomain(config, dataKey, buckets, stackedAreaGroups);
526
+ return this.applyNiceDomain(config, domain);
527
+ }
528
+ getSeriesTooltipValue(series, dataPoint, xKey, areaStackingContextBySeries) {
529
+ const rawValue = dataPoint[series.dataKey];
530
+ if (rawValue === null || rawValue === undefined) {
531
+ return NaN;
532
+ }
533
+ const parsedValue = this.parseValue(rawValue);
534
+ if (!Number.isFinite(parsedValue)) {
535
+ return NaN;
536
+ }
537
+ if (series.type === 'bar') {
538
+ return series.getRenderedValue(parsedValue, this.orientation);
539
+ }
540
+ if (series.type !== 'area') {
541
+ return parsedValue;
542
+ }
543
+ return this.getAreaTooltipValue(dataPoint, xKey, parsedValue, areaStackingContextBySeries.get(series));
544
+ }
545
+ validateRenderState() {
546
+ this.validateSeriesOrientation();
547
+ this.validateSeriesData();
548
+ this.validateValueScaleRequirements();
549
+ this.validateXAxisDataKey();
550
+ }
551
+ validateSeriesData() {
552
+ this.series.forEach((series) => {
553
+ const typeName = this.getSeriesTypeName(series);
554
+ ChartValidator.validateDataKey(this.data, series.dataKey, typeName);
555
+ ChartValidator.validateNumericData(this.data, series.dataKey, typeName);
556
+ });
557
+ }
558
+ validateValueScaleRequirements() {
559
+ if ((this.resolvedScaleConfig.y?.type ?? 'linear') !== 'log') {
560
+ return;
561
+ }
562
+ this.series.forEach((series) => {
563
+ ChartValidator.validatePositiveData(this.data, series.dataKey, this.getSeriesTypeName(series));
564
+ });
565
+ }
566
+ validateXAxisDataKey() {
567
+ if (this.xAxis?.dataKey) {
568
+ ChartValidator.validateDataKey(this.data, this.xAxis.dataKey, 'XAxis');
431
569
  }
570
+ }
571
+ renderAxes(svg, plotArea) {
572
+ if (!this.x || !this.y) {
573
+ return;
574
+ }
575
+ if (this.xAxis) {
576
+ this.xAxis.render(svg, this.x, this.renderTheme, plotArea.bottom, this.data);
577
+ }
578
+ if (this.yAxis) {
579
+ this.yAxis.render(svg, this.y, this.renderTheme, plotArea.left);
580
+ }
581
+ }
582
+ attachTooltip(svg, plotArea, visibleSeries, xKey, categoryScaleType) {
583
+ if (!this.tooltip || !this.x || !this.y) {
584
+ return;
585
+ }
586
+ const visibleAreaSeries = visibleSeries.filter((series) => series.type === 'area');
587
+ const areaStackingContextBySeries = this.computeAreaStackingContexts(this.data, xKey, visibleAreaSeries);
588
+ this.tooltip.initialize(this.renderTheme);
589
+ this.tooltip.attachToArea(svg, this.data, visibleSeries, xKey, this.x, this.y, this.renderTheme, plotArea, this.parseValue.bind(this), this.isHorizontalOrientation(), categoryScaleType, (series, dataPoint) => this.getSeriesTooltipValue(series, dataPoint, xKey, areaStackingContextBySeries));
590
+ }
591
+ validateExplicitBarValueDomain(config, dataKey, scaleType, domain) {
592
+ if (dataKey === null &&
593
+ this.series.some((series) => series.type === 'bar') &&
594
+ (scaleType === 'linear' || scaleType === 'log') &&
595
+ (config.domain !== undefined ||
596
+ config.min !== undefined ||
597
+ config.max !== undefined)) {
598
+ ChartValidator.validateBarDomainIncludesZero(domain);
599
+ }
600
+ }
601
+ validateScaleDomain(scaleType, domain) {
432
602
  if (scaleType === 'log') {
433
603
  ChartValidator.validateScaleConfig(scaleType, domain);
434
604
  }
605
+ }
606
+ buildScale(scaleType, domain, range, config) {
435
607
  switch (scaleType) {
436
- case 'band': {
437
- const scale = scaleBand()
608
+ case 'band':
609
+ return scaleBand()
438
610
  .domain(domain)
439
- .rangeRound([rangeStart, rangeEnd]);
440
- if (config.padding !== undefined) {
441
- scale.paddingInner(config.padding);
442
- }
443
- else {
444
- scale.paddingInner(0.1);
445
- }
446
- return scale;
447
- }
611
+ .rangeRound(range)
612
+ .paddingInner(config.padding ?? 0.1);
448
613
  case 'linear': {
449
614
  const scale = scaleLinear()
450
615
  .domain(domain)
451
- .rangeRound([rangeStart, rangeEnd]);
452
- if (config.nice !== false) {
453
- scale.nice();
454
- }
455
- return scale;
616
+ .rangeRound(range);
617
+ return config.nice === false ? scale : scale.nice();
456
618
  }
457
619
  case 'time': {
458
620
  const scale = scaleTime()
459
621
  .domain(domain)
460
- .range([rangeStart, rangeEnd]);
461
- if (config.nice !== false) {
462
- scale.nice();
463
- }
464
- return scale;
622
+ .range(range);
623
+ return config.nice === false ? scale : scale.nice();
465
624
  }
466
625
  case 'log': {
467
626
  const scale = scaleLog()
468
627
  .domain(domain)
469
- .rangeRound([rangeStart, rangeEnd]);
470
- if (config.nice !== false) {
471
- scale.nice();
472
- }
473
- return scale;
628
+ .rangeRound(range);
629
+ return config.nice === false ? scale : scale.nice();
474
630
  }
475
631
  default:
476
632
  throw new Error(`Unsupported scale type: ${scaleType}`);
477
633
  }
478
634
  }
479
- getSeriesTooltipValue(series, dataPoint, xKey, areaStackingContextBySeries) {
480
- const rawValue = dataPoint[series.dataKey];
481
- if (rawValue === null || rawValue === undefined) {
482
- return NaN;
635
+ getSeriesBuckets() {
636
+ return {
637
+ barSeries: this.series.filter((s) => s.type === 'bar'),
638
+ lineSeries: this.series.filter((s) => s.type === 'line'),
639
+ scatterSeries: this.series.filter((s) => s.type === 'scatter'),
640
+ areaSeries: this.series.filter((s) => s.type === 'area'),
641
+ };
642
+ }
643
+ isBarValueScale(scaleType, dataKey, barSeries) {
644
+ return (dataKey === null &&
645
+ barSeries.length > 0 &&
646
+ (scaleType === 'linear' || scaleType === 'log'));
647
+ }
648
+ resolveRawScaleDomain(config, dataKey, buckets, stackedAreaGroups) {
649
+ if (config.domain) {
650
+ return config.domain;
483
651
  }
484
- const parsedValue = this.parseValue(rawValue);
485
- if (!Number.isFinite(parsedValue)) {
486
- return NaN;
652
+ if (config.type === 'band' && dataKey) {
653
+ return this.buildBandDomainWithGroupGaps(dataKey, config.groupGap ?? 0);
487
654
  }
488
- if (series.type !== 'area') {
489
- return parsedValue;
655
+ if (config.type === 'time' && dataKey) {
656
+ return this.resolveTimeDomain(dataKey);
657
+ }
658
+ if ((config.type === 'linear' || config.type === 'log') && dataKey) {
659
+ return this.resolveNumericDataKeyDomain(config, dataKey);
660
+ }
661
+ return this.resolveSeriesValueDomain(config, buckets, stackedAreaGroups);
662
+ }
663
+ resolveTimeDomain(dataKey) {
664
+ return [
665
+ min(this.data, (d) => resolveScaleValue(d[dataKey], 'time')),
666
+ max(this.data, (d) => resolveScaleValue(d[dataKey], 'time')),
667
+ ];
668
+ }
669
+ resolveNumericDataKeyDomain(config, dataKey) {
670
+ const values = this.data
671
+ .map((d) => this.parseValue(d[dataKey]))
672
+ .filter((value) => Number.isFinite(value));
673
+ return [
674
+ config.min ?? min(values) ?? 0,
675
+ config.max ?? max(values) ?? 100,
676
+ ];
677
+ }
678
+ resolveSeriesValueDomain(config, buckets, stackedAreaGroups) {
679
+ if (this.hasPercentValueDomain(buckets.barSeries, stackedAreaGroups)) {
680
+ return this.resolvePercentValueDomain(buckets.barSeries, stackedAreaGroups);
681
+ }
682
+ return this.resolveStandardValueDomain(config, buckets, stackedAreaGroups);
683
+ }
684
+ hasPercentValueDomain(barSeries, stackedAreaGroups) {
685
+ return ((this.barStackMode === 'percent' && barSeries.length > 0) ||
686
+ (this.areaStackMode === 'percent' && stackedAreaGroups.size > 0));
687
+ }
688
+ resolvePercentValueDomain(barSeries, stackedAreaGroups) {
689
+ let minDomain = 0;
690
+ let maxDomain = 100;
691
+ if (this.barStackMode === 'percent' && barSeries.length > 0) {
692
+ [minDomain, maxDomain] = this.getBarValueDomain(this.getXKey(), barSeries);
693
+ }
694
+ if (this.areaStackMode === 'percent' && stackedAreaGroups.size > 0) {
695
+ minDomain = Math.min(minDomain, 0);
696
+ maxDomain = Math.max(maxDomain, 100);
697
+ }
698
+ return [minDomain, maxDomain];
699
+ }
700
+ resolveStandardValueDomain(config, buckets, stackedAreaGroups) {
701
+ const values = this.collectSeriesValues([
702
+ ...buckets.lineSeries,
703
+ ...buckets.scatterSeries,
704
+ ...buckets.areaSeries,
705
+ ]);
706
+ const minCandidates = [...values];
707
+ const maxCandidates = [...values];
708
+ const stackedValues = this.collectStackedAreaTotals(stackedAreaGroups);
709
+ if (buckets.barSeries.length > 0) {
710
+ const [barMin, barMax] = this.getBarValueDomain(this.getXKey(), buckets.barSeries);
711
+ minCandidates.push(barMin);
712
+ maxCandidates.push(barMax);
713
+ }
714
+ this.addAreaBaselines(minCandidates, buckets.areaSeries, stackedAreaGroups);
715
+ return [
716
+ config.min ?? min(minCandidates) ?? 0,
717
+ config.max ?? max([...maxCandidates, ...stackedValues]) ?? 100,
718
+ ];
719
+ }
720
+ collectStackedAreaTotals(stackedAreaGroups) {
721
+ const stackedValues = [];
722
+ stackedAreaGroups.forEach((stackSeries) => {
723
+ this.data.forEach((dataPoint) => {
724
+ const total = stackSeries.reduce((sum, series) => {
725
+ const value = this.parseValue(dataPoint[series.dataKey]);
726
+ return Number.isFinite(value) ? sum + value : sum;
727
+ }, 0);
728
+ stackedValues.push(total);
729
+ });
730
+ });
731
+ return stackedValues;
732
+ }
733
+ addAreaBaselines(minCandidates, areaSeries, stackedAreaGroups) {
734
+ const stackedAreaKeys = new Set();
735
+ stackedAreaGroups.forEach((stackSeries) => {
736
+ stackSeries.forEach((series) => {
737
+ stackedAreaKeys.add(series.dataKey);
738
+ });
739
+ minCandidates.push(0);
740
+ });
741
+ areaSeries
742
+ .filter((series) => !stackedAreaKeys.has(series.dataKey))
743
+ .forEach((series) => {
744
+ minCandidates.push(series.baseline);
745
+ });
746
+ }
747
+ applyNiceDomain(config, domain) {
748
+ if (config.nice === false) {
749
+ return domain;
750
+ }
751
+ if (config.type === 'linear') {
752
+ return scaleLinear()
753
+ .domain(domain)
754
+ .nice()
755
+ .domain();
756
+ }
757
+ if (config.type === 'time') {
758
+ return scaleTime()
759
+ .domain(domain)
760
+ .nice()
761
+ .domain();
762
+ }
763
+ if (config.type === 'log') {
764
+ return scaleLog()
765
+ .domain(domain)
766
+ .nice()
767
+ .domain();
490
768
  }
491
- const stackingContext = areaStackingContextBySeries.get(series);
769
+ return domain;
770
+ }
771
+ getAreaTooltipValue(dataPoint, xKey, parsedValue, stackingContext) {
492
772
  if (!stackingContext || stackingContext.mode === 'none') {
493
773
  return parsedValue;
494
774
  }
@@ -512,12 +792,13 @@ export class XYChart extends BaseChart {
512
792
  const barSeries = visibleSeries.filter((s) => s.type === 'bar');
513
793
  const areaSeries = visibleSeries.filter((s) => s.type === 'area');
514
794
  const lineSeries = visibleSeries.filter((s) => s.type === 'line');
795
+ const scatterSeries = visibleSeries.filter((s) => s.type === 'scatter');
515
796
  const areaValueLabelLayer = areaSeries.length > 0
516
797
  ? this.plotGroup
517
798
  .append('g')
518
799
  .attr('class', 'area-value-label-layer')
519
800
  : null;
520
- const { cumulativeDataBySeriesIndex, totalData, rawValuesBySeriesIndex, } = this.computeStackingData(this.data, xKey, barSeries);
801
+ const { cumulativeDataBySeriesIndex, positiveCumulativeDataBySeriesIndex, negativeCumulativeDataBySeriesIndex, totalData, positiveTotalData, negativeTotalData, rawValuesBySeriesIndex, } = this.computeStackingData(this.data, xKey, barSeries);
521
802
  const areaStackingContextBySeries = this.computeAreaStackingContexts(this.data, xKey, areaSeries);
522
803
  barSeries.forEach((series, barIndex) => {
523
804
  const nextLayerData = this.barStackMode === 'layer'
@@ -529,6 +810,12 @@ export class XYChart extends BaseChart {
529
810
  totalSeries: barSeries.length,
530
811
  cumulativeData: cumulativeDataBySeriesIndex.get(barIndex) ?? new Map(),
531
812
  totalData,
813
+ positiveCumulativeData: positiveCumulativeDataBySeriesIndex.get(barIndex) ??
814
+ new Map(),
815
+ negativeCumulativeData: negativeCumulativeDataBySeriesIndex.get(barIndex) ??
816
+ new Map(),
817
+ positiveTotalData,
818
+ negativeTotalData,
532
819
  gap: this.barStackGap,
533
820
  nextLayerData,
534
821
  };
@@ -540,21 +827,37 @@ export class XYChart extends BaseChart {
540
827
  lineSeries.forEach((series) => {
541
828
  series.render(this.plotGroup, this.data, xKey, this.x, this.y, this.parseValue, categoryScaleType, this.renderTheme);
542
829
  });
830
+ scatterSeries.forEach((series) => {
831
+ series.render(this.plotGroup, this.data, xKey, this.x, this.y, this.parseValue, categoryScaleType, this.renderTheme);
832
+ });
543
833
  if (areaValueLabelLayer) {
544
834
  areaValueLabelLayer.raise();
545
835
  }
546
836
  }
547
837
  computeStackingData(data, xKey, barSeries) {
548
838
  const cumulativeDataBySeriesIndex = new Map();
839
+ const positiveCumulativeDataBySeriesIndex = new Map();
840
+ const negativeCumulativeDataBySeriesIndex = new Map();
549
841
  const rawValuesBySeriesIndex = new Map();
550
842
  const totalData = new Map();
843
+ const positiveTotalData = new Map();
844
+ const negativeTotalData = new Map();
551
845
  data.forEach((dataPoint) => {
552
846
  const categoryKey = String(dataPoint[xKey]);
553
847
  let total = 0;
848
+ let positiveTotal = 0;
849
+ let negativeTotal = 0;
554
850
  barSeries.forEach((series, seriesIndex) => {
555
- const value = this.parseValue(dataPoint[series.dataKey]);
851
+ const rawValue = this.parseValue(dataPoint[series.dataKey]);
852
+ const value = series.getRenderedValue(rawValue, this.orientation);
556
853
  if (Number.isFinite(value)) {
557
854
  total += value;
855
+ if (value > 0) {
856
+ positiveTotal += value;
857
+ }
858
+ else if (value < 0) {
859
+ negativeTotal += Math.abs(value);
860
+ }
558
861
  }
559
862
  // Build per-series raw value maps (used for layer next-layer data)
560
863
  let rawMap = rawValuesBySeriesIndex.get(seriesIndex);
@@ -567,25 +870,45 @@ export class XYChart extends BaseChart {
567
870
  }
568
871
  });
569
872
  totalData.set(categoryKey, total);
873
+ positiveTotalData.set(categoryKey, positiveTotal);
874
+ negativeTotalData.set(categoryKey, negativeTotal);
570
875
  });
571
876
  barSeries.forEach((_, seriesIndex) => {
572
877
  const cumulativeForSeries = new Map();
878
+ const positiveCumulativeForSeries = new Map();
879
+ const negativeCumulativeForSeries = new Map();
573
880
  data.forEach((dataPoint) => {
574
881
  const categoryKey = String(dataPoint[xKey]);
575
882
  let cumulative = 0;
883
+ let positiveCumulative = 0;
884
+ let negativeCumulative = 0;
576
885
  for (let i = 0; i < seriesIndex; i++) {
577
- const value = this.parseValue(dataPoint[barSeries[i].dataKey]);
886
+ const value = barSeries[i].getRenderedValue(this.parseValue(dataPoint[barSeries[i].dataKey]), this.orientation);
578
887
  if (Number.isFinite(value)) {
579
888
  cumulative += value;
889
+ if (value > 0) {
890
+ positiveCumulative += value;
891
+ }
892
+ else if (value < 0) {
893
+ negativeCumulative += Math.abs(value);
894
+ }
580
895
  }
581
896
  }
582
897
  cumulativeForSeries.set(categoryKey, cumulative);
898
+ positiveCumulativeForSeries.set(categoryKey, positiveCumulative);
899
+ negativeCumulativeForSeries.set(categoryKey, negativeCumulative);
583
900
  });
584
901
  cumulativeDataBySeriesIndex.set(seriesIndex, cumulativeForSeries);
902
+ positiveCumulativeDataBySeriesIndex.set(seriesIndex, positiveCumulativeForSeries);
903
+ negativeCumulativeDataBySeriesIndex.set(seriesIndex, negativeCumulativeForSeries);
585
904
  });
586
905
  return {
587
906
  cumulativeDataBySeriesIndex,
907
+ positiveCumulativeDataBySeriesIndex,
908
+ negativeCumulativeDataBySeriesIndex,
588
909
  totalData,
910
+ positiveTotalData,
911
+ negativeTotalData,
589
912
  rawValuesBySeriesIndex,
590
913
  };
591
914
  }