@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 +37 -0
- package/bar.js +59 -114
- package/base-chart.js +2 -5
- package/package.json +10 -3
- package/title.d.ts +1 -0
- package/title.js +18 -0
- package/types.d.ts +5 -0
- package/x-axis.d.ts +3 -0
- package/x-axis.js +47 -2
- package/xy-chart.d.ts +2 -0
- package/xy-chart.js +54 -5
- package/y-axis.d.ts +1 -0
- package/y-axis.js +18 -0
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
|
-
|
|
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/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.
|
|
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
|
-
"
|
|
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": "
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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();
|
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) {
|