@internetstiftelsen/charts 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/bar.js +59 -114
- package/package.json +1 -1
- package/types.d.ts +2 -0
- package/xy-chart.d.ts +2 -0
- package/xy-chart.js +54 -5
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@ A framework-agnostic, composable charting library built on D3.js with TypeScript
|
|
|
7
7
|
- **Framework Agnostic** - Works with vanilla JS, React, Vue, Svelte, or any framework
|
|
8
8
|
- **Composable Architecture** - Build charts by composing components
|
|
9
9
|
- **Multiple Chart Types** - XYChart (lines, areas, bars), DonutChart, PieChart, and GaugeChart
|
|
10
|
+
- **Stacking Control** - Bar stacking modes with optional reversed visual series order
|
|
10
11
|
- **Flexible Scales** - Band, linear, time, and logarithmic scales
|
|
11
12
|
- **Auto Resize** - Built-in ResizeObserver handles responsive behavior
|
|
12
13
|
- **Responsive Policy** - Chart-level container-query overrides for theme and components
|
package/bar.js
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
import { getContrastTextColor, sanitizeForCSS, mergeDeep } from './utils.js';
|
|
2
|
+
const LABEL_INSET_DEFAULT = 4;
|
|
3
|
+
const LABEL_INSET_STACKED = 6;
|
|
4
|
+
const LABEL_MIN_PADDING_DEFAULT = 8;
|
|
5
|
+
const LABEL_MIN_PADDING_STACKED = 16;
|
|
6
|
+
const LAYER_LABEL_GAP = 6;
|
|
7
|
+
function getLabelSpacing(mode) {
|
|
8
|
+
const stacked = mode !== 'none';
|
|
9
|
+
return {
|
|
10
|
+
inset: stacked ? LABEL_INSET_STACKED : LABEL_INSET_DEFAULT,
|
|
11
|
+
minPadding: stacked
|
|
12
|
+
? LABEL_MIN_PADDING_STACKED
|
|
13
|
+
: LABEL_MIN_PADDING_DEFAULT,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
2
16
|
export class Bar {
|
|
3
17
|
constructor(config) {
|
|
4
18
|
Object.defineProperty(this, "type", {
|
|
@@ -426,76 +440,42 @@ export class Bar {
|
|
|
426
440
|
}
|
|
427
441
|
}
|
|
428
442
|
else {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const seriesIndex = stackingContext?.seriesIndex ?? 0;
|
|
433
|
-
const isTopLayer = seriesIndex === totalSeries - 1;
|
|
434
|
-
switch (insidePosition) {
|
|
435
|
-
case 'top':
|
|
436
|
-
// For layer mode + inside + top: check if there's enough space in the gap
|
|
437
|
-
if (seriesIndex < totalSeries - 1) {
|
|
438
|
-
// Calculate the gap to the next layer
|
|
439
|
-
const nextLayerScaleFactor = 1 - ((seriesIndex + 1) / totalSeries) * 0.7;
|
|
440
|
-
const nextLayerWidth = (this.maxBarSize
|
|
441
|
-
? Math.min(bandwidth, this.maxBarSize)
|
|
442
|
-
: bandwidth) * nextLayerScaleFactor;
|
|
443
|
-
const gap = (barWidth - nextLayerWidth) / 2;
|
|
444
|
-
const marginBelow = 4; // Minimum margin below text
|
|
445
|
-
if (boxHeight + marginBelow <= gap) {
|
|
446
|
-
labelY =
|
|
447
|
-
barTop + boxHeight / 2 + marginBelow;
|
|
448
|
-
}
|
|
449
|
-
else {
|
|
450
|
-
shouldRender = false;
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
else {
|
|
454
|
-
// Top layer - use normal top position if it fits
|
|
455
|
-
labelY = barTop + boxHeight / 2 + 4;
|
|
456
|
-
if (boxHeight + 8 > barHeight) {
|
|
457
|
-
shouldRender = false;
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
break;
|
|
461
|
-
case 'middle':
|
|
462
|
-
// For layer mode + inside + middle: only show what fits
|
|
463
|
-
labelY = (barTop + barBottom) / 2;
|
|
464
|
-
if (boxHeight + 8 > barHeight) {
|
|
465
|
-
shouldRender = false;
|
|
466
|
-
}
|
|
467
|
-
break;
|
|
468
|
-
case 'bottom':
|
|
469
|
-
// For layer mode + inside + bottom: only show for top layer if it fits
|
|
470
|
-
if (isTopLayer) {
|
|
471
|
-
labelY = barBottom - boxHeight / 2 - 4;
|
|
472
|
-
if (boxHeight + 8 > barHeight) {
|
|
473
|
-
shouldRender = false;
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
else {
|
|
477
|
-
shouldRender = false;
|
|
478
|
-
}
|
|
479
|
-
break;
|
|
480
|
-
}
|
|
443
|
+
if (mode === 'layer' && insidePosition === 'bottom') {
|
|
444
|
+
// Bottom labels in layer mode are visually ambiguous and often hidden by overlap.
|
|
445
|
+
shouldRender = false;
|
|
481
446
|
}
|
|
482
447
|
else {
|
|
483
|
-
|
|
448
|
+
const { inset, minPadding } = getLabelSpacing(mode);
|
|
484
449
|
switch (insidePosition) {
|
|
485
450
|
case 'top':
|
|
486
|
-
labelY = barTop + boxHeight / 2 +
|
|
451
|
+
labelY = barTop + boxHeight / 2 + inset;
|
|
487
452
|
break;
|
|
488
453
|
case 'middle':
|
|
489
454
|
labelY = (barTop + barBottom) / 2;
|
|
490
455
|
break;
|
|
491
456
|
case 'bottom':
|
|
492
|
-
labelY = barBottom - boxHeight / 2 -
|
|
457
|
+
labelY = barBottom - boxHeight / 2 - inset;
|
|
493
458
|
break;
|
|
494
459
|
}
|
|
495
460
|
// Check if it fits inside the bar
|
|
496
|
-
if (boxHeight +
|
|
461
|
+
if (boxHeight + minPadding > barHeight) {
|
|
497
462
|
shouldRender = false;
|
|
498
463
|
}
|
|
464
|
+
// In layer mode, check the label fits in the visible gap
|
|
465
|
+
// above the next layer's bar top
|
|
466
|
+
if (shouldRender &&
|
|
467
|
+
mode === 'layer' &&
|
|
468
|
+
insidePosition === 'top' &&
|
|
469
|
+
stackingContext?.nextLayerData) {
|
|
470
|
+
const nextValue = stackingContext.nextLayerData.get(categoryKey);
|
|
471
|
+
if (nextValue !== undefined) {
|
|
472
|
+
const nextBarTop = y(nextValue) || 0;
|
|
473
|
+
const labelBottom = labelY + boxHeight / 2;
|
|
474
|
+
if (labelBottom + LAYER_LABEL_GAP > nextBarTop) {
|
|
475
|
+
shouldRender = false;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
499
479
|
}
|
|
500
480
|
}
|
|
501
481
|
tempText.remove();
|
|
@@ -644,78 +624,43 @@ export class Bar {
|
|
|
644
624
|
}
|
|
645
625
|
}
|
|
646
626
|
else {
|
|
647
|
-
//
|
|
648
|
-
if (mode === 'layer') {
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
const isTopLayer = seriesIndex === totalSeries - 1;
|
|
652
|
-
// Map top/middle/bottom to start/middle/end for horizontal
|
|
653
|
-
switch (insidePosition) {
|
|
654
|
-
case 'top': // start of bar (left side)
|
|
655
|
-
// For layer mode + inside + top(left): check if there's enough space in the gap
|
|
656
|
-
if (seriesIndex < totalSeries - 1) {
|
|
657
|
-
// Calculate the gap to the next layer
|
|
658
|
-
const nextLayerScaleFactor = 1 - ((seriesIndex + 1) / totalSeries) * 0.7;
|
|
659
|
-
const nextLayerHeight = (this.maxBarSize
|
|
660
|
-
? Math.min(bandwidth, this.maxBarSize)
|
|
661
|
-
: bandwidth) * nextLayerScaleFactor;
|
|
662
|
-
const gap = (barHeight - nextLayerHeight) / 2;
|
|
663
|
-
const marginRight = 4; // Minimum margin to the right of text
|
|
664
|
-
if (boxWidth + marginRight <= gap) {
|
|
665
|
-
labelX =
|
|
666
|
-
barLeft + boxWidth / 2 + marginRight;
|
|
667
|
-
}
|
|
668
|
-
else {
|
|
669
|
-
shouldRender = false;
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
else {
|
|
673
|
-
// Top layer - use normal left position if it fits
|
|
674
|
-
labelX = barLeft + boxWidth / 2 + 4;
|
|
675
|
-
if (boxWidth + 8 > barWidth) {
|
|
676
|
-
shouldRender = false;
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
break;
|
|
680
|
-
case 'middle':
|
|
681
|
-
// For layer mode + inside + middle: only show what fits
|
|
682
|
-
labelX = (barLeft + barRight) / 2;
|
|
683
|
-
if (boxWidth + 8 > barWidth) {
|
|
684
|
-
shouldRender = false;
|
|
685
|
-
}
|
|
686
|
-
break;
|
|
687
|
-
case 'bottom': // end of bar (right side)
|
|
688
|
-
// For layer mode + inside + bottom(right): only show for top layer if it fits
|
|
689
|
-
if (isTopLayer) {
|
|
690
|
-
labelX = barRight - boxWidth / 2 - 4;
|
|
691
|
-
if (boxWidth + 8 > barWidth) {
|
|
692
|
-
shouldRender = false;
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
else {
|
|
696
|
-
shouldRender = false;
|
|
697
|
-
}
|
|
698
|
-
break;
|
|
699
|
-
}
|
|
627
|
+
// Map top/middle/bottom to start/middle/end for horizontal
|
|
628
|
+
if (mode === 'layer' && insidePosition === 'bottom') {
|
|
629
|
+
// Bottom labels in layer mode are visually ambiguous and often hidden by overlap.
|
|
630
|
+
shouldRender = false;
|
|
700
631
|
}
|
|
701
632
|
else {
|
|
702
|
-
|
|
703
|
-
// Map top/middle/bottom to start/middle/end for horizontal
|
|
633
|
+
const { inset, minPadding } = getLabelSpacing(mode);
|
|
704
634
|
switch (insidePosition) {
|
|
705
635
|
case 'top': // start of bar (left side)
|
|
706
|
-
labelX = barLeft + boxWidth / 2 +
|
|
636
|
+
labelX = barLeft + boxWidth / 2 + inset;
|
|
707
637
|
break;
|
|
708
638
|
case 'middle':
|
|
709
639
|
labelX = (barLeft + barRight) / 2;
|
|
710
640
|
break;
|
|
711
641
|
case 'bottom': // end of bar (right side)
|
|
712
|
-
labelX = barRight - boxWidth / 2 -
|
|
642
|
+
labelX = barRight - boxWidth / 2 - inset;
|
|
713
643
|
break;
|
|
714
644
|
}
|
|
715
645
|
// Check if it fits inside the bar
|
|
716
|
-
if (boxWidth +
|
|
646
|
+
if (boxWidth + minPadding > barWidth) {
|
|
717
647
|
shouldRender = false;
|
|
718
648
|
}
|
|
649
|
+
// In layer mode, check the label fits in the visible gap
|
|
650
|
+
// before the next layer's bar end
|
|
651
|
+
if (shouldRender &&
|
|
652
|
+
mode === 'layer' &&
|
|
653
|
+
insidePosition === 'top' &&
|
|
654
|
+
stackingContext?.nextLayerData) {
|
|
655
|
+
const nextValue = stackingContext.nextLayerData.get(categoryKey);
|
|
656
|
+
if (nextValue !== undefined) {
|
|
657
|
+
const nextBarRight = x(nextValue) || 0;
|
|
658
|
+
const labelRight = labelX + boxWidth / 2;
|
|
659
|
+
if (labelRight + LAYER_LABEL_GAP > nextBarRight) {
|
|
660
|
+
shouldRender = false;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
719
664
|
}
|
|
720
665
|
}
|
|
721
666
|
tempText.remove();
|
package/package.json
CHANGED
package/types.d.ts
CHANGED
|
@@ -207,6 +207,7 @@ export type AreaConfig = AreaConfigBase & {
|
|
|
207
207
|
export type BarStackConfig = {
|
|
208
208
|
mode?: BarStackMode;
|
|
209
209
|
gap?: number;
|
|
210
|
+
reverseSeries?: boolean;
|
|
210
211
|
};
|
|
211
212
|
export type AreaStackMode = 'none' | 'normal' | 'percent';
|
|
212
213
|
export type AreaStackConfig = {
|
|
@@ -327,6 +328,7 @@ export type BarStackingContext = {
|
|
|
327
328
|
cumulativeData: Map<string, number>;
|
|
328
329
|
totalData: Map<string, number>;
|
|
329
330
|
gap: number;
|
|
331
|
+
nextLayerData?: Map<string, number>;
|
|
330
332
|
};
|
|
331
333
|
export type AreaStackingContext = {
|
|
332
334
|
mode: AreaStackMode;
|
package/xy-chart.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export declare class XYChart extends BaseChart {
|
|
|
9
9
|
private readonly series;
|
|
10
10
|
private barStackMode;
|
|
11
11
|
private barStackGap;
|
|
12
|
+
private barStackReverseSeries;
|
|
12
13
|
private areaStackMode;
|
|
13
14
|
constructor(config: XYChartConfig);
|
|
14
15
|
addChild(component: ChartComponent): this;
|
|
@@ -22,6 +23,7 @@ export declare class XYChart extends BaseChart {
|
|
|
22
23
|
protected getLegendSeries(): LegendSeries[];
|
|
23
24
|
private getCategoryScaleType;
|
|
24
25
|
private getVisibleSeries;
|
|
26
|
+
private getDisplaySeries;
|
|
25
27
|
private setupScales;
|
|
26
28
|
private isHorizontalOrientation;
|
|
27
29
|
private collectSeriesValues;
|
package/xy-chart.js
CHANGED
|
@@ -23,6 +23,12 @@ export class XYChart extends BaseChart {
|
|
|
23
23
|
writable: true,
|
|
24
24
|
value: void 0
|
|
25
25
|
});
|
|
26
|
+
Object.defineProperty(this, "barStackReverseSeries", {
|
|
27
|
+
enumerable: true,
|
|
28
|
+
configurable: true,
|
|
29
|
+
writable: true,
|
|
30
|
+
value: void 0
|
|
31
|
+
});
|
|
26
32
|
Object.defineProperty(this, "areaStackMode", {
|
|
27
33
|
enumerable: true,
|
|
28
34
|
configurable: true,
|
|
@@ -31,6 +37,7 @@ export class XYChart extends BaseChart {
|
|
|
31
37
|
});
|
|
32
38
|
this.barStackMode = config.barStack?.mode ?? 'normal';
|
|
33
39
|
this.barStackGap = config.barStack?.gap ?? 0.1;
|
|
40
|
+
this.barStackReverseSeries = config.barStack?.reverseSeries ?? false;
|
|
34
41
|
this.areaStackMode = config.areaStack?.mode ?? 'none';
|
|
35
42
|
}
|
|
36
43
|
addChild(component) {
|
|
@@ -120,6 +127,7 @@ export class XYChart extends BaseChart {
|
|
|
120
127
|
barStack: {
|
|
121
128
|
mode: this.barStackMode,
|
|
122
129
|
gap: this.barStackGap,
|
|
130
|
+
reverseSeries: this.barStackReverseSeries,
|
|
123
131
|
},
|
|
124
132
|
areaStack: {
|
|
125
133
|
mode: this.areaStackMode,
|
|
@@ -235,7 +243,8 @@ export class XYChart extends BaseChart {
|
|
|
235
243
|
return (Object.keys(this.data[0]).find((key) => !this.series.some((s) => s.dataKey === key)) || 'column');
|
|
236
244
|
}
|
|
237
245
|
getLegendSeries() {
|
|
238
|
-
|
|
246
|
+
const displaySeries = this.getDisplaySeries();
|
|
247
|
+
return displaySeries.map((series) => {
|
|
239
248
|
if (series.type === 'line') {
|
|
240
249
|
return {
|
|
241
250
|
dataKey: series.dataKey,
|
|
@@ -252,10 +261,32 @@ export class XYChart extends BaseChart {
|
|
|
252
261
|
return this.scaleConfig.x?.type || 'band';
|
|
253
262
|
}
|
|
254
263
|
getVisibleSeries() {
|
|
264
|
+
const displaySeries = this.getDisplaySeries();
|
|
255
265
|
if (!this.legend) {
|
|
266
|
+
return displaySeries;
|
|
267
|
+
}
|
|
268
|
+
return displaySeries.filter((series) => this.legend.isSeriesVisible(series.dataKey));
|
|
269
|
+
}
|
|
270
|
+
getDisplaySeries() {
|
|
271
|
+
if (!this.barStackReverseSeries) {
|
|
272
|
+
return this.series;
|
|
273
|
+
}
|
|
274
|
+
const barSeries = this.series.filter((entry) => {
|
|
275
|
+
return entry.type === 'bar';
|
|
276
|
+
});
|
|
277
|
+
if (barSeries.length < 2) {
|
|
256
278
|
return this.series;
|
|
257
279
|
}
|
|
258
|
-
|
|
280
|
+
const reversedBars = [...barSeries].reverse();
|
|
281
|
+
let reversedBarIndex = 0;
|
|
282
|
+
return this.series.map((entry) => {
|
|
283
|
+
if (entry.type !== 'bar') {
|
|
284
|
+
return entry;
|
|
285
|
+
}
|
|
286
|
+
const nextBar = reversedBars[reversedBarIndex];
|
|
287
|
+
reversedBarIndex += 1;
|
|
288
|
+
return nextBar;
|
|
289
|
+
});
|
|
259
290
|
}
|
|
260
291
|
setupScales() {
|
|
261
292
|
const xKey = this.getXKey();
|
|
@@ -509,9 +540,12 @@ export class XYChart extends BaseChart {
|
|
|
509
540
|
.append('g')
|
|
510
541
|
.attr('class', 'area-value-label-layer')
|
|
511
542
|
: null;
|
|
512
|
-
const { cumulativeDataBySeriesIndex, totalData } = this.computeStackingData(this.data, xKey, barSeries);
|
|
543
|
+
const { cumulativeDataBySeriesIndex, totalData, rawValuesBySeriesIndex, } = this.computeStackingData(this.data, xKey, barSeries);
|
|
513
544
|
const areaStackingContextBySeries = this.computeAreaStackingContexts(this.data, xKey, areaSeries);
|
|
514
545
|
barSeries.forEach((series, barIndex) => {
|
|
546
|
+
const nextLayerData = this.barStackMode === 'layer'
|
|
547
|
+
? rawValuesBySeriesIndex.get(barIndex + 1)
|
|
548
|
+
: undefined;
|
|
515
549
|
const stackingContext = {
|
|
516
550
|
mode: this.barStackMode,
|
|
517
551
|
seriesIndex: barIndex,
|
|
@@ -519,6 +553,7 @@ export class XYChart extends BaseChart {
|
|
|
519
553
|
cumulativeData: cumulativeDataBySeriesIndex.get(barIndex) ?? new Map(),
|
|
520
554
|
totalData,
|
|
521
555
|
gap: this.barStackGap,
|
|
556
|
+
nextLayerData,
|
|
522
557
|
};
|
|
523
558
|
series.render(this.plotGroup, this.data, xKey, this.x, this.y, this.parseValue, categoryScaleType, this.theme, stackingContext);
|
|
524
559
|
});
|
|
@@ -534,15 +569,25 @@ export class XYChart extends BaseChart {
|
|
|
534
569
|
}
|
|
535
570
|
computeStackingData(data, xKey, barSeries) {
|
|
536
571
|
const cumulativeDataBySeriesIndex = new Map();
|
|
572
|
+
const rawValuesBySeriesIndex = new Map();
|
|
537
573
|
const totalData = new Map();
|
|
538
574
|
data.forEach((dataPoint) => {
|
|
539
575
|
const categoryKey = String(dataPoint[xKey]);
|
|
540
576
|
let total = 0;
|
|
541
|
-
barSeries.forEach((series) => {
|
|
577
|
+
barSeries.forEach((series, seriesIndex) => {
|
|
542
578
|
const value = this.parseValue(dataPoint[series.dataKey]);
|
|
543
579
|
if (Number.isFinite(value)) {
|
|
544
580
|
total += value;
|
|
545
581
|
}
|
|
582
|
+
// Build per-series raw value maps (used for layer next-layer data)
|
|
583
|
+
let rawMap = rawValuesBySeriesIndex.get(seriesIndex);
|
|
584
|
+
if (!rawMap) {
|
|
585
|
+
rawMap = new Map();
|
|
586
|
+
rawValuesBySeriesIndex.set(seriesIndex, rawMap);
|
|
587
|
+
}
|
|
588
|
+
if (Number.isFinite(value)) {
|
|
589
|
+
rawMap.set(categoryKey, value);
|
|
590
|
+
}
|
|
546
591
|
});
|
|
547
592
|
totalData.set(categoryKey, total);
|
|
548
593
|
});
|
|
@@ -561,7 +606,11 @@ export class XYChart extends BaseChart {
|
|
|
561
606
|
});
|
|
562
607
|
cumulativeDataBySeriesIndex.set(seriesIndex, cumulativeForSeries);
|
|
563
608
|
});
|
|
564
|
-
return {
|
|
609
|
+
return {
|
|
610
|
+
cumulativeDataBySeriesIndex,
|
|
611
|
+
totalData,
|
|
612
|
+
rawValuesBySeriesIndex,
|
|
613
|
+
};
|
|
565
614
|
}
|
|
566
615
|
computeAreaStackingContexts(data, xKey, areaSeries) {
|
|
567
616
|
const contextMap = new Map();
|