@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 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
- // Inside the bar - with special handling for layer mode
430
- if (mode === 'layer') {
431
- const totalSeries = stackingContext?.totalSeries ?? 1;
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
- // Non-layer modes - use existing logic
448
+ const { inset, minPadding } = getLabelSpacing(mode);
484
449
  switch (insidePosition) {
485
450
  case 'top':
486
- labelY = barTop + boxHeight / 2 + 4;
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 - 4;
457
+ labelY = barBottom - boxHeight / 2 - inset;
493
458
  break;
494
459
  }
495
460
  // Check if it fits inside the bar
496
- if (boxHeight + 8 > barHeight) {
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
- // Inside the bar - with special handling for layer mode
648
- if (mode === 'layer') {
649
- const totalSeries = stackingContext?.totalSeries ?? 1;
650
- const seriesIndex = stackingContext?.seriesIndex ?? 0;
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
- // Non-layer modes - use existing logic
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 + 4;
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 - 4;
642
+ labelX = barRight - boxWidth / 2 - inset;
713
643
  break;
714
644
  }
715
645
  // Check if it fits inside the bar
716
- if (boxWidth + 8 > barWidth) {
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
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.7.1",
2
+ "version": "0.8.0",
3
3
  "name": "@internetstiftelsen/charts",
4
4
  "type": "module",
5
5
  "sideEffects": false,
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
- return this.series.map((series) => {
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
- return this.series.filter((series) => this.legend.isSeriesVisible(series.dataKey));
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 { cumulativeDataBySeriesIndex, totalData };
609
+ return {
610
+ cumulativeDataBySeriesIndex,
611
+ totalData,
612
+ rawValuesBySeriesIndex,
613
+ };
565
614
  }
566
615
  computeAreaStackingContexts(data, xKey, areaSeries) {
567
616
  const contextMap = new Map();