@internetstiftelsen/charts 0.0.7 → 0.0.9
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/bar.d.ts +7 -2
- package/bar.js +522 -63
- package/base-chart.d.ts +7 -1
- package/base-chart.js +26 -0
- package/line.d.ts +3 -1
- package/line.js +85 -0
- package/package.json +3 -1
- package/theme.js +20 -0
- package/tooltip.d.ts +6 -0
- package/tooltip.js +50 -14
- package/types.d.ts +50 -4
- package/xy-chart.d.ts +7 -1
- package/xy-chart.js +87 -10
package/bar.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Selection } from 'd3';
|
|
2
|
-
import type { BarConfig,
|
|
2
|
+
import type { BarConfig, BarStackingContext, BarValueLabelConfig, ChartTheme, DataItem, Orientation, ScaleType } from './types.js';
|
|
3
3
|
import type { ChartComponent } from './chart-interface.js';
|
|
4
4
|
export declare class Bar implements ChartComponent {
|
|
5
5
|
readonly type: "bar";
|
|
@@ -7,8 +7,13 @@ export declare class Bar implements ChartComponent {
|
|
|
7
7
|
readonly fill: string;
|
|
8
8
|
readonly colorAdapter?: (data: DataItem, index: number) => string;
|
|
9
9
|
readonly orientation: Orientation;
|
|
10
|
+
readonly maxBarSize?: number;
|
|
11
|
+
readonly valueLabel?: BarValueLabelConfig;
|
|
10
12
|
constructor(config: BarConfig);
|
|
11
|
-
|
|
13
|
+
private getScaledPosition;
|
|
14
|
+
render(plotGroup: Selection<SVGGElement, undefined, null, undefined>, data: DataItem[], xKey: string, x: any, y: any, parseValue: (value: any) => number, xScaleType?: ScaleType, theme?: ChartTheme, stackingContext?: BarStackingContext): void;
|
|
12
15
|
private renderVertical;
|
|
13
16
|
private renderHorizontal;
|
|
17
|
+
private renderVerticalValueLabels;
|
|
18
|
+
private renderHorizontalValueLabels;
|
|
14
19
|
}
|
package/bar.js
CHANGED
|
@@ -30,43 +30,102 @@ export class Bar {
|
|
|
30
30
|
writable: true,
|
|
31
31
|
value: void 0
|
|
32
32
|
});
|
|
33
|
+
Object.defineProperty(this, "maxBarSize", {
|
|
34
|
+
enumerable: true,
|
|
35
|
+
configurable: true,
|
|
36
|
+
writable: true,
|
|
37
|
+
value: void 0
|
|
38
|
+
});
|
|
39
|
+
Object.defineProperty(this, "valueLabel", {
|
|
40
|
+
enumerable: true,
|
|
41
|
+
configurable: true,
|
|
42
|
+
writable: true,
|
|
43
|
+
value: void 0
|
|
44
|
+
});
|
|
33
45
|
this.dataKey = config.dataKey;
|
|
34
46
|
this.fill = config.fill || '#8884d8';
|
|
35
47
|
this.colorAdapter = config.colorAdapter;
|
|
36
48
|
this.orientation = config.orientation || 'vertical';
|
|
49
|
+
this.maxBarSize = config.maxBarSize;
|
|
50
|
+
this.valueLabel = config.valueLabel;
|
|
51
|
+
}
|
|
52
|
+
getScaledPosition(data, key, scale, scaleType) {
|
|
53
|
+
const value = data[key];
|
|
54
|
+
let scaledValue;
|
|
55
|
+
switch (scaleType) {
|
|
56
|
+
case 'band':
|
|
57
|
+
scaledValue = value;
|
|
58
|
+
break;
|
|
59
|
+
case 'time':
|
|
60
|
+
scaledValue = value instanceof Date ? value : new Date(value);
|
|
61
|
+
break;
|
|
62
|
+
case 'linear':
|
|
63
|
+
case 'log':
|
|
64
|
+
scaledValue = typeof value === 'number' ? value : Number(value);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
return scale(scaledValue) || 0;
|
|
37
68
|
}
|
|
38
|
-
render(plotGroup, data, xKey, x, y, parseValue, xScaleType = 'band',
|
|
69
|
+
render(plotGroup, data, xKey, x, y, parseValue, xScaleType = 'band', theme, stackingContext) {
|
|
39
70
|
if (this.orientation === 'vertical') {
|
|
40
|
-
this.renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType);
|
|
71
|
+
this.renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext);
|
|
41
72
|
}
|
|
42
73
|
else {
|
|
43
|
-
this.renderHorizontal(plotGroup, data, xKey, x, y, parseValue, xScaleType);
|
|
74
|
+
this.renderHorizontal(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext);
|
|
75
|
+
}
|
|
76
|
+
// Render value labels if enabled
|
|
77
|
+
if (this.valueLabel?.show && theme) {
|
|
78
|
+
if (this.orientation === 'vertical') {
|
|
79
|
+
this.renderVerticalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme, stackingContext);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
this.renderHorizontalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme, stackingContext);
|
|
83
|
+
}
|
|
44
84
|
}
|
|
45
85
|
}
|
|
46
|
-
renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType) {
|
|
47
|
-
const getXPosition = (d) => {
|
|
48
|
-
const xValue = d[xKey];
|
|
49
|
-
let scaledValue;
|
|
50
|
-
switch (xScaleType) {
|
|
51
|
-
case 'band':
|
|
52
|
-
scaledValue = xValue;
|
|
53
|
-
break;
|
|
54
|
-
case 'time':
|
|
55
|
-
scaledValue =
|
|
56
|
-
xValue instanceof Date ? xValue : new Date(xValue);
|
|
57
|
-
break;
|
|
58
|
-
case 'linear':
|
|
59
|
-
case 'log':
|
|
60
|
-
scaledValue =
|
|
61
|
-
typeof xValue === 'number' ? xValue : Number(xValue);
|
|
62
|
-
break;
|
|
63
|
-
}
|
|
64
|
-
return x(scaledValue) || 0;
|
|
65
|
-
};
|
|
86
|
+
renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext) {
|
|
66
87
|
const bandwidth = x.bandwidth ? x.bandwidth() : 20;
|
|
88
|
+
const mode = stackingContext?.mode ?? 'normal';
|
|
89
|
+
// Calculate bar width based on stacking mode
|
|
90
|
+
let barWidth;
|
|
91
|
+
let barOffset;
|
|
92
|
+
if (mode === 'none') {
|
|
93
|
+
// Grouped bars: divide bandwidth among series with gap
|
|
94
|
+
const totalSeries = stackingContext?.totalSeries ?? 1;
|
|
95
|
+
const seriesIndex = stackingContext?.seriesIndex ?? 0;
|
|
96
|
+
const gap = stackingContext?.gap ?? 0.1;
|
|
97
|
+
const groupWidth = this.maxBarSize
|
|
98
|
+
? Math.min(bandwidth, this.maxBarSize * totalSeries)
|
|
99
|
+
: bandwidth;
|
|
100
|
+
// Calculate total gap space and individual bar width
|
|
101
|
+
const totalGapSpace = groupWidth * gap * (totalSeries - 1);
|
|
102
|
+
const availableWidth = groupWidth - totalGapSpace;
|
|
103
|
+
barWidth = availableWidth / totalSeries;
|
|
104
|
+
const gapSize = totalSeries > 1 ? groupWidth * gap : 0;
|
|
105
|
+
barOffset =
|
|
106
|
+
(bandwidth - groupWidth) / 2 +
|
|
107
|
+
seriesIndex * (barWidth + gapSize);
|
|
108
|
+
}
|
|
109
|
+
else if (mode === 'layer') {
|
|
110
|
+
// Layer mode: each subsequent series has smaller bars
|
|
111
|
+
const totalSeries = stackingContext?.totalSeries ?? 1;
|
|
112
|
+
const seriesIndex = stackingContext?.seriesIndex ?? 0;
|
|
113
|
+
const maxWidth = this.maxBarSize
|
|
114
|
+
? Math.min(bandwidth, this.maxBarSize)
|
|
115
|
+
: bandwidth;
|
|
116
|
+
// Scale from 100% to a minimum (e.g., 30%) based on series position
|
|
117
|
+
const scaleFactor = 1 - (seriesIndex / totalSeries) * 0.7;
|
|
118
|
+
barWidth = maxWidth * scaleFactor;
|
|
119
|
+
barOffset = (bandwidth - barWidth) / 2;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
// Normal and Percent modes: full width stacked bars
|
|
123
|
+
barWidth = this.maxBarSize
|
|
124
|
+
? Math.min(bandwidth, this.maxBarSize)
|
|
125
|
+
: bandwidth;
|
|
126
|
+
barOffset = (bandwidth - barWidth) / 2;
|
|
127
|
+
}
|
|
67
128
|
// Get the baseline value from the Y scale's domain
|
|
68
|
-
// For linear scales, use 0 if it's in the domain, otherwise use domain max (bottom of chart)
|
|
69
|
-
// For log scales, use the minimum value from the domain
|
|
70
129
|
const yDomain = y.domain();
|
|
71
130
|
const baselineValue = yDomain[0] >= 0 ? Math.max(0, yDomain[0]) : yDomain[0];
|
|
72
131
|
const yBaseline = y(baselineValue) || 0;
|
|
@@ -77,45 +136,98 @@ export class Bar {
|
|
|
77
136
|
.join('rect')
|
|
78
137
|
.attr('class', `bar-${this.dataKey.replace(/\s+/g, '-')}`)
|
|
79
138
|
.attr('x', (d) => {
|
|
80
|
-
const xPos =
|
|
81
|
-
|
|
82
|
-
|
|
139
|
+
const xPos = this.getScaledPosition(d, xKey, x, xScaleType);
|
|
140
|
+
return xScaleType === 'band'
|
|
141
|
+
? xPos + barOffset
|
|
142
|
+
: xPos - barWidth / 2;
|
|
83
143
|
})
|
|
84
144
|
.attr('y', (d) => {
|
|
85
|
-
const
|
|
86
|
-
|
|
145
|
+
const categoryKey = String(d[xKey]);
|
|
146
|
+
const value = parseValue(d[this.dataKey]);
|
|
147
|
+
if (mode === 'none' || mode === 'layer') {
|
|
148
|
+
// No stacking - each bar starts from baseline
|
|
149
|
+
const yPos = y(value) || 0;
|
|
150
|
+
return Math.min(yBaseline, yPos);
|
|
151
|
+
}
|
|
152
|
+
else if (mode === 'percent') {
|
|
153
|
+
// Percent mode: calculate position based on cumulative percentage
|
|
154
|
+
const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
|
|
155
|
+
const total = stackingContext?.totalData.get(categoryKey) ?? 1;
|
|
156
|
+
const percentCumulative = (cumulative / total) * 100;
|
|
157
|
+
const percentValue = (value / total) * 100;
|
|
158
|
+
return y(percentCumulative + percentValue) || 0;
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
// Normal stacking mode
|
|
162
|
+
const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
|
|
163
|
+
return y(cumulative + value) || 0;
|
|
164
|
+
}
|
|
87
165
|
})
|
|
88
|
-
.attr('width',
|
|
166
|
+
.attr('width', barWidth)
|
|
89
167
|
.attr('height', (d) => {
|
|
90
|
-
const
|
|
91
|
-
|
|
168
|
+
const categoryKey = String(d[xKey]);
|
|
169
|
+
const value = parseValue(d[this.dataKey]);
|
|
170
|
+
if (mode === 'none' || mode === 'layer') {
|
|
171
|
+
const yPos = y(value) || 0;
|
|
172
|
+
return Math.abs(yBaseline - yPos);
|
|
173
|
+
}
|
|
174
|
+
else if (mode === 'percent') {
|
|
175
|
+
const total = stackingContext?.totalData.get(categoryKey) ?? 1;
|
|
176
|
+
const percentValue = (value / total) * 100;
|
|
177
|
+
const yTop = y(percentValue) || 0;
|
|
178
|
+
const yBottom = y(0) || 0;
|
|
179
|
+
return Math.abs(yBottom - yTop);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
// Normal stacking mode
|
|
183
|
+
const yTop = y(value) || 0;
|
|
184
|
+
return Math.abs(yBaseline - yTop);
|
|
185
|
+
}
|
|
92
186
|
})
|
|
93
187
|
.attr('fill', (d, i) => this.colorAdapter ? this.colorAdapter(d, i) : this.fill);
|
|
94
188
|
}
|
|
95
|
-
renderHorizontal(plotGroup, data, xKey, x, y, parseValue, yScaleType) {
|
|
96
|
-
const getYPosition = (d) => {
|
|
97
|
-
const yValue = d[xKey];
|
|
98
|
-
let scaledValue;
|
|
99
|
-
switch (yScaleType) {
|
|
100
|
-
case 'band':
|
|
101
|
-
scaledValue = yValue;
|
|
102
|
-
break;
|
|
103
|
-
case 'time':
|
|
104
|
-
scaledValue =
|
|
105
|
-
yValue instanceof Date ? yValue : new Date(yValue);
|
|
106
|
-
break;
|
|
107
|
-
case 'linear':
|
|
108
|
-
case 'log':
|
|
109
|
-
scaledValue =
|
|
110
|
-
typeof yValue === 'number' ? yValue : Number(yValue);
|
|
111
|
-
break;
|
|
112
|
-
}
|
|
113
|
-
return y(scaledValue) || 0;
|
|
114
|
-
};
|
|
189
|
+
renderHorizontal(plotGroup, data, xKey, x, y, parseValue, yScaleType, stackingContext) {
|
|
115
190
|
const bandwidth = y.bandwidth ? y.bandwidth() : 20;
|
|
191
|
+
const mode = stackingContext?.mode ?? 'normal';
|
|
192
|
+
// Calculate bar height based on stacking mode
|
|
193
|
+
let barHeight;
|
|
194
|
+
let barOffset;
|
|
195
|
+
if (mode === 'none') {
|
|
196
|
+
// Grouped bars: divide bandwidth among series with gap
|
|
197
|
+
const totalSeries = stackingContext?.totalSeries ?? 1;
|
|
198
|
+
const seriesIndex = stackingContext?.seriesIndex ?? 0;
|
|
199
|
+
const gap = stackingContext?.gap ?? 0.1;
|
|
200
|
+
const groupHeight = this.maxBarSize
|
|
201
|
+
? Math.min(bandwidth, this.maxBarSize * totalSeries)
|
|
202
|
+
: bandwidth;
|
|
203
|
+
// Calculate total gap space and individual bar height
|
|
204
|
+
const totalGapSpace = groupHeight * gap * (totalSeries - 1);
|
|
205
|
+
const availableHeight = groupHeight - totalGapSpace;
|
|
206
|
+
barHeight = availableHeight / totalSeries;
|
|
207
|
+
const gapSize = totalSeries > 1 ? groupHeight * gap : 0;
|
|
208
|
+
barOffset =
|
|
209
|
+
(bandwidth - groupHeight) / 2 +
|
|
210
|
+
seriesIndex * (barHeight + gapSize);
|
|
211
|
+
}
|
|
212
|
+
else if (mode === 'layer') {
|
|
213
|
+
// Layer mode: each subsequent series has smaller bars
|
|
214
|
+
const totalSeries = stackingContext?.totalSeries ?? 1;
|
|
215
|
+
const seriesIndex = stackingContext?.seriesIndex ?? 0;
|
|
216
|
+
const maxHeight = this.maxBarSize
|
|
217
|
+
? Math.min(bandwidth, this.maxBarSize)
|
|
218
|
+
: bandwidth;
|
|
219
|
+
const scaleFactor = 1 - (seriesIndex / totalSeries) * 0.7;
|
|
220
|
+
barHeight = maxHeight * scaleFactor;
|
|
221
|
+
barOffset = (bandwidth - barHeight) / 2;
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
// Normal and Percent modes: full height stacked bars
|
|
225
|
+
barHeight = this.maxBarSize
|
|
226
|
+
? Math.min(bandwidth, this.maxBarSize)
|
|
227
|
+
: bandwidth;
|
|
228
|
+
barOffset = (bandwidth - barHeight) / 2;
|
|
229
|
+
}
|
|
116
230
|
// Get the baseline value from the scale's domain
|
|
117
|
-
// For linear scales, use 0 if it's in the domain, otherwise use domain min
|
|
118
|
-
// For log scales, use the minimum value from the domain
|
|
119
231
|
const domain = x.domain();
|
|
120
232
|
const baselineValue = domain[0] >= 0 ? Math.max(0, domain[0]) : domain[0];
|
|
121
233
|
const xBaseline = x(baselineValue) || 0;
|
|
@@ -126,19 +238,366 @@ export class Bar {
|
|
|
126
238
|
.join('rect')
|
|
127
239
|
.attr('class', `bar-${this.dataKey.replace(/\s+/g, '-')}`)
|
|
128
240
|
.attr('x', (d) => {
|
|
129
|
-
const
|
|
130
|
-
|
|
241
|
+
const categoryKey = String(d[xKey]);
|
|
242
|
+
const value = parseValue(d[this.dataKey]);
|
|
243
|
+
if (mode === 'none' || mode === 'layer') {
|
|
244
|
+
const xPos = x(value) || 0;
|
|
245
|
+
return Math.min(xBaseline, xPos);
|
|
246
|
+
}
|
|
247
|
+
else if (mode === 'percent') {
|
|
248
|
+
const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
|
|
249
|
+
const total = stackingContext?.totalData.get(categoryKey) ?? 1;
|
|
250
|
+
const percentCumulative = (cumulative / total) * 100;
|
|
251
|
+
return x(percentCumulative) || 0;
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
// Normal stacking mode
|
|
255
|
+
const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
|
|
256
|
+
return x(cumulative) || 0;
|
|
257
|
+
}
|
|
131
258
|
})
|
|
132
259
|
.attr('y', (d) => {
|
|
133
|
-
const yPos =
|
|
134
|
-
|
|
135
|
-
|
|
260
|
+
const yPos = this.getScaledPosition(d, xKey, y, yScaleType);
|
|
261
|
+
return yScaleType === 'band'
|
|
262
|
+
? yPos + barOffset
|
|
263
|
+
: yPos - barHeight / 2;
|
|
136
264
|
})
|
|
137
265
|
.attr('width', (d) => {
|
|
138
|
-
const
|
|
139
|
-
|
|
266
|
+
const categoryKey = String(d[xKey]);
|
|
267
|
+
const value = parseValue(d[this.dataKey]);
|
|
268
|
+
if (mode === 'none' || mode === 'layer') {
|
|
269
|
+
const xPos = x(value) || 0;
|
|
270
|
+
return Math.abs(xPos - xBaseline);
|
|
271
|
+
}
|
|
272
|
+
else if (mode === 'percent') {
|
|
273
|
+
const total = stackingContext?.totalData.get(categoryKey) ?? 1;
|
|
274
|
+
const percentValue = (value / total) * 100;
|
|
275
|
+
const xLeft = x(0) || 0;
|
|
276
|
+
const xRight = x(percentValue) || 0;
|
|
277
|
+
return Math.abs(xRight - xLeft);
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
// Normal stacking mode
|
|
281
|
+
const xLeft = x(0) || 0;
|
|
282
|
+
const xRight = x(value) || 0;
|
|
283
|
+
return Math.abs(xRight - xLeft);
|
|
284
|
+
}
|
|
140
285
|
})
|
|
141
|
-
.attr('height',
|
|
286
|
+
.attr('height', barHeight)
|
|
142
287
|
.attr('fill', (d, i) => this.colorAdapter ? this.colorAdapter(d, i) : this.fill);
|
|
143
288
|
}
|
|
289
|
+
renderVerticalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme, stackingContext) {
|
|
290
|
+
const bandwidth = x.bandwidth ? x.bandwidth() : 20;
|
|
291
|
+
const mode = stackingContext?.mode ?? 'normal';
|
|
292
|
+
// Calculate bar width based on stacking mode (same logic as renderVertical)
|
|
293
|
+
let barWidth;
|
|
294
|
+
let barOffset;
|
|
295
|
+
if (mode === 'none') {
|
|
296
|
+
const totalSeries = stackingContext?.totalSeries ?? 1;
|
|
297
|
+
const seriesIndex = stackingContext?.seriesIndex ?? 0;
|
|
298
|
+
const gap = stackingContext?.gap ?? 0.1;
|
|
299
|
+
const groupWidth = this.maxBarSize
|
|
300
|
+
? Math.min(bandwidth, this.maxBarSize * totalSeries)
|
|
301
|
+
: bandwidth;
|
|
302
|
+
const totalGapSpace = groupWidth * gap * (totalSeries - 1);
|
|
303
|
+
const availableWidth = groupWidth - totalGapSpace;
|
|
304
|
+
barWidth = availableWidth / totalSeries;
|
|
305
|
+
const gapSize = totalSeries > 1 ? groupWidth * gap : 0;
|
|
306
|
+
barOffset =
|
|
307
|
+
(bandwidth - groupWidth) / 2 +
|
|
308
|
+
seriesIndex * (barWidth + gapSize);
|
|
309
|
+
}
|
|
310
|
+
else if (mode === 'layer') {
|
|
311
|
+
const totalSeries = stackingContext?.totalSeries ?? 1;
|
|
312
|
+
const seriesIndex = stackingContext?.seriesIndex ?? 0;
|
|
313
|
+
const maxWidth = this.maxBarSize
|
|
314
|
+
? Math.min(bandwidth, this.maxBarSize)
|
|
315
|
+
: bandwidth;
|
|
316
|
+
const scaleFactor = 1 - (seriesIndex / totalSeries) * 0.7;
|
|
317
|
+
barWidth = maxWidth * scaleFactor;
|
|
318
|
+
barOffset = (bandwidth - barWidth) / 2;
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
barWidth = this.maxBarSize
|
|
322
|
+
? Math.min(bandwidth, this.maxBarSize)
|
|
323
|
+
: bandwidth;
|
|
324
|
+
barOffset = (bandwidth - barWidth) / 2;
|
|
325
|
+
}
|
|
326
|
+
const yDomain = y.domain();
|
|
327
|
+
const baselineValue = yDomain[0] >= 0 ? Math.max(0, yDomain[0]) : yDomain[0];
|
|
328
|
+
const yBaseline = y(baselineValue) || 0;
|
|
329
|
+
const config = this.valueLabel;
|
|
330
|
+
const position = config.position || 'outside';
|
|
331
|
+
const insidePosition = config.insidePosition || 'top';
|
|
332
|
+
const fontSize = config.fontSize ?? theme.valueLabel.fontSize;
|
|
333
|
+
const fontFamily = config.fontFamily ?? theme.valueLabel.fontFamily;
|
|
334
|
+
const fontWeight = config.fontWeight ?? theme.valueLabel.fontWeight;
|
|
335
|
+
const color = config.color ?? theme.valueLabel.color;
|
|
336
|
+
const background = config.background ?? theme.valueLabel.background;
|
|
337
|
+
const border = config.border ?? theme.valueLabel.border;
|
|
338
|
+
const borderRadius = config.borderRadius ?? theme.valueLabel.borderRadius;
|
|
339
|
+
const padding = config.padding ?? theme.valueLabel.padding;
|
|
340
|
+
const labelGroup = plotGroup
|
|
341
|
+
.append('g')
|
|
342
|
+
.attr('class', `bar-value-labels-${this.dataKey.replace(/\s+/g, '-')}`);
|
|
343
|
+
data.forEach((d) => {
|
|
344
|
+
const categoryKey = String(d[xKey]);
|
|
345
|
+
const value = parseValue(d[this.dataKey]);
|
|
346
|
+
const valueText = String(value);
|
|
347
|
+
const xPos = this.getScaledPosition(d, xKey, x, xScaleType);
|
|
348
|
+
// Calculate bar position based on stacking mode
|
|
349
|
+
let barTop;
|
|
350
|
+
let barBottom;
|
|
351
|
+
if (mode === 'none' || mode === 'layer') {
|
|
352
|
+
const yPos = y(value) || 0;
|
|
353
|
+
barTop = Math.min(yBaseline, yPos);
|
|
354
|
+
barBottom = Math.max(yBaseline, yPos);
|
|
355
|
+
}
|
|
356
|
+
else if (mode === 'percent') {
|
|
357
|
+
const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
|
|
358
|
+
const total = stackingContext?.totalData.get(categoryKey) ?? 1;
|
|
359
|
+
const percentCumulative = (cumulative / total) * 100;
|
|
360
|
+
const percentValue = (value / total) * 100;
|
|
361
|
+
barTop = y(percentCumulative + percentValue) || 0;
|
|
362
|
+
barBottom = y(percentCumulative) || 0;
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
|
|
366
|
+
barTop = y(cumulative + value) || 0;
|
|
367
|
+
barBottom = y(cumulative) || 0;
|
|
368
|
+
}
|
|
369
|
+
const barHeight = Math.abs(barBottom - barTop);
|
|
370
|
+
const barCenterX = xPos +
|
|
371
|
+
(xScaleType === 'band' ? barOffset : -barWidth / 2) +
|
|
372
|
+
barWidth / 2;
|
|
373
|
+
// Create temporary text to measure dimensions
|
|
374
|
+
const tempText = labelGroup
|
|
375
|
+
.append('text')
|
|
376
|
+
.style('font-size', `${fontSize}px`)
|
|
377
|
+
.style('font-family', fontFamily)
|
|
378
|
+
.style('font-weight', fontWeight)
|
|
379
|
+
.text(valueText);
|
|
380
|
+
const textBBox = tempText.node().getBBox();
|
|
381
|
+
const boxWidth = textBBox.width + padding * 2;
|
|
382
|
+
const boxHeight = textBBox.height + padding * 2;
|
|
383
|
+
let labelX = barCenterX;
|
|
384
|
+
let labelY;
|
|
385
|
+
let shouldRender = true;
|
|
386
|
+
if (position === 'outside') {
|
|
387
|
+
// Place above the bar
|
|
388
|
+
labelY = barTop - boxHeight / 2 - 4;
|
|
389
|
+
// Check if it fits (not going above plot area)
|
|
390
|
+
const plotTop = y.range()[1];
|
|
391
|
+
if (labelY - boxHeight / 2 < plotTop) {
|
|
392
|
+
shouldRender = false;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
// Inside the bar
|
|
397
|
+
switch (insidePosition) {
|
|
398
|
+
case 'top':
|
|
399
|
+
labelY = barTop + boxHeight / 2 + 4;
|
|
400
|
+
break;
|
|
401
|
+
case 'middle':
|
|
402
|
+
labelY = (barTop + barBottom) / 2;
|
|
403
|
+
break;
|
|
404
|
+
case 'bottom':
|
|
405
|
+
labelY = barBottom - boxHeight / 2 - 4;
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
// Check if it fits inside the bar
|
|
409
|
+
if (boxHeight + 8 > barHeight) {
|
|
410
|
+
shouldRender = false;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
tempText.remove();
|
|
414
|
+
if (shouldRender) {
|
|
415
|
+
const group = labelGroup.append('g');
|
|
416
|
+
if (position === 'outside') {
|
|
417
|
+
// Draw rounded rectangle background
|
|
418
|
+
group
|
|
419
|
+
.append('rect')
|
|
420
|
+
.attr('x', labelX - boxWidth / 2)
|
|
421
|
+
.attr('y', labelY - boxHeight / 2)
|
|
422
|
+
.attr('width', boxWidth)
|
|
423
|
+
.attr('height', boxHeight)
|
|
424
|
+
.attr('rx', borderRadius)
|
|
425
|
+
.attr('ry', borderRadius)
|
|
426
|
+
.attr('fill', background)
|
|
427
|
+
.attr('stroke', border)
|
|
428
|
+
.attr('stroke-width', 1);
|
|
429
|
+
}
|
|
430
|
+
// Draw text
|
|
431
|
+
group
|
|
432
|
+
.append('text')
|
|
433
|
+
.attr('x', labelX)
|
|
434
|
+
.attr('y', labelY)
|
|
435
|
+
.attr('text-anchor', 'middle')
|
|
436
|
+
.attr('dominant-baseline', 'central')
|
|
437
|
+
.style('font-size', `${fontSize}px`)
|
|
438
|
+
.style('font-family', fontFamily)
|
|
439
|
+
.style('font-weight', fontWeight)
|
|
440
|
+
.style('fill', color)
|
|
441
|
+
.style('pointer-events', 'none')
|
|
442
|
+
.text(valueText);
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
renderHorizontalValueLabels(plotGroup, data, xKey, x, y, parseValue, yScaleType, theme, stackingContext) {
|
|
447
|
+
const bandwidth = y.bandwidth ? y.bandwidth() : 20;
|
|
448
|
+
const mode = stackingContext?.mode ?? 'normal';
|
|
449
|
+
// Calculate bar height based on stacking mode (same logic as renderHorizontal)
|
|
450
|
+
let barHeight;
|
|
451
|
+
let barOffset;
|
|
452
|
+
if (mode === 'none') {
|
|
453
|
+
const totalSeries = stackingContext?.totalSeries ?? 1;
|
|
454
|
+
const seriesIndex = stackingContext?.seriesIndex ?? 0;
|
|
455
|
+
const gap = stackingContext?.gap ?? 0.1;
|
|
456
|
+
const groupHeight = this.maxBarSize
|
|
457
|
+
? Math.min(bandwidth, this.maxBarSize * totalSeries)
|
|
458
|
+
: bandwidth;
|
|
459
|
+
const totalGapSpace = groupHeight * gap * (totalSeries - 1);
|
|
460
|
+
const availableHeight = groupHeight - totalGapSpace;
|
|
461
|
+
barHeight = availableHeight / totalSeries;
|
|
462
|
+
const gapSize = totalSeries > 1 ? groupHeight * gap : 0;
|
|
463
|
+
barOffset =
|
|
464
|
+
(bandwidth - groupHeight) / 2 +
|
|
465
|
+
seriesIndex * (barHeight + gapSize);
|
|
466
|
+
}
|
|
467
|
+
else if (mode === 'layer') {
|
|
468
|
+
const totalSeries = stackingContext?.totalSeries ?? 1;
|
|
469
|
+
const seriesIndex = stackingContext?.seriesIndex ?? 0;
|
|
470
|
+
const maxHeight = this.maxBarSize
|
|
471
|
+
? Math.min(bandwidth, this.maxBarSize)
|
|
472
|
+
: bandwidth;
|
|
473
|
+
const scaleFactor = 1 - (seriesIndex / totalSeries) * 0.7;
|
|
474
|
+
barHeight = maxHeight * scaleFactor;
|
|
475
|
+
barOffset = (bandwidth - barHeight) / 2;
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
barHeight = this.maxBarSize
|
|
479
|
+
? Math.min(bandwidth, this.maxBarSize)
|
|
480
|
+
: bandwidth;
|
|
481
|
+
barOffset = (bandwidth - barHeight) / 2;
|
|
482
|
+
}
|
|
483
|
+
const domain = x.domain();
|
|
484
|
+
const baselineValue = domain[0] >= 0 ? Math.max(0, domain[0]) : domain[0];
|
|
485
|
+
const xBaseline = x(baselineValue) || 0;
|
|
486
|
+
const config = this.valueLabel;
|
|
487
|
+
const position = config.position || 'outside';
|
|
488
|
+
const insidePosition = config.insidePosition || 'top';
|
|
489
|
+
const fontSize = config.fontSize ?? theme.valueLabel.fontSize;
|
|
490
|
+
const fontFamily = config.fontFamily ?? theme.valueLabel.fontFamily;
|
|
491
|
+
const fontWeight = config.fontWeight ?? theme.valueLabel.fontWeight;
|
|
492
|
+
const color = config.color ?? theme.valueLabel.color;
|
|
493
|
+
const background = config.background ?? theme.valueLabel.background;
|
|
494
|
+
const border = config.border ?? theme.valueLabel.border;
|
|
495
|
+
const borderRadius = config.borderRadius ?? theme.valueLabel.borderRadius;
|
|
496
|
+
const padding = config.padding ?? theme.valueLabel.padding;
|
|
497
|
+
const labelGroup = plotGroup
|
|
498
|
+
.append('g')
|
|
499
|
+
.attr('class', `bar-value-labels-${this.dataKey.replace(/\s+/g, '-')}`);
|
|
500
|
+
data.forEach((d) => {
|
|
501
|
+
const categoryKey = String(d[xKey]);
|
|
502
|
+
const value = parseValue(d[this.dataKey]);
|
|
503
|
+
const valueText = String(value);
|
|
504
|
+
const yPos = this.getScaledPosition(d, xKey, y, yScaleType);
|
|
505
|
+
// Calculate bar position based on stacking mode
|
|
506
|
+
let barLeft;
|
|
507
|
+
let barRight;
|
|
508
|
+
if (mode === 'none' || mode === 'layer') {
|
|
509
|
+
const xPos = x(value) || 0;
|
|
510
|
+
barLeft = Math.min(xBaseline, xPos);
|
|
511
|
+
barRight = Math.max(xBaseline, xPos);
|
|
512
|
+
}
|
|
513
|
+
else if (mode === 'percent') {
|
|
514
|
+
const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
|
|
515
|
+
const total = stackingContext?.totalData.get(categoryKey) ?? 1;
|
|
516
|
+
const percentCumulative = (cumulative / total) * 100;
|
|
517
|
+
const percentValue = (value / total) * 100;
|
|
518
|
+
barLeft = x(percentCumulative) || 0;
|
|
519
|
+
barRight = x(percentCumulative + percentValue) || 0;
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
|
|
523
|
+
barLeft = x(cumulative) || 0;
|
|
524
|
+
barRight = x(cumulative + value) || 0;
|
|
525
|
+
}
|
|
526
|
+
const barWidth = Math.abs(barRight - barLeft);
|
|
527
|
+
const barCenterY = yPos +
|
|
528
|
+
(yScaleType === 'band' ? barOffset : -barHeight / 2) +
|
|
529
|
+
barHeight / 2;
|
|
530
|
+
// Create temporary text to measure dimensions
|
|
531
|
+
const tempText = labelGroup
|
|
532
|
+
.append('text')
|
|
533
|
+
.style('font-size', `${fontSize}px`)
|
|
534
|
+
.style('font-family', fontFamily)
|
|
535
|
+
.style('font-weight', fontWeight)
|
|
536
|
+
.text(valueText);
|
|
537
|
+
const textBBox = tempText.node().getBBox();
|
|
538
|
+
const boxWidth = textBBox.width + padding * 2;
|
|
539
|
+
const boxHeight = textBBox.height + padding * 2;
|
|
540
|
+
let labelX;
|
|
541
|
+
let labelY = barCenterY;
|
|
542
|
+
let shouldRender = true;
|
|
543
|
+
if (position === 'outside') {
|
|
544
|
+
// Place to the right of the bar
|
|
545
|
+
labelX = barRight + boxWidth / 2 + 4;
|
|
546
|
+
// Check if it fits (not going beyond plot area)
|
|
547
|
+
const plotRight = x.range()[1];
|
|
548
|
+
if (labelX + boxWidth / 2 > plotRight) {
|
|
549
|
+
shouldRender = false;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
// Inside the bar - map top/middle/bottom to start/middle/end for horizontal
|
|
554
|
+
switch (insidePosition) {
|
|
555
|
+
case 'top': // start of bar (left side)
|
|
556
|
+
labelX = barLeft + boxWidth / 2 + 4;
|
|
557
|
+
break;
|
|
558
|
+
case 'middle':
|
|
559
|
+
labelX = (barLeft + barRight) / 2;
|
|
560
|
+
break;
|
|
561
|
+
case 'bottom': // end of bar (right side)
|
|
562
|
+
labelX = barRight - boxWidth / 2 - 4;
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
// Check if it fits inside the bar
|
|
566
|
+
if (boxWidth + 8 > barWidth) {
|
|
567
|
+
shouldRender = false;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
tempText.remove();
|
|
571
|
+
if (shouldRender) {
|
|
572
|
+
const group = labelGroup.append('g');
|
|
573
|
+
if (position === 'outside') {
|
|
574
|
+
// Draw rounded rectangle background
|
|
575
|
+
group
|
|
576
|
+
.append('rect')
|
|
577
|
+
.attr('x', labelX - boxWidth / 2)
|
|
578
|
+
.attr('y', labelY - boxHeight / 2)
|
|
579
|
+
.attr('width', boxWidth)
|
|
580
|
+
.attr('height', boxHeight)
|
|
581
|
+
.attr('rx', borderRadius)
|
|
582
|
+
.attr('ry', borderRadius)
|
|
583
|
+
.attr('fill', background)
|
|
584
|
+
.attr('stroke', border)
|
|
585
|
+
.attr('stroke-width', 1);
|
|
586
|
+
}
|
|
587
|
+
// Draw text
|
|
588
|
+
group
|
|
589
|
+
.append('text')
|
|
590
|
+
.attr('x', labelX)
|
|
591
|
+
.attr('y', labelY)
|
|
592
|
+
.attr('text-anchor', 'middle')
|
|
593
|
+
.attr('dominant-baseline', 'central')
|
|
594
|
+
.style('font-size', `${fontSize}px`)
|
|
595
|
+
.style('font-family', fontFamily)
|
|
596
|
+
.style('font-weight', fontWeight)
|
|
597
|
+
.style('fill', color)
|
|
598
|
+
.style('pointer-events', 'none')
|
|
599
|
+
.text(valueText);
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
}
|
|
144
603
|
}
|
package/base-chart.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Selection } from 'd3';
|
|
2
|
-
import type { DataItem, ChartTheme, AxisScaleConfig } from './types.js';
|
|
2
|
+
import type { DataItem, ChartTheme, AxisScaleConfig, ExportFormat } from './types.js';
|
|
3
3
|
import type { ChartComponent } from './chart-interface.js';
|
|
4
4
|
import type { XAxis } from './x-axis.js';
|
|
5
5
|
import type { YAxis } from './y-axis.js';
|
|
@@ -69,4 +69,10 @@ export declare abstract class BaseChart {
|
|
|
69
69
|
*/
|
|
70
70
|
destroy(): void;
|
|
71
71
|
protected parseValue(value: any): number;
|
|
72
|
+
/**
|
|
73
|
+
* Exports the chart in the specified format
|
|
74
|
+
*/
|
|
75
|
+
export(format: ExportFormat): string;
|
|
76
|
+
protected exportSVG(): string;
|
|
77
|
+
protected exportJSON(): string;
|
|
72
78
|
}
|
package/base-chart.js
CHANGED
|
@@ -224,4 +224,30 @@ export class BaseChart {
|
|
|
224
224
|
parseValue(value) {
|
|
225
225
|
return typeof value === 'string' ? parseFloat(value) : value;
|
|
226
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* Exports the chart in the specified format
|
|
229
|
+
*/
|
|
230
|
+
export(format) {
|
|
231
|
+
if (format === 'svg') {
|
|
232
|
+
return this.exportSVG();
|
|
233
|
+
}
|
|
234
|
+
return this.exportJSON();
|
|
235
|
+
}
|
|
236
|
+
exportSVG() {
|
|
237
|
+
if (!this.svg) {
|
|
238
|
+
throw new Error('Chart must be rendered before export');
|
|
239
|
+
}
|
|
240
|
+
const clone = this.svg.node().cloneNode(true);
|
|
241
|
+
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
242
|
+
clone.setAttribute('width', String(this.width));
|
|
243
|
+
clone.setAttribute('height', String(this.theme.height));
|
|
244
|
+
return clone.outerHTML;
|
|
245
|
+
}
|
|
246
|
+
exportJSON() {
|
|
247
|
+
return JSON.stringify({
|
|
248
|
+
data: this.data,
|
|
249
|
+
theme: this.theme,
|
|
250
|
+
scales: this.scaleConfig,
|
|
251
|
+
}, null, 2);
|
|
252
|
+
}
|
|
227
253
|
}
|
package/line.d.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { type Selection } from 'd3';
|
|
2
|
-
import type { LineConfig, DataItem, ScaleType, ChartTheme } from './types.js';
|
|
2
|
+
import type { LineConfig, DataItem, ScaleType, ChartTheme, LineValueLabelConfig } from './types.js';
|
|
3
3
|
import type { ChartComponent } from './chart-interface.js';
|
|
4
4
|
export declare class Line implements ChartComponent {
|
|
5
5
|
readonly type: "line";
|
|
6
6
|
readonly dataKey: string;
|
|
7
7
|
readonly stroke: string;
|
|
8
8
|
readonly strokeWidth?: number;
|
|
9
|
+
readonly valueLabel?: LineValueLabelConfig;
|
|
9
10
|
constructor(config: LineConfig);
|
|
10
11
|
render(plotGroup: Selection<SVGGElement, undefined, null, undefined>, data: DataItem[], xKey: string, x: any, y: any, parseValue: (value: any) => number, xScaleType: ScaleType | undefined, theme: ChartTheme): void;
|
|
12
|
+
private renderValueLabels;
|
|
11
13
|
}
|
package/line.js
CHANGED
|
@@ -25,9 +25,16 @@ export class Line {
|
|
|
25
25
|
writable: true,
|
|
26
26
|
value: void 0
|
|
27
27
|
});
|
|
28
|
+
Object.defineProperty(this, "valueLabel", {
|
|
29
|
+
enumerable: true,
|
|
30
|
+
configurable: true,
|
|
31
|
+
writable: true,
|
|
32
|
+
value: void 0
|
|
33
|
+
});
|
|
28
34
|
this.dataKey = config.dataKey;
|
|
29
35
|
this.stroke = config.stroke || '#8884d8';
|
|
30
36
|
this.strokeWidth = config.strokeWidth;
|
|
37
|
+
this.valueLabel = config.valueLabel;
|
|
31
38
|
}
|
|
32
39
|
render(plotGroup, data, xKey, x, y, parseValue, xScaleType = 'band', theme) {
|
|
33
40
|
const getXPosition = (d) => {
|
|
@@ -83,5 +90,83 @@ export class Line {
|
|
|
83
90
|
.attr('fill', pointColor)
|
|
84
91
|
.attr('stroke', pointStrokeColor)
|
|
85
92
|
.attr('stroke-width', pointStrokeWidth);
|
|
93
|
+
// Render value labels if enabled
|
|
94
|
+
if (this.valueLabel?.show) {
|
|
95
|
+
this.renderValueLabels(plotGroup, data, y, parseValue, theme, getXPosition);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
renderValueLabels(plotGroup, data, y, parseValue, theme, getXPosition) {
|
|
99
|
+
const config = this.valueLabel;
|
|
100
|
+
const fontSize = config.fontSize ?? theme.valueLabel.fontSize;
|
|
101
|
+
const fontFamily = config.fontFamily ?? theme.valueLabel.fontFamily;
|
|
102
|
+
const fontWeight = config.fontWeight ?? theme.valueLabel.fontWeight;
|
|
103
|
+
const color = config.color ?? theme.valueLabel.color;
|
|
104
|
+
const background = config.background ?? theme.valueLabel.background;
|
|
105
|
+
const border = config.border ?? theme.valueLabel.border;
|
|
106
|
+
const borderRadius = config.borderRadius ?? theme.valueLabel.borderRadius;
|
|
107
|
+
const padding = config.padding ?? theme.valueLabel.padding;
|
|
108
|
+
const labelGroup = plotGroup
|
|
109
|
+
.append('g')
|
|
110
|
+
.attr('class', `line-value-labels-${this.dataKey.replace(/\s+/g, '-')}`);
|
|
111
|
+
const plotTop = y.range()[1];
|
|
112
|
+
const plotBottom = y.range()[0];
|
|
113
|
+
data.forEach((d) => {
|
|
114
|
+
const value = parseValue(d[this.dataKey]);
|
|
115
|
+
const valueText = String(value);
|
|
116
|
+
const xPos = getXPosition(d);
|
|
117
|
+
const yPos = y(value) || 0;
|
|
118
|
+
// Create temporary text to measure dimensions
|
|
119
|
+
const tempText = labelGroup
|
|
120
|
+
.append('text')
|
|
121
|
+
.style('font-size', `${fontSize}px`)
|
|
122
|
+
.style('font-family', fontFamily)
|
|
123
|
+
.style('font-weight', fontWeight)
|
|
124
|
+
.text(valueText);
|
|
125
|
+
const textBBox = tempText.node().getBBox();
|
|
126
|
+
const boxWidth = textBBox.width + padding * 2;
|
|
127
|
+
const boxHeight = textBBox.height + padding * 2;
|
|
128
|
+
let labelX = xPos;
|
|
129
|
+
let labelY;
|
|
130
|
+
let shouldRender = true;
|
|
131
|
+
// Default: place above the point
|
|
132
|
+
labelY = yPos - boxHeight / 2 - theme.line.point.size - 4;
|
|
133
|
+
// If too close to top, place below instead
|
|
134
|
+
if (labelY - boxHeight / 2 < plotTop + 4) {
|
|
135
|
+
labelY = yPos + boxHeight / 2 + theme.line.point.size + 4;
|
|
136
|
+
// Check if it fits below
|
|
137
|
+
if (labelY + boxHeight / 2 > plotBottom - 4) {
|
|
138
|
+
shouldRender = false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
tempText.remove();
|
|
142
|
+
if (shouldRender) {
|
|
143
|
+
const group = labelGroup.append('g');
|
|
144
|
+
// Draw rounded rectangle background
|
|
145
|
+
group
|
|
146
|
+
.append('rect')
|
|
147
|
+
.attr('x', labelX - boxWidth / 2)
|
|
148
|
+
.attr('y', labelY - boxHeight / 2)
|
|
149
|
+
.attr('width', boxWidth)
|
|
150
|
+
.attr('height', boxHeight)
|
|
151
|
+
.attr('rx', borderRadius)
|
|
152
|
+
.attr('ry', borderRadius)
|
|
153
|
+
.attr('fill', background)
|
|
154
|
+
.attr('stroke', border)
|
|
155
|
+
.attr('stroke-width', 1);
|
|
156
|
+
// Draw text
|
|
157
|
+
group
|
|
158
|
+
.append('text')
|
|
159
|
+
.attr('x', labelX)
|
|
160
|
+
.attr('y', labelY)
|
|
161
|
+
.attr('text-anchor', 'middle')
|
|
162
|
+
.attr('dominant-baseline', 'central')
|
|
163
|
+
.style('font-size', `${fontSize}px`)
|
|
164
|
+
.style('font-family', fontFamily)
|
|
165
|
+
.style('font-weight', fontWeight)
|
|
166
|
+
.style('fill', color)
|
|
167
|
+
.style('pointer-events', 'none')
|
|
168
|
+
.text(valueText);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
86
171
|
}
|
|
87
172
|
}
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.0.
|
|
2
|
+
"version": "0.0.9",
|
|
3
3
|
"name": "@internetstiftelsen/charts",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"pub": "npm run prepub && cd dist && npm publish --access public"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
+
"@handsontable/react-wrapper": "^16.2.0",
|
|
27
28
|
"@radix-ui/react-label": "^2.1.8",
|
|
28
29
|
"@radix-ui/react-select": "^2.2.6",
|
|
29
30
|
"@radix-ui/react-switch": "^1.2.6",
|
|
@@ -33,6 +34,7 @@
|
|
|
33
34
|
"class-variance-authority": "^0.7.1",
|
|
34
35
|
"clsx": "^2.1.1",
|
|
35
36
|
"d3": "^7.9.0",
|
|
37
|
+
"handsontable": "^16.2.0",
|
|
36
38
|
"lucide-react": "^0.548.0",
|
|
37
39
|
"react": "^19.2.0",
|
|
38
40
|
"react-dom": "^19.2.0",
|
package/theme.js
CHANGED
|
@@ -40,6 +40,16 @@ export const defaultTheme = {
|
|
|
40
40
|
size: 5,
|
|
41
41
|
},
|
|
42
42
|
},
|
|
43
|
+
valueLabel: {
|
|
44
|
+
fontSize: 12,
|
|
45
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
|
|
46
|
+
fontWeight: '600',
|
|
47
|
+
color: '#1f2a36',
|
|
48
|
+
background: 'rgba(255, 255, 255, 0.95)',
|
|
49
|
+
border: '#e0e0e0',
|
|
50
|
+
borderRadius: 4,
|
|
51
|
+
padding: 4,
|
|
52
|
+
},
|
|
43
53
|
};
|
|
44
54
|
export const newspaperTheme = {
|
|
45
55
|
width: 928,
|
|
@@ -84,6 +94,16 @@ export const newspaperTheme = {
|
|
|
84
94
|
size: 5,
|
|
85
95
|
},
|
|
86
96
|
},
|
|
97
|
+
valueLabel: {
|
|
98
|
+
fontSize: 11,
|
|
99
|
+
fontFamily: 'Georgia, "Times New Roman", Times, serif',
|
|
100
|
+
fontWeight: '600',
|
|
101
|
+
color: '#1a1a1a',
|
|
102
|
+
background: 'rgba(245, 245, 220, 0.95)',
|
|
103
|
+
border: '#2c2c2c',
|
|
104
|
+
borderRadius: 2,
|
|
105
|
+
padding: 3,
|
|
106
|
+
},
|
|
87
107
|
};
|
|
88
108
|
export const themes = {
|
|
89
109
|
default: defaultTheme,
|
package/tooltip.d.ts
CHANGED
|
@@ -5,8 +5,14 @@ import type { Line } from './line.js';
|
|
|
5
5
|
import type { Bar } from './bar.js';
|
|
6
6
|
import type { PlotAreaBounds } from './layout-manager.js';
|
|
7
7
|
export declare class Tooltip implements ChartComponent {
|
|
8
|
+
readonly id = "iisChartTooltip";
|
|
8
9
|
readonly type: "tooltip";
|
|
9
10
|
readonly formatter?: (dataKey: string, value: any, data: DataItem) => string;
|
|
11
|
+
readonly labelFormatter?: (label: string, data: DataItem) => string;
|
|
12
|
+
readonly customFormatter?: (data: DataItem, series: {
|
|
13
|
+
dataKey: string;
|
|
14
|
+
[key: string]: any;
|
|
15
|
+
}[]) => string;
|
|
10
16
|
private tooltipDiv;
|
|
11
17
|
constructor(config?: TooltipConfig);
|
|
12
18
|
initialize(theme: ChartTheme): void;
|
package/tooltip.js
CHANGED
|
@@ -2,6 +2,12 @@ import { pointer, select } from 'd3';
|
|
|
2
2
|
import { getSeriesColor } from './types.js';
|
|
3
3
|
export class Tooltip {
|
|
4
4
|
constructor(config) {
|
|
5
|
+
Object.defineProperty(this, "id", {
|
|
6
|
+
enumerable: true,
|
|
7
|
+
configurable: true,
|
|
8
|
+
writable: true,
|
|
9
|
+
value: 'iisChartTooltip'
|
|
10
|
+
});
|
|
5
11
|
Object.defineProperty(this, "type", {
|
|
6
12
|
enumerable: true,
|
|
7
13
|
configurable: true,
|
|
@@ -14,6 +20,18 @@ export class Tooltip {
|
|
|
14
20
|
writable: true,
|
|
15
21
|
value: void 0
|
|
16
22
|
});
|
|
23
|
+
Object.defineProperty(this, "labelFormatter", {
|
|
24
|
+
enumerable: true,
|
|
25
|
+
configurable: true,
|
|
26
|
+
writable: true,
|
|
27
|
+
value: void 0
|
|
28
|
+
});
|
|
29
|
+
Object.defineProperty(this, "customFormatter", {
|
|
30
|
+
enumerable: true,
|
|
31
|
+
configurable: true,
|
|
32
|
+
writable: true,
|
|
33
|
+
value: void 0
|
|
34
|
+
});
|
|
17
35
|
Object.defineProperty(this, "tooltipDiv", {
|
|
18
36
|
enumerable: true,
|
|
19
37
|
configurable: true,
|
|
@@ -21,11 +39,15 @@ export class Tooltip {
|
|
|
21
39
|
value: null
|
|
22
40
|
});
|
|
23
41
|
this.formatter = config?.formatter;
|
|
42
|
+
this.labelFormatter = config?.labelFormatter;
|
|
43
|
+
this.customFormatter = config?.customFormatter;
|
|
24
44
|
}
|
|
25
45
|
initialize(theme) {
|
|
46
|
+
this.cleanup();
|
|
26
47
|
this.tooltipDiv = select('body')
|
|
27
48
|
.append('div')
|
|
28
49
|
.attr('class', 'chart-tooltip')
|
|
50
|
+
.attr('id', this.id)
|
|
29
51
|
.style('position', 'absolute')
|
|
30
52
|
.style('visibility', 'hidden')
|
|
31
53
|
.style('background-color', 'white')
|
|
@@ -43,6 +65,8 @@ export class Tooltip {
|
|
|
43
65
|
return;
|
|
44
66
|
const tooltip = this.tooltipDiv;
|
|
45
67
|
const formatter = this.formatter;
|
|
68
|
+
const labelFormatter = this.labelFormatter;
|
|
69
|
+
const customFormatter = this.customFormatter;
|
|
46
70
|
// Helper to get x position for any scale type
|
|
47
71
|
const getXPosition = (dataPoint) => {
|
|
48
72
|
const xValue = dataPoint[xKey];
|
|
@@ -102,17 +126,27 @@ export class Tooltip {
|
|
|
102
126
|
.style('opacity', 1);
|
|
103
127
|
});
|
|
104
128
|
// Build tooltip content
|
|
105
|
-
let content
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
129
|
+
let content;
|
|
130
|
+
if (customFormatter) {
|
|
131
|
+
content = customFormatter(dataPoint, series);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
const label = labelFormatter
|
|
135
|
+
? labelFormatter(dataPoint[xKey], dataPoint)
|
|
136
|
+
: dataPoint[xKey];
|
|
137
|
+
content = `<strong>${label}</strong><br/>`;
|
|
138
|
+
series.forEach((s) => {
|
|
139
|
+
const value = dataPoint[s.dataKey];
|
|
140
|
+
if (formatter) {
|
|
141
|
+
content +=
|
|
142
|
+
formatter(s.dataKey, value, dataPoint) +
|
|
143
|
+
'<br/>';
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
content += `${s.dataKey}: ${value}<br/>`;
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
116
150
|
// Position tooltip relative to the data point
|
|
117
151
|
const svgRect = svg.node().getBoundingClientRect();
|
|
118
152
|
const tooltipX = svgRect.left + window.scrollX + xPos + 10;
|
|
@@ -132,9 +166,11 @@ export class Tooltip {
|
|
|
132
166
|
});
|
|
133
167
|
}
|
|
134
168
|
cleanup() {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
169
|
+
const tooltip = select(`#${this.id}`);
|
|
170
|
+
if (tooltip.empty()) {
|
|
171
|
+
return;
|
|
138
172
|
}
|
|
173
|
+
tooltip.remove();
|
|
174
|
+
this.tooltipDiv = null;
|
|
139
175
|
}
|
|
140
176
|
}
|
package/types.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export type DataItem = {
|
|
2
2
|
[key: string]: any;
|
|
3
3
|
};
|
|
4
|
+
export type ExportFormat = 'svg' | 'json';
|
|
4
5
|
export type ColorPalette = string[];
|
|
5
6
|
export type ChartTheme = {
|
|
6
7
|
width: number;
|
|
@@ -36,17 +37,52 @@ export type ChartTheme = {
|
|
|
36
37
|
size: number;
|
|
37
38
|
};
|
|
38
39
|
};
|
|
40
|
+
valueLabel: {
|
|
41
|
+
fontSize: number;
|
|
42
|
+
fontFamily: string;
|
|
43
|
+
fontWeight: string;
|
|
44
|
+
color: string;
|
|
45
|
+
background: string;
|
|
46
|
+
border: string;
|
|
47
|
+
borderRadius: number;
|
|
48
|
+
padding: number;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
export type ValueLabelConfig = {
|
|
52
|
+
fontSize?: number;
|
|
53
|
+
fontFamily?: string;
|
|
54
|
+
fontWeight?: string;
|
|
55
|
+
color?: string;
|
|
56
|
+
background?: string;
|
|
57
|
+
border?: string;
|
|
58
|
+
borderRadius?: number;
|
|
59
|
+
padding?: number;
|
|
60
|
+
};
|
|
61
|
+
export type LineValueLabelConfig = ValueLabelConfig & {
|
|
62
|
+
show?: boolean;
|
|
63
|
+
};
|
|
64
|
+
export type BarValueLabelConfig = ValueLabelConfig & {
|
|
65
|
+
show?: boolean;
|
|
66
|
+
position?: 'inside' | 'outside';
|
|
67
|
+
insidePosition?: 'top' | 'middle' | 'bottom';
|
|
39
68
|
};
|
|
40
69
|
export type LineConfig = {
|
|
41
70
|
dataKey: string;
|
|
42
71
|
stroke?: string;
|
|
43
72
|
strokeWidth?: number;
|
|
73
|
+
valueLabel?: LineValueLabelConfig;
|
|
44
74
|
};
|
|
45
75
|
export type BarConfig = {
|
|
46
76
|
dataKey: string;
|
|
47
77
|
fill?: string;
|
|
48
78
|
colorAdapter?: (data: DataItem, index: number) => string;
|
|
49
79
|
orientation?: 'vertical' | 'horizontal';
|
|
80
|
+
maxBarSize?: number;
|
|
81
|
+
valueLabel?: BarValueLabelConfig;
|
|
82
|
+
};
|
|
83
|
+
export type BarStackConfig = {
|
|
84
|
+
mode?: BarStackMode;
|
|
85
|
+
gap?: number;
|
|
50
86
|
};
|
|
51
87
|
export declare function getSeriesColor(series: {
|
|
52
88
|
stroke?: string;
|
|
@@ -65,6 +101,11 @@ export type GridConfig = {
|
|
|
65
101
|
};
|
|
66
102
|
export type TooltipConfig = {
|
|
67
103
|
formatter?: (dataKey: string, value: any, data: DataItem) => string;
|
|
104
|
+
labelFormatter?: (label: string, data: DataItem) => string;
|
|
105
|
+
customFormatter?: (data: DataItem, series: {
|
|
106
|
+
dataKey: string;
|
|
107
|
+
[key: string]: any;
|
|
108
|
+
}[]) => string;
|
|
68
109
|
};
|
|
69
110
|
export type LegendConfig = {
|
|
70
111
|
position?: 'bottom';
|
|
@@ -80,10 +121,6 @@ export type TitleConfig = {
|
|
|
80
121
|
marginTop?: number;
|
|
81
122
|
marginBottom?: number;
|
|
82
123
|
};
|
|
83
|
-
export type ChartStyle = {
|
|
84
|
-
maxHeight?: string;
|
|
85
|
-
aspectRatio?: number;
|
|
86
|
-
};
|
|
87
124
|
export type ScaleType = 'band' | 'linear' | 'time' | 'log';
|
|
88
125
|
export type ScaleConfig = {
|
|
89
126
|
type: ScaleType;
|
|
@@ -99,3 +136,12 @@ export type AxisScaleConfig = {
|
|
|
99
136
|
y?: Partial<ScaleConfig>;
|
|
100
137
|
};
|
|
101
138
|
export type Orientation = 'vertical' | 'horizontal';
|
|
139
|
+
export type BarStackMode = 'none' | 'normal' | 'percent' | 'layer';
|
|
140
|
+
export type BarStackingContext = {
|
|
141
|
+
mode: BarStackMode;
|
|
142
|
+
seriesIndex: number;
|
|
143
|
+
totalSeries: number;
|
|
144
|
+
cumulativeData: Map<string, number>;
|
|
145
|
+
totalData: Map<string, number>;
|
|
146
|
+
gap: number;
|
|
147
|
+
};
|
package/xy-chart.d.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
+
import type { BarStackConfig } from './types.js';
|
|
1
2
|
import { BaseChart, type BaseChartConfig } from './base-chart.js';
|
|
2
3
|
import type { ChartComponent } from './chart-interface.js';
|
|
3
|
-
export type XYChartConfig = BaseChartConfig
|
|
4
|
+
export type XYChartConfig = BaseChartConfig & {
|
|
5
|
+
barStack?: BarStackConfig;
|
|
6
|
+
};
|
|
4
7
|
export declare class XYChart extends BaseChart {
|
|
5
8
|
private readonly series;
|
|
6
9
|
private sortedDataCache;
|
|
7
10
|
private xKeyCache;
|
|
11
|
+
private barStackMode;
|
|
12
|
+
private barStackGap;
|
|
8
13
|
constructor(config: XYChartConfig);
|
|
9
14
|
addChild(component: ChartComponent): this;
|
|
10
15
|
private rerender;
|
|
@@ -15,4 +20,5 @@ export declare class XYChart extends BaseChart {
|
|
|
15
20
|
private isHorizontalOrientation;
|
|
16
21
|
private createScale;
|
|
17
22
|
private renderSeries;
|
|
23
|
+
private computeStackingData;
|
|
18
24
|
}
|
package/xy-chart.js
CHANGED
|
@@ -23,6 +23,20 @@ export class XYChart extends BaseChart {
|
|
|
23
23
|
writable: true,
|
|
24
24
|
value: null
|
|
25
25
|
});
|
|
26
|
+
Object.defineProperty(this, "barStackMode", {
|
|
27
|
+
enumerable: true,
|
|
28
|
+
configurable: true,
|
|
29
|
+
writable: true,
|
|
30
|
+
value: void 0
|
|
31
|
+
});
|
|
32
|
+
Object.defineProperty(this, "barStackGap", {
|
|
33
|
+
enumerable: true,
|
|
34
|
+
configurable: true,
|
|
35
|
+
writable: true,
|
|
36
|
+
value: void 0
|
|
37
|
+
});
|
|
38
|
+
this.barStackMode = config.barStack?.mode ?? 'normal';
|
|
39
|
+
this.barStackGap = config.barStack?.gap ?? 0.1;
|
|
26
40
|
}
|
|
27
41
|
addChild(component) {
|
|
28
42
|
const type = component.type;
|
|
@@ -189,8 +203,8 @@ export class XYChart extends BaseChart {
|
|
|
189
203
|
else if ((scaleType === 'linear' || scaleType === 'log') && dataKey) {
|
|
190
204
|
// For linear and log scales with a dataKey, calculate from that key
|
|
191
205
|
const values = this.data.map((d) => this.parseValue(d[dataKey]));
|
|
192
|
-
const minVal = config.min ??
|
|
193
|
-
const maxVal = config.max ??
|
|
206
|
+
const minVal = config.min ?? min(values) ?? 0;
|
|
207
|
+
const maxVal = config.max ?? max(values) ?? 100;
|
|
194
208
|
domain =
|
|
195
209
|
scaleType === 'log' && minVal <= 0
|
|
196
210
|
? [1, maxVal]
|
|
@@ -198,13 +212,30 @@ export class XYChart extends BaseChart {
|
|
|
198
212
|
}
|
|
199
213
|
else {
|
|
200
214
|
// Calculate from series data (for value axes without explicit dataKey)
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
215
|
+
const barSeries = this.series.filter((s) => s.type === 'bar');
|
|
216
|
+
// For percent stacking, domain is always 0-100
|
|
217
|
+
if (this.barStackMode === 'percent' && barSeries.length > 0) {
|
|
218
|
+
domain = [0, 100];
|
|
219
|
+
}
|
|
220
|
+
else if (this.barStackMode === 'normal' && barSeries.length > 1) {
|
|
221
|
+
// For normal stacking, calculate cumulative totals
|
|
222
|
+
const stackedValues = this.data.map((d) => {
|
|
223
|
+
return barSeries.reduce((sum, s) => sum + this.parseValue(d[s.dataKey]), 0);
|
|
224
|
+
});
|
|
225
|
+
const minVal = config.min ?? 0;
|
|
226
|
+
const maxVal = config.max ?? max(stackedValues) ?? 100;
|
|
227
|
+
domain = [minVal, maxVal];
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
// For none, layer, or single bar: use individual values
|
|
231
|
+
const values = this.data.flatMap((d) => this.series.map((s) => this.parseValue(d[s.dataKey])));
|
|
232
|
+
const minVal = config.min ?? min(values) ?? 0;
|
|
233
|
+
const maxVal = config.max ?? max(values) ?? 100;
|
|
234
|
+
domain =
|
|
235
|
+
scaleType === 'log' && minVal <= 0
|
|
236
|
+
? [1, maxVal]
|
|
237
|
+
: [minVal, maxVal];
|
|
238
|
+
}
|
|
208
239
|
}
|
|
209
240
|
switch (scaleType) {
|
|
210
241
|
case 'band': {
|
|
@@ -265,8 +296,54 @@ export class XYChart extends BaseChart {
|
|
|
265
296
|
const visibleSeries = this.legend
|
|
266
297
|
? this.series.filter((series) => this.legend.isSeriesVisible(series.dataKey))
|
|
267
298
|
: this.series;
|
|
299
|
+
// Get only bar series for stacking calculations
|
|
300
|
+
const barSeries = visibleSeries.filter((s) => s.type === 'bar');
|
|
301
|
+
// Compute stacking data for bar charts
|
|
302
|
+
const { cumulativeDataBySeriesIndex, totalData } = this.computeStackingData(sortedData, xKey, barSeries);
|
|
268
303
|
visibleSeries.forEach((series) => {
|
|
269
|
-
series.
|
|
304
|
+
if (series.type === 'bar') {
|
|
305
|
+
const barIndex = barSeries.indexOf(series);
|
|
306
|
+
const stackingContext = {
|
|
307
|
+
mode: this.barStackMode,
|
|
308
|
+
seriesIndex: barIndex,
|
|
309
|
+
totalSeries: barSeries.length,
|
|
310
|
+
cumulativeData: cumulativeDataBySeriesIndex.get(barIndex) ?? new Map(),
|
|
311
|
+
totalData,
|
|
312
|
+
gap: this.barStackGap,
|
|
313
|
+
};
|
|
314
|
+
series.render(this.plotGroup, sortedData, xKey, this.x, this.y, this.parseValue, categoryScaleType, this.theme, stackingContext);
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
series.render(this.plotGroup, sortedData, xKey, this.x, this.y, this.parseValue, categoryScaleType, this.theme);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
computeStackingData(data, xKey, barSeries) {
|
|
322
|
+
const cumulativeDataBySeriesIndex = new Map();
|
|
323
|
+
const totalData = new Map();
|
|
324
|
+
// First pass: compute totals for each category
|
|
325
|
+
data.forEach((d) => {
|
|
326
|
+
const categoryKey = String(d[xKey]);
|
|
327
|
+
let total = 0;
|
|
328
|
+
barSeries.forEach((series) => {
|
|
329
|
+
total += this.parseValue(d[series.dataKey]);
|
|
330
|
+
});
|
|
331
|
+
totalData.set(categoryKey, total);
|
|
332
|
+
});
|
|
333
|
+
// Second pass: compute cumulative values for each series at each category
|
|
334
|
+
barSeries.forEach((_, seriesIndex) => {
|
|
335
|
+
const cumulativeForSeries = new Map();
|
|
336
|
+
data.forEach((d) => {
|
|
337
|
+
const categoryKey = String(d[xKey]);
|
|
338
|
+
let cumulative = 0;
|
|
339
|
+
// Sum all previous series' values for this category
|
|
340
|
+
for (let i = 0; i < seriesIndex; i++) {
|
|
341
|
+
cumulative += this.parseValue(d[barSeries[i].dataKey]);
|
|
342
|
+
}
|
|
343
|
+
cumulativeForSeries.set(categoryKey, cumulative);
|
|
344
|
+
});
|
|
345
|
+
cumulativeDataBySeriesIndex.set(seriesIndex, cumulativeForSeries);
|
|
270
346
|
});
|
|
347
|
+
return { cumulativeDataBySeriesIndex, totalData };
|
|
271
348
|
}
|
|
272
349
|
}
|