@internetstiftelsen/charts 0.0.8 → 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 +2 -2
- package/bar.js +290 -41
- package/base-chart.d.ts +7 -1
- package/base-chart.js +26 -0
- package/package.json +3 -1
- package/types.d.ts +14 -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";
|
|
@@ -11,7 +11,7 @@ export declare class Bar implements ChartComponent {
|
|
|
11
11
|
readonly valueLabel?: BarValueLabelConfig;
|
|
12
12
|
constructor(config: BarConfig);
|
|
13
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): void;
|
|
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;
|
|
15
15
|
private renderVertical;
|
|
16
16
|
private renderHorizontal;
|
|
17
17
|
private renderVerticalValueLabels;
|
package/bar.js
CHANGED
|
@@ -66,29 +66,66 @@ export class Bar {
|
|
|
66
66
|
}
|
|
67
67
|
return scale(scaledValue) || 0;
|
|
68
68
|
}
|
|
69
|
-
render(plotGroup, data, xKey, x, y, parseValue, xScaleType = 'band', theme) {
|
|
69
|
+
render(plotGroup, data, xKey, x, y, parseValue, xScaleType = 'band', theme, stackingContext) {
|
|
70
70
|
if (this.orientation === 'vertical') {
|
|
71
|
-
this.renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType);
|
|
71
|
+
this.renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext);
|
|
72
72
|
}
|
|
73
73
|
else {
|
|
74
|
-
this.renderHorizontal(plotGroup, data, xKey, x, y, parseValue, xScaleType);
|
|
74
|
+
this.renderHorizontal(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext);
|
|
75
75
|
}
|
|
76
76
|
// Render value labels if enabled
|
|
77
77
|
if (this.valueLabel?.show && theme) {
|
|
78
78
|
if (this.orientation === 'vertical') {
|
|
79
|
-
this.renderVerticalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme);
|
|
79
|
+
this.renderVerticalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme, stackingContext);
|
|
80
80
|
}
|
|
81
81
|
else {
|
|
82
|
-
this.renderHorizontalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme);
|
|
82
|
+
this.renderHorizontalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme, stackingContext);
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
|
-
renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType) {
|
|
86
|
+
renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext) {
|
|
87
87
|
const bandwidth = x.bandwidth ? x.bandwidth() : 20;
|
|
88
|
-
const
|
|
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
|
+
}
|
|
89
128
|
// Get the baseline value from the Y scale's domain
|
|
90
|
-
// For linear scales, use 0 if it's in the domain, otherwise use domain max (bottom of chart)
|
|
91
|
-
// For log scales, use the minimum value from the domain
|
|
92
129
|
const yDomain = y.domain();
|
|
93
130
|
const baselineValue = yDomain[0] >= 0 ? Math.max(0, yDomain[0]) : yDomain[0];
|
|
94
131
|
const yBaseline = y(baselineValue) || 0;
|
|
@@ -100,27 +137,97 @@ export class Bar {
|
|
|
100
137
|
.attr('class', `bar-${this.dataKey.replace(/\s+/g, '-')}`)
|
|
101
138
|
.attr('x', (d) => {
|
|
102
139
|
const xPos = this.getScaledPosition(d, xKey, x, xScaleType);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
140
|
+
return xScaleType === 'band'
|
|
141
|
+
? xPos + barOffset
|
|
142
|
+
: xPos - barWidth / 2;
|
|
106
143
|
})
|
|
107
144
|
.attr('y', (d) => {
|
|
108
|
-
const
|
|
109
|
-
|
|
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
|
+
}
|
|
110
165
|
})
|
|
111
166
|
.attr('width', barWidth)
|
|
112
167
|
.attr('height', (d) => {
|
|
113
|
-
const
|
|
114
|
-
|
|
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
|
+
}
|
|
115
186
|
})
|
|
116
187
|
.attr('fill', (d, i) => this.colorAdapter ? this.colorAdapter(d, i) : this.fill);
|
|
117
188
|
}
|
|
118
|
-
renderHorizontal(plotGroup, data, xKey, x, y, parseValue, yScaleType) {
|
|
189
|
+
renderHorizontal(plotGroup, data, xKey, x, y, parseValue, yScaleType, stackingContext) {
|
|
119
190
|
const bandwidth = y.bandwidth ? y.bandwidth() : 20;
|
|
120
|
-
const
|
|
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
|
+
}
|
|
121
230
|
// Get the baseline value from the scale's domain
|
|
122
|
-
// For linear scales, use 0 if it's in the domain, otherwise use domain min
|
|
123
|
-
// For log scales, use the minimum value from the domain
|
|
124
231
|
const domain = x.domain();
|
|
125
232
|
const baselineValue = domain[0] >= 0 ? Math.max(0, domain[0]) : domain[0];
|
|
126
233
|
const xBaseline = x(baselineValue) || 0;
|
|
@@ -131,25 +238,91 @@ export class Bar {
|
|
|
131
238
|
.join('rect')
|
|
132
239
|
.attr('class', `bar-${this.dataKey.replace(/\s+/g, '-')}`)
|
|
133
240
|
.attr('x', (d) => {
|
|
134
|
-
const
|
|
135
|
-
|
|
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
|
+
}
|
|
136
258
|
})
|
|
137
259
|
.attr('y', (d) => {
|
|
138
260
|
const yPos = this.getScaledPosition(d, xKey, y, yScaleType);
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
261
|
+
return yScaleType === 'band'
|
|
262
|
+
? yPos + barOffset
|
|
263
|
+
: yPos - barHeight / 2;
|
|
142
264
|
})
|
|
143
265
|
.attr('width', (d) => {
|
|
144
|
-
const
|
|
145
|
-
|
|
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
|
+
}
|
|
146
285
|
})
|
|
147
286
|
.attr('height', barHeight)
|
|
148
287
|
.attr('fill', (d, i) => this.colorAdapter ? this.colorAdapter(d, i) : this.fill);
|
|
149
288
|
}
|
|
150
|
-
renderVerticalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme) {
|
|
289
|
+
renderVerticalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme, stackingContext) {
|
|
151
290
|
const bandwidth = x.bandwidth ? x.bandwidth() : 20;
|
|
152
|
-
const
|
|
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
|
+
}
|
|
153
326
|
const yDomain = y.domain();
|
|
154
327
|
const baselineValue = yDomain[0] >= 0 ? Math.max(0, yDomain[0]) : yDomain[0];
|
|
155
328
|
const yBaseline = y(baselineValue) || 0;
|
|
@@ -168,14 +341,35 @@ export class Bar {
|
|
|
168
341
|
.append('g')
|
|
169
342
|
.attr('class', `bar-value-labels-${this.dataKey.replace(/\s+/g, '-')}`);
|
|
170
343
|
data.forEach((d) => {
|
|
344
|
+
const categoryKey = String(d[xKey]);
|
|
171
345
|
const value = parseValue(d[this.dataKey]);
|
|
172
346
|
const valueText = String(value);
|
|
173
347
|
const xPos = this.getScaledPosition(d, xKey, x, xScaleType);
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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;
|
|
179
373
|
// Create temporary text to measure dimensions
|
|
180
374
|
const tempText = labelGroup
|
|
181
375
|
.append('text')
|
|
@@ -249,9 +443,43 @@ export class Bar {
|
|
|
249
443
|
}
|
|
250
444
|
});
|
|
251
445
|
}
|
|
252
|
-
renderHorizontalValueLabels(plotGroup, data, xKey, x, y, parseValue, yScaleType, theme) {
|
|
446
|
+
renderHorizontalValueLabels(plotGroup, data, xKey, x, y, parseValue, yScaleType, theme, stackingContext) {
|
|
253
447
|
const bandwidth = y.bandwidth ? y.bandwidth() : 20;
|
|
254
|
-
const
|
|
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
|
+
}
|
|
255
483
|
const domain = x.domain();
|
|
256
484
|
const baselineValue = domain[0] >= 0 ? Math.max(0, domain[0]) : domain[0];
|
|
257
485
|
const xBaseline = x(baselineValue) || 0;
|
|
@@ -270,14 +498,35 @@ export class Bar {
|
|
|
270
498
|
.append('g')
|
|
271
499
|
.attr('class', `bar-value-labels-${this.dataKey.replace(/\s+/g, '-')}`);
|
|
272
500
|
data.forEach((d) => {
|
|
501
|
+
const categoryKey = String(d[xKey]);
|
|
273
502
|
const value = parseValue(d[this.dataKey]);
|
|
274
503
|
const valueText = String(value);
|
|
275
504
|
const yPos = this.getScaledPosition(d, xKey, y, yScaleType);
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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;
|
|
281
530
|
// Create temporary text to measure dimensions
|
|
282
531
|
const tempText = labelGroup
|
|
283
532
|
.append('text')
|
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/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/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;
|
|
@@ -79,6 +80,10 @@ export type BarConfig = {
|
|
|
79
80
|
maxBarSize?: number;
|
|
80
81
|
valueLabel?: BarValueLabelConfig;
|
|
81
82
|
};
|
|
83
|
+
export type BarStackConfig = {
|
|
84
|
+
mode?: BarStackMode;
|
|
85
|
+
gap?: number;
|
|
86
|
+
};
|
|
82
87
|
export declare function getSeriesColor(series: {
|
|
83
88
|
stroke?: string;
|
|
84
89
|
fill?: string;
|
|
@@ -116,10 +121,6 @@ export type TitleConfig = {
|
|
|
116
121
|
marginTop?: number;
|
|
117
122
|
marginBottom?: number;
|
|
118
123
|
};
|
|
119
|
-
export type ChartStyle = {
|
|
120
|
-
maxHeight?: string;
|
|
121
|
-
aspectRatio?: number;
|
|
122
|
-
};
|
|
123
124
|
export type ScaleType = 'band' | 'linear' | 'time' | 'log';
|
|
124
125
|
export type ScaleConfig = {
|
|
125
126
|
type: ScaleType;
|
|
@@ -135,3 +136,12 @@ export type AxisScaleConfig = {
|
|
|
135
136
|
y?: Partial<ScaleConfig>;
|
|
136
137
|
};
|
|
137
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
|
}
|