@internetstiftelsen/charts 0.7.0 → 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
@@ -20,6 +21,42 @@ A framework-agnostic, composable charting library built on D3.js with TypeScript
20
21
  npm install @internetstiftelsen/charts
21
22
  ```
22
23
 
24
+ ## Local Development
25
+
26
+ ```bash
27
+ pnpm dev
28
+ ```
29
+
30
+ Runs the interactive demo app (`index.html`) with sidebar controls and
31
+ Chart/Data/Showcase tabs.
32
+
33
+ ```bash
34
+ pnpm dev:docs
35
+ ```
36
+
37
+ Runs the marketing landing page (`docs.html`) built on
38
+ `@internetstiftelsen/styleguide`.
39
+
40
+ ## Build Targets
41
+
42
+ ```bash
43
+ pnpm build
44
+ ```
45
+
46
+ Builds the publishable chart library output into `dist`.
47
+
48
+ ```bash
49
+ pnpm build:docs
50
+ ```
51
+
52
+ Builds the static marketing site into `dist-docs` (used for Pages deploys).
53
+
54
+ ```bash
55
+ pnpm build:demo
56
+ ```
57
+
58
+ Builds the demo app using the default Vite config.
59
+
23
60
  ## Quick Start
24
61
 
25
62
  ```javascript
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/base-chart.js CHANGED
@@ -377,15 +377,12 @@ export class BaseChart {
377
377
  const matchesIndex = match.index === undefined || match.index === index;
378
378
  const matchesType = match.type === undefined || match.type === component.type;
379
379
  const matchesDataKey = match.dataKey === undefined ||
380
- (typeof dataKey === 'string' &&
381
- dataKey === match.dataKey);
380
+ (typeof dataKey === 'string' && dataKey === match.dataKey);
382
381
  if (!matchesIndex || !matchesType || !matchesDataKey) {
383
382
  return;
384
383
  }
385
384
  const existing = componentOverrides.get(component);
386
- componentOverrides.set(component, existing
387
- ? mergeDeep(existing, override)
388
- : { ...override });
385
+ componentOverrides.set(component, existing ? mergeDeep(existing, override) : { ...override });
389
386
  });
390
387
  });
391
388
  return {
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.7.0",
2
+ "version": "0.8.0",
3
3
  "name": "@internetstiftelsen/charts",
4
4
  "type": "module",
5
5
  "sideEffects": false,
@@ -16,13 +16,17 @@
16
16
  ],
17
17
  "scripts": {
18
18
  "dev": "vite",
19
- "build": "tsc -b && vite build",
19
+ "dev:docs": "vite --config vite.docs.config.ts --open",
20
+ "build": "tsc --project tsconfig.lib.json && tsc-alias --project tsconfig.lib.json",
21
+ "build:demo": "tsc -b && vite build",
22
+ "build:docs": "vite build --config vite.docs.config.ts && cp -R docs dist-docs/docs",
20
23
  "lint": "eslint .",
21
24
  "format": "prettier --write ./src",
22
25
  "preview": "vite preview",
26
+ "preview:docs": "vite preview --config vite.docs.config.ts",
23
27
  "test": "vitest",
24
28
  "test:run": "vitest run",
25
- "build:lib": "tsc --project tsconfig.lib.json && tsc-alias --project tsconfig.lib.json",
29
+ "build:lib": "npm run build",
26
30
  "prepub": "rm -rf dist && npm run build:lib && cp package.json dist && cp README.md dist",
27
31
  "pub": "npm run prepub && cd dist && npm publish --access public"
28
32
  },
@@ -49,6 +53,8 @@
49
53
  },
