@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 CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Selection } from 'd3';
2
- import type { BarConfig, DataItem, ScaleType, Orientation, ChartTheme } from './types.js';
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
- render(plotGroup: Selection<SVGGElement, undefined, null, undefined>, data: DataItem[], xKey: string, x: any, y: any, parseValue: (value: any) => number, xScaleType?: ScaleType, _theme?: ChartTheme): void;
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', _theme) {
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 = getXPosition(d);
81
- // For non-band scales, center the bar
82
- return xScaleType === 'band' ? xPos : xPos - bandwidth / 2;
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 yPos = y(parseValue(d[this.dataKey])) || 0;
86
- return Math.min(yBaseline, yPos);
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', bandwidth)
166
+ .attr('width', barWidth)
89
167
  .attr('height', (d) => {
90
- const yPos = y(parseValue(d[this.dataKey])) || 0;
91
- return Math.abs(yBaseline - yPos);
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 xPos = x(parseValue(d[this.dataKey])) || 0;
130
- return Math.min(xBaseline, xPos);
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 = getYPosition(d);
134
- // For non-band scales, center the bar
135
- return yScaleType === 'band' ? yPos : yPos - bandwidth / 2;
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 xPos = x(parseValue(d[this.dataKey])) || 0;
139
- return Math.abs(xPos - xBaseline);
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', bandwidth)
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.7",
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 = `<strong>${dataPoint[xKey]}</strong><br/>`;
106
- series.forEach((s) => {
107
- const value = dataPoint[s.dataKey];
108
- if (formatter) {
109
- content +=
110
- formatter(s.dataKey, value, dataPoint) + '<br/>';
111
- }
112
- else {
113
- content += `${s.dataKey}: ${value}<br/>`;
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
- if (this.tooltipDiv) {
136
- this.tooltipDiv.remove();
137
- this.tooltipDiv = null;
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 ?? (min(values) ?? 0);
193
- const maxVal = config.max ?? (max(values) ?? 100);
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 values = this.data.flatMap((d) => this.series.map((s) => this.parseValue(d[s.dataKey])));
202
- const minVal = config.min ?? (min(values) ?? 0);
203
- const maxVal = config.max ?? (max(values) ?? 100);
204
- domain =
205
- scaleType === 'log' && minVal <= 0
206
- ? [1, maxVal]
207
- : [minVal, maxVal];
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.render(this.plotGroup, sortedData, xKey, this.x, this.y, this.parseValue, categoryScaleType, this.theme);
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
  }