@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 CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Selection } from 'd3';
2
- import type { BarConfig, DataItem, ScaleType, Orientation, ChartTheme, BarValueLabelConfig } 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";
@@ -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 barWidth = this.maxBarSize ? Math.min(bandwidth, this.maxBarSize) : bandwidth;
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
- const offset = (bandwidth - barWidth) / 2;
104
- // For non-band scales, center the bar
105
- return xScaleType === 'band' ? xPos + offset : xPos - barWidth / 2;
140
+ return xScaleType === 'band'
141
+ ? xPos + barOffset
142
+ : xPos - barWidth / 2;
106
143
  })
107
144
  .attr('y', (d) => {
108
- const yPos = y(parseValue(d[this.dataKey])) || 0;
109
- 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
+ }
110
165
  })
111
166
  .attr('width', barWidth)
112
167
  .attr('height', (d) => {
113
- const yPos = y(parseValue(d[this.dataKey])) || 0;
114
- 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
+ }
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 barHeight = this.maxBarSize ? Math.min(bandwidth, this.maxBarSize) : bandwidth;
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 xPos = x(parseValue(d[this.dataKey])) || 0;
135
- 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
+ }
136
258
  })
137
259
  .attr('y', (d) => {
138
260
  const yPos = this.getScaledPosition(d, xKey, y, yScaleType);
139
- const offset = (bandwidth - barHeight) / 2;
140
- // For non-band scales, center the bar
141
- return yScaleType === 'band' ? yPos + offset : yPos - barHeight / 2;
261
+ return yScaleType === 'band'
262
+ ? yPos + barOffset
263
+ : yPos - barHeight / 2;
142
264
  })
143
265
  .attr('width', (d) => {
144
- const xPos = x(parseValue(d[this.dataKey])) || 0;
145
- 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
+ }
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 barWidth = this.maxBarSize ? Math.min(bandwidth, this.maxBarSize) : bandwidth;
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
- const yPos = y(value) || 0;
175
- const barHeight = Math.abs(yBaseline - yPos);
176
- const barTop = Math.min(yBaseline, yPos);
177
- const barBottom = Math.max(yBaseline, yPos);
178
- const barCenterX = xPos + (xScaleType === 'band' ? (bandwidth - barWidth) / 2 : -barWidth / 2) + barWidth / 2;
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 barHeight = this.maxBarSize ? Math.min(bandwidth, this.maxBarSize) : bandwidth;
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
- const xPos = x(value) || 0;
277
- const barWidth = Math.abs(xPos - xBaseline);
278
- const barLeft = Math.min(xBaseline, xPos);
279
- const barRight = Math.max(xBaseline, xPos);
280
- const barCenterY = yPos + (yScaleType === 'band' ? (bandwidth - barHeight) / 2 : -barHeight / 2) + barHeight / 2;
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.8",
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 ?? (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
  }