50
54
  "devDependencies": {
51
55
  "@eslint/js": "^9.39.2",
56
+ "@internetstiftelsen/styleguide": "^5.1.23",
57
+ "@speed-highlight/core": "^1.2.14",
52
58
  "@tailwindcss/vite": "^4.1.18",
53
59
  "@testing-library/dom": "^10.4.1",
54
60
  "@testing-library/jest-dom": "^6.9.1",
@@ -63,6 +69,7 @@
63
69
  "globals": "^16.5.0",
64
70
  "jsdom": "^27.4.0",
65
71
  "prettier": "3.6.2",
72
+ "sass": "^1.97.3",
66
73
  "tsc-alias": "^1.8.16",
67
74
  "tw-animate-css": "^1.4.0",
68
75
  "typescript": "~5.9.3",
package/title.d.ts CHANGED
@@ -3,6 +3,7 @@ import type { TitleConfig, ChartTheme, ExportHooks, TitleConfigBase } from './ty
3
3
  import type { LayoutAwareComponent, ComponentSpace } from './chart-interface.js';
4
4
  export declare class Title implements LayoutAwareComponent<TitleConfigBase> {
5
5
  readonly type: "title";
6
+ readonly display: boolean;
6
7
  readonly text: string;
7
8
  readonly exportHooks?: ExportHooks<TitleConfigBase>;
8
9
  private readonly fontSize;
package/title.js CHANGED
@@ -7,6 +7,12 @@ export class Title {
7
7
  writable: true,
8
8
  value: 'title'
9
9
  });
10
+ Object.defineProperty(this, "display", {
11
+ enumerable: true,
12
+ configurable: true,
13
+ writable: true,
14
+ value: void 0
15
+ });
10
16
  Object.defineProperty(this, "text", {
11
17
  enumerable: true,
12
18
  configurable: true,
@@ -55,6 +61,7 @@ export class Title {
55
61
  writable: true,
56
62
  value: void 0
57
63
  });
64
+ this.display = config.display ?? true;
58
65
  this.text = config.text;
59
66
  this.fontSize = config.fontSize ?? 18;
60
67
  this.fontWeight = config.fontWeight ?? 'bold';
@@ -66,6 +73,7 @@ export class Title {
66
73
  }
67
74
  getExportConfig() {
68
75
  return {
76
+ display: this.display,
69
77
  text: this.text,
70
78
  fontSize: this.fontSize,
71
79
  fontWeight: this.fontWeight,
@@ -86,6 +94,13 @@ export class Title {
86
94
  * Returns the space required by the title
87
95
  */
88
96
  getRequiredSpace() {
97
+ if (!this.display) {
98
+ return {
99
+ width: 0,
100
+ height: 0,
101
+ position: 'top',
102
+ };
103
+ }
89
104
  return {
90
105
  width: 0, // Title spans full width
91
106
  height: this.marginTop + this.fontSize + this.marginBottom,
@@ -93,6 +108,9 @@ export class Title {
93
108
  };
94
109
  }
95
110
  render(svg, theme, width, x = 0, y = 0) {
111
+ if (!this.display) {
112
+ return;
113
+ }
96
114
  const titleGroup = svg
97
115
  .append('g')
98
116
  .attr('class', 'title')
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 = {
@@ -218,6 +219,7 @@ export declare function getSeriesColor(series: {
218
219
  }): string;
219
220
  export type LabelOversizedBehavior = 'truncate' | 'wrap' | 'hide';
220
221
  export type XAxisConfigBase = {
222
+ display?: boolean;
221
223
  dataKey?: string;
222
224
  labelKey?: string;
223
225
  groupLabelKey?: string;
@@ -235,6 +237,7 @@ export type XAxisConfig = XAxisConfigBase & {
235
237
  exportHooks?: ExportHooks<XAxisConfigBase>;
236
238
  };
237
239
  export type YAxisConfigBase = {
240
+ display?: boolean;
238
241
  tickFormat?: string | ((value: number) => string) | null;
239
242
  rotatedLabels?: boolean;
240
243
  maxLabelWidth?: number;
@@ -287,6 +290,7 @@ export type LegendItem = {
287
290
  visible: boolean;
288
291
  };
289
292
  export type TitleConfigBase = {
293
+ display?: boolean;
290
294
  text: string;
291
295
  fontSize?: number;
292
296
  fontWeight?: string;
@@ -324,6 +328,7 @@ export type BarStackingContext = {
324
328
  cumulativeData: Map<string, number>;
325
329
  totalData: Map<string, number>;
326
330
  gap: number;
331
+ nextLayerData?: Map<string, number>;
327
332
  };
328
333
  export type AreaStackingContext = {
329
334
  mode: AreaStackMode;
package/x-axis.d.ts CHANGED
@@ -3,6 +3,7 @@ import type { XAxisConfig, ChartTheme, D3Scale, DataItem, ExportHooks, XAxisConf
3
3
  import type { LayoutAwareComponent, ComponentSpace } from './chart-interface.js';
4
4
  export declare class XAxis implements LayoutAwareComponent<XAxisConfigBase> {
5
5
  readonly type: "xAxis";
6
+ readonly display: boolean;
6
7
  readonly dataKey?: string;
7
8
  readonly labelKey?: string;
8
9
  readonly groupLabelKey?: string;
@@ -16,6 +17,7 @@ export declare class XAxis implements LayoutAwareComponent<XAxisConfigBase> {
16
17
  private readonly tickFormat;
17
18
  private wrapLineCount;
18
19
  private estimatedHeight;
20
+ private estimatedTickLabelVerticalFootprint;
19
21
  private readonly autoHideOverlapping;
20
22
  private readonly minLabelGap;
21
23
  private readonly preserveEndLabels;
@@ -30,6 +32,7 @@ export declare class XAxis implements LayoutAwareComponent<XAxisConfigBase> {
30
32
  getRequiredSpace(): ComponentSpace;
31
33
  estimateLayoutSpace(labels: unknown[], theme: ChartTheme, svg: SVGSVGElement): void;
32
34
  clearEstimatedSpace(): void;
35
+ private getTickLabelVerticalFootprint;
33
36
  render(svg: Selection<SVGSVGElement, undefined, null, undefined>, x: D3Scale, theme: ChartTheme, yPosition: number, data?: DataItem[]): void;
34
37
  private buildLabelLookup;
35
38
  private renderGroupLabels;
package/x-axis.js CHANGED
@@ -25,6 +25,12 @@ export class XAxis {
25
25
  writable: true,
26
26
  value: 'xAxis'
27
27
  });
28
+ Object.defineProperty(this, "display", {
29
+ enumerable: true,
30
+ configurable: true,
31
+ writable: true,
32
+ value: void 0
33
+ });
28
34
  Object.defineProperty(this, "dataKey", {
29
35
  enumerable: true,
30
36
  configurable: true,
@@ -104,6 +110,12 @@ export class XAxis {
104
110
  writable: true,
105
111
  value: null
106
112
  });
113
+ Object.defineProperty(this, "estimatedTickLabelVerticalFootprint", {
114
+ enumerable: true,
115
+ configurable: true,
116
+ writable: true,
117
+ value: null
118
+ });
107
119
  Object.defineProperty(this, "autoHideOverlapping", {
108
120
  enumerable: true,
109
121
  configurable: true,
@@ -128,6 +140,7 @@ export class XAxis {
128
140
  writable: true,
129
141
  value: void 0
130
142
  });
143
+ this.display = config?.display ?? true;
131
144
  this.dataKey = config?.dataKey;
132
145
  this.labelKey = config?.labelKey;
133
146
  this.groupLabelKey = config?.groupLabelKey;
@@ -144,6 +157,7 @@ export class XAxis {
144
157
  }
145
158
  getExportConfig() {
146
159
  return {
160
+ display: this.display,
147
161
  dataKey: this.dataKey,
148
162
  labelKey: this.labelKey,
149
163
  groupLabelKey: this.groupLabelKey,
@@ -169,6 +183,13 @@ export class XAxis {
169
183
  * Returns the space required by the x-axis
170
184
  */
171
185
  getRequiredSpace() {
186
+ if (!this.display) {
187
+ return {
188
+ width: 0,
189
+ height: 0,
190
+ position: 'bottom',
191
+ };
192
+ }
172
193
  if (this.estimatedHeight !== null) {
173
194
  return {
174
195
  width: 0,
@@ -198,6 +219,8 @@ export class XAxis {
198
219
  estimateLayoutSpace(labels, theme, svg) {
199
220
  if (!labels.length) {
200
221
  this.estimatedHeight = null;
222
+ this.estimatedTickLabelVerticalFootprint = null;
223
+ this.wrapLineCount = 1;
201
224
  return;
202
225
  }
203
226
  const parsedFontSize = typeof theme.axis.fontSize === 'string'
@@ -232,9 +255,13 @@ export class XAxis {
232
255
  if (this.rotatedLabels) {
233
256
  const radians = Math.PI / 4;
234
257
  const verticalFootprint = Math.sin(radians) * maxWidth + Math.cos(radians) * textHeight;
258
+ this.estimatedTickLabelVerticalFootprint = verticalFootprint;
235
259
  this.estimatedHeight = this.tickPadding + verticalFootprint + 5;
236
260
  }
237
261
  else {
262
+ const wrappedExtraHeight = Math.max(0, maxLines - 1) * lineHeight;
263
+ this.estimatedTickLabelVerticalFootprint =
264
+ this.fontSize + wrappedExtraHeight;
238
265
  this.estimatedHeight = this.tickPadding + textHeight + 5;
239
266
  }
240
267
  if (this.showGroupLabels) {
@@ -242,12 +269,29 @@ export class XAxis {
242
269
  this.estimatedHeight +=
243
270
  this.groupLabelGap + groupLabelStyle.fontSize + 5;
244
271
  }
245
- this.wrapLineCount = Math.max(this.wrapLineCount, maxLines);
272
+ this.wrapLineCount = maxLines;
246
273
  }
247
274
  clearEstimatedSpace() {
248
275
  this.estimatedHeight = null;
276
+ this.estimatedTickLabelVerticalFootprint = null;
277
+ }
278
+ getTickLabelVerticalFootprint() {
279
+ if (this.estimatedTickLabelVerticalFootprint !== null) {
280
+ return this.estimatedTickLabelVerticalFootprint;
281
+ }
282
+ if (this.rotatedLabels) {
283
+ // Fallback to the same rough factor used by getRequiredSpace().
284
+ const baseHeight = this.tickPadding + this.fontSize + 5;
285
+ return Math.max(baseHeight * 2.5 - this.tickPadding - 5, 0);
286
+ }
287
+ const lineHeight = this.fontSize * 1.2;
288
+ const wrappedExtraHeight = Math.max(0, this.wrapLineCount - 1) * lineHeight;
289
+ return this.fontSize + wrappedExtraHeight;
249
290
  }
250
291
  render(svg, x, theme, yPosition, data = []) {
292
+ if (!this.display) {
293
+ return;
294
+ }
251
295
  const labelLookup = this.buildLabelLookup(data);
252
296
  const axisGenerator = axisBottom(x)
253
297
  .tickSizeOuter(0)
@@ -321,7 +365,8 @@ export class XAxis {
321
365
  if (groupRanges.length === 0) {
322
366
  return;
323
367
  }
324
- const yOffset = this.tickPadding + this.fontSize + this.groupLabelGap;
368
+ const tickLabelVerticalFootprint = this.getTickLabelVerticalFootprint();
369
+ const yOffset = this.tickPadding + tickLabelVerticalFootprint + this.groupLabelGap;
325
370
  const groupLabelStyle = this.resolveGroupLabelStyle(theme);
326
371
  const groupLayer = svg
327
372
  .append('g')
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();
package/y-axis.d.ts CHANGED
@@ -3,6 +3,7 @@ import type { ChartTheme, YAxisConfig, D3Scale, ExportHooks, YAxisConfigBase } f
3
3
  import type { LayoutAwareComponent, ComponentSpace } from './chart-interface.js';
4
4
  export declare class YAxis implements LayoutAwareComponent<YAxisConfigBase> {
5
5
  readonly type: "yAxis";
6
+ readonly display: boolean;
6
7
  private readonly tickPadding;
7
8
  private readonly fontSize;
8
9
  private readonly maxLabelWidth;
package/y-axis.js CHANGED
@@ -8,6 +8,12 @@ export class YAxis {
8
8
  writable: true,
9
9
  value: 'yAxis'
10
10
  });
11
+ Object.defineProperty(this, "display", {
12
+ enumerable: true,
13
+ configurable: true,
14
+ writable: true,
15
+ value: void 0
16
+ });
11
17
  Object.defineProperty(this, "tickPadding", {
12
18
  enumerable: true,
13
19
  configurable: true,
@@ -51,6 +57,7 @@ export class YAxis {
51
57
  writable: true,
52
58
  value: void 0
53
59
  });
60
+ this.display = config?.display ?? true;
54
61
  this.tickFormat = config?.tickFormat ?? null;
55
62
  this.rotatedLabels = config?.rotatedLabels ?? false;
56
63
  this.maxLabelWidth = config?.maxLabelWidth ?? 40; // Default 40 for backward compatibility
@@ -59,6 +66,7 @@ export class YAxis {
59
66
  }
60
67
  getExportConfig() {
61
68
  return {
69
+ display: this.display,
62
70
  tickFormat: this.tickFormat,
63
71
  rotatedLabels: this.rotatedLabels,
64
72
  maxLabelWidth: this.maxLabelWidth,
@@ -76,6 +84,13 @@ export class YAxis {
76
84
  * Returns the space required by the y-axis
77
85
  */
78
86
  getRequiredSpace() {
87
+ if (!this.display) {
88
+ return {
89
+ width: 0,
90
+ height: 0,
91
+ position: 'left',
92
+ };
93
+ }
79
94
  // Width = max label width + tick padding
80
95
  // Rotated labels need less width (cos(45°) ≈ 0.7 of horizontal width)
81
96
  const baseWidth = this.maxLabelWidth + this.tickPadding;
@@ -87,6 +102,9 @@ export class YAxis {
87
102
  };
88
103
  }
89
104
  render(svg, y, theme, xPosition) {
105
+ if (!this.display) {
106
+ return;
107
+ }
90
108
  const axis = axisLeft(y).tickSize(0).tickPadding(this.tickPadding);
91
109
  // Apply tick formatting if specified
92
110
  if (this.tickFormat) {