@internetstiftelsen/charts 0.0.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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, D3Scale, 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: D3Scale, y: D3Scale, parseValue: (value: unknown) => number, xScaleType?: ScaleType, theme?: ChartTheme, stackingContext?: BarStackingContext): void;
15
15
  private renderVertical;
16
16
  private renderHorizontal;
17
17
  private renderVerticalValueLabels;
package/bar.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { sanitizeForCSS } from './utils.js';
1
2
  export class Bar {
2
3
  constructor(config) {
3
4
  Object.defineProperty(this, "type", {
@@ -54,10 +55,11 @@ export class Bar {
54
55
  let scaledValue;
55
56
  switch (scaleType) {
56
57
  case 'band':
57
- scaledValue = value;
58
+ scaledValue = String(value);
58
59
  break;
59
60
  case 'time':
60
- scaledValue = value instanceof Date ? value : new Date(value);
61
+ scaledValue =
62
+ value instanceof Date ? value : new Date(String(value));
61
63
  break;
62
64
  case 'linear':
63
65
  case 'log':
@@ -66,90 +68,267 @@ export class Bar {
66
68
  }
67
69
  return scale(scaledValue) || 0;
68
70
  }
69
- render(plotGroup, data, xKey, x, y, parseValue, xScaleType = 'band', theme) {
71
+ render(plotGroup, data, xKey, x, y, parseValue, xScaleType = 'band', theme, stackingContext) {
70
72
  if (this.orientation === 'vertical') {
71
- this.renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType);
73
+ this.renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext);
72
74
  }
73
75
  else {
74
- this.renderHorizontal(plotGroup, data, xKey, x, y, parseValue, xScaleType);
76
+ this.renderHorizontal(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext);
75
77
  }
76
78
  // Render value labels if enabled
77
79
  if (this.valueLabel?.show && theme) {
78
80
  if (this.orientation === 'vertical') {
79
- this.renderVerticalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme);
81
+ this.renderVerticalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme, stackingContext);
80
82
  }
81
83
  else {
82
- this.renderHorizontalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme);
84
+ this.renderHorizontalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme, stackingContext);
83
85
  }
84
86
  }
85
87
  }
86
- renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType) {
88
+ renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext) {
87
89
  const bandwidth = x.bandwidth ? x.bandwidth() : 20;
88
- const barWidth = this.maxBarSize ? Math.min(bandwidth, this.maxBarSize) : bandwidth;
90
+ const mode = stackingContext?.mode ?? 'normal';
91
+ // Calculate bar width based on stacking mode
92
+ let barWidth;
93
+ let barOffset;
94
+ if (mode === 'none') {
95
+ // Grouped bars: divide bandwidth among series with gap
96
+ const totalSeries = stackingContext?.totalSeries ?? 1;
97
+ const seriesIndex = stackingContext?.seriesIndex ?? 0;
98
+ const gap = stackingContext?.gap ?? 0.1;
99
+ const groupWidth = this.maxBarSize
100
+ ? Math.min(bandwidth, this.maxBarSize * totalSeries)
101
+ : bandwidth;
102
+ // Calculate total gap space and individual bar width
103
+ const totalGapSpace = groupWidth * gap * (totalSeries - 1);
104
+ const availableWidth = groupWidth - totalGapSpace;
105
+ barWidth = availableWidth / totalSeries;
106
+ const gapSize = totalSeries > 1 ? groupWidth * gap : 0;
107
+ barOffset =
108
+ (bandwidth - groupWidth) / 2 +
109
+ seriesIndex * (barWidth + gapSize);
110
+ }
111
+ else if (mode === 'layer') {
112
+ // Layer mode: each subsequent series has smaller bars
113
+ const totalSeries = stackingContext?.totalSeries ?? 1;
114
+ const seriesIndex = stackingContext?.seriesIndex ?? 0;
115
+ const maxWidth = this.maxBarSize
116
+ ? Math.min(bandwidth, this.maxBarSize)
117
+ : bandwidth;
118
+ // Scale from 100% to a minimum (e.g., 30%) based on series position
119
+ const scaleFactor = 1 - (seriesIndex / totalSeries) * 0.7;
120
+ barWidth = maxWidth * scaleFactor;
121
+ barOffset = (bandwidth - barWidth) / 2;
122
+ }
123
+ else {
124
+ // Normal and Percent modes: full width stacked bars
125
+ barWidth = this.maxBarSize
126
+ ? Math.min(bandwidth, this.maxBarSize)
127
+ : bandwidth;
128
+ barOffset = (bandwidth - barWidth) / 2;
129
+ }
89
130
  // 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
131
  const yDomain = y.domain();
93
132
  const baselineValue = yDomain[0] >= 0 ? Math.max(0, yDomain[0]) : yDomain[0];
94
133
  const yBaseline = y(baselineValue) || 0;
95
134
  // Add bar rectangles
135
+ const sanitizedKey = sanitizeForCSS(this.dataKey);
96
136
  plotGroup
97
- .selectAll(`.bar-${this.dataKey.replace(/\s+/g, '-')}`)
137
+ .selectAll(`.bar-${sanitizedKey}`)
98
138
  .data(data)
99
139
  .join('rect')
100
- .attr('class', `bar-${this.dataKey.replace(/\s+/g, '-')}`)
140
+ .attr('class', `bar-${sanitizedKey}`)
141
+ .attr('data-index', (_, i) => i)
101
142
  .attr('x', (d) => {
102
143
  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;
144
+ return xScaleType === 'band'
145
+ ? xPos + barOffset
146
+ : xPos - barWidth / 2;
106
147
  })
107
148
  .attr('y', (d) => {
108
- const yPos = y(parseValue(d[this.dataKey])) || 0;
109
- return Math.min(yBaseline, yPos);
149
+ const categoryKey = String(d[xKey]);
150
+ const value = parseValue(d[this.dataKey]);
151
+ if (mode === 'none' || mode === 'layer') {
152
+ // No stacking - each bar starts from baseline
153
+ const yPos = y(value) || 0;
154
+ return Math.min(yBaseline, yPos);
155
+ }
156
+ else if (mode === 'percent') {
157
+ // Percent mode: calculate position based on cumulative percentage
158
+ const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
159
+ const total = stackingContext?.totalData.get(categoryKey) ?? 1;
160
+ const percentCumulative = (cumulative / total) * 100;
161
+ const percentValue = (value / total) * 100;
162
+ return y(percentCumulative + percentValue) || 0;
163
+ }
164
+ else {
165
+ // Normal stacking mode
166
+ const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
167
+ return y(cumulative + value) || 0;
168
+ }
110
169
  })
111
170
  .attr('width', barWidth)
112
171
  .attr('height', (d) => {
113
- const yPos = y(parseValue(d[this.dataKey])) || 0;
114
- return Math.abs(yBaseline - yPos);
172
+ const categoryKey = String(d[xKey]);
173
+ const value = parseValue(d[this.dataKey]);
174
+ if (mode === 'none' || mode === 'layer') {
175
+ const yPos = y(value) || 0;
176
+ return Math.abs(yBaseline - yPos);
177
+ }
178
+ else if (mode === 'percent') {
179
+ const total = stackingContext?.totalData.get(categoryKey) ?? 1;
180
+ const percentValue = (value / total) * 100;
181
+ const yTop = y(percentValue) || 0;
182
+ const yBottom = y(0) || 0;
183
+ return Math.abs(yBottom - yTop);
184
+ }
185
+ else {
186
+ // Normal stacking mode
187
+ const yTop = y(value) || 0;
188
+ return Math.abs(yBaseline - yTop);
189
+ }
115
190
  })
116
191
  .attr('fill', (d, i) => this.colorAdapter ? this.colorAdapter(d, i) : this.fill);
117
192
  }
118
- renderHorizontal(plotGroup, data, xKey, x, y, parseValue, yScaleType) {
193
+ renderHorizontal(plotGroup, data, xKey, x, y, parseValue, yScaleType, stackingContext) {
119
194
  const bandwidth = y.bandwidth ? y.bandwidth() : 20;
120
- const barHeight = this.maxBarSize ? Math.min(bandwidth, this.maxBarSize) : bandwidth;
195
+ const mode = stackingContext?.mode ?? 'normal';
196
+ // Calculate bar height based on stacking mode
197
+ let barHeight;
198
+ let barOffset;
199
+ if (mode === 'none') {
200
+ // Grouped bars: divide bandwidth among series with gap
201
+ const totalSeries = stackingContext?.totalSeries ?? 1;
202
+ const seriesIndex = stackingContext?.seriesIndex ?? 0;
203
+ const gap = stackingContext?.gap ?? 0.1;
204
+ const groupHeight = this.maxBarSize
205
+ ? Math.min(bandwidth, this.maxBarSize * totalSeries)
206
+ : bandwidth;
207
+ // Calculate total gap space and individual bar height
208
+ const totalGapSpace = groupHeight * gap * (totalSeries - 1);
209
+ const availableHeight = groupHeight - totalGapSpace;
210
+ barHeight = availableHeight / totalSeries;
211
+ const gapSize = totalSeries > 1 ? groupHeight * gap : 0;
212
+ barOffset =
213
+ (bandwidth - groupHeight) / 2 +
214
+ seriesIndex * (barHeight + gapSize);
215
+ }
216
+ else if (mode === 'layer') {
217
+ // Layer mode: each subsequent series has smaller bars
218
+ const totalSeries = stackingContext?.totalSeries ?? 1;
219
+ const seriesIndex = stackingContext?.seriesIndex ?? 0;
220
+ const maxHeight = this.maxBarSize
221
+ ? Math.min(bandwidth, this.maxBarSize)
222
+ : bandwidth;
223
+ const scaleFactor = 1 - (seriesIndex / totalSeries) * 0.7;
224
+ barHeight = maxHeight * scaleFactor;
225
+ barOffset = (bandwidth - barHeight) / 2;
226
+ }
227
+ else {
228
+ // Normal and Percent modes: full height stacked bars
229
+ barHeight = this.maxBarSize
230
+ ? Math.min(bandwidth, this.maxBarSize)
231
+ : bandwidth;
232
+ barOffset = (bandwidth - barHeight) / 2;
233
+ }
121
234
  // 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
235
  const domain = x.domain();
125
236
  const baselineValue = domain[0] >= 0 ? Math.max(0, domain[0]) : domain[0];
126
237
  const xBaseline = x(baselineValue) || 0;
127
238
  // Add bar rectangles (horizontal)
239
+ const sanitizedKey = sanitizeForCSS(this.dataKey);
128
240
  plotGroup
129
- .selectAll(`.bar-${this.dataKey.replace(/\s+/g, '-')}`)
241
+ .selectAll(`.bar-${sanitizedKey}`)
130
242
  .data(data)
131
243
  .join('rect')
132
- .attr('class', `bar-${this.dataKey.replace(/\s+/g, '-')}`)
244
+ .attr('class', `bar-${sanitizedKey}`)
245
+ .attr('data-index', (_, i) => i)
133
246
  .attr('x', (d) => {
134
- const xPos = x(parseValue(d[this.dataKey])) || 0;
135
- return Math.min(xBaseline, xPos);
247
+ const categoryKey = String(d[xKey]);
248
+ const value = parseValue(d[this.dataKey]);
249
+ if (mode === 'none' || mode === 'layer') {
250
+ const xPos = x(value) || 0;
251
+ return Math.min(xBaseline, xPos);
252
+ }
253
+ else if (mode === 'percent') {
254
+ const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
255
+ const total = stackingContext?.totalData.get(categoryKey) ?? 1;
256
+ const percentCumulative = (cumulative / total) * 100;
257
+ return x(percentCumulative) || 0;
258
+ }
259
+ else {
260
+ // Normal stacking mode
261
+ const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
262
+ return x(cumulative) || 0;
263
+ }
136
264
  })
137
265
  .attr('y', (d) => {
138
266
  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;
267
+ return yScaleType === 'band'
268
+ ? yPos + barOffset
269
+ : yPos - barHeight / 2;
142
270
  })
143
271
  .attr('width', (d) => {
144
- const xPos = x(parseValue(d[this.dataKey])) || 0;
145
- return Math.abs(xPos - xBaseline);
272
+ const categoryKey = String(d[xKey]);
273
+ const value = parseValue(d[this.dataKey]);
274
+ if (mode === 'none' || mode === 'layer') {
275
+ const xPos = x(value) || 0;
276
+ return Math.abs(xPos - xBaseline);
277
+ }
278
+ else if (mode === 'percent') {
279
+ const total = stackingContext?.totalData.get(categoryKey) ?? 1;
280
+ const percentValue = (value / total) * 100;
281
+ const xLeft = x(0) || 0;
282
+ const xRight = x(percentValue) || 0;
283
+ return Math.abs(xRight - xLeft);
284
+ }
285
+ else {
286
+ // Normal stacking mode
287
+ const xLeft = x(0) || 0;
288
+ const xRight = x(value) || 0;
289
+ return Math.abs(xRight - xLeft);
290
+ }
146
291
  })
147
292
  .attr('height', barHeight)
148
293
  .attr('fill', (d, i) => this.colorAdapter ? this.colorAdapter(d, i) : this.fill);
149
294
  }
150
- renderVerticalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme) {
295
+ renderVerticalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme, stackingContext) {
151
296
  const bandwidth = x.bandwidth ? x.bandwidth() : 20;
152
- const barWidth = this.maxBarSize ? Math.min(bandwidth, this.maxBarSize) : bandwidth;
297
+ const mode = stackingContext?.mode ?? 'normal';
298
+ // Calculate bar width based on stacking mode (same logic as renderVertical)
299
+ let barWidth;
300
+ let barOffset;
301
+ if (mode === 'none') {
302
+ const totalSeries = stackingContext?.totalSeries ?? 1;
303
+ const seriesIndex = stackingContext?.seriesIndex ?? 0;
304
+ const gap = stackingContext?.gap ?? 0.1;
305
+ const groupWidth = this.maxBarSize
306
+ ? Math.min(bandwidth, this.maxBarSize * totalSeries)
307
+ : bandwidth;
308
+ const totalGapSpace = groupWidth * gap * (totalSeries - 1);
309
+ const availableWidth = groupWidth - totalGapSpace;
310
+ barWidth = availableWidth / totalSeries;
311
+ const gapSize = totalSeries > 1 ? groupWidth * gap : 0;
312
+ barOffset =
313
+ (bandwidth - groupWidth) / 2 +
314
+ seriesIndex * (barWidth + gapSize);
315
+ }
316
+ else if (mode === 'layer') {
317
+ const totalSeries = stackingContext?.totalSeries ?? 1;
318
+ const seriesIndex = stackingContext?.seriesIndex ?? 0;
319
+ const maxWidth = this.maxBarSize
320
+ ? Math.min(bandwidth, this.maxBarSize)
321
+ : bandwidth;
322
+ const scaleFactor = 1 - (seriesIndex / totalSeries) * 0.7;
323
+ barWidth = maxWidth * scaleFactor;
324
+ barOffset = (bandwidth - barWidth) / 2;
325
+ }
326
+ else {
327
+ barWidth = this.maxBarSize
328
+ ? Math.min(bandwidth, this.maxBarSize)
329
+ : bandwidth;
330
+ barOffset = (bandwidth - barWidth) / 2;
331
+ }
153
332
  const yDomain = y.domain();
154
333
  const baselineValue = yDomain[0] >= 0 ? Math.max(0, yDomain[0]) : yDomain[0];
155
334
  const yBaseline = y(baselineValue) || 0;
@@ -166,16 +345,37 @@ export class Bar {
166
345
  const padding = config.padding ?? theme.valueLabel.padding;
167
346
  const labelGroup = plotGroup
168
347
  .append('g')
169
- .attr('class', `bar-value-labels-${this.dataKey.replace(/\s+/g, '-')}`);
348
+ .attr('class', `bar-value-labels-${sanitizeForCSS(this.dataKey)}`);
170
349
  data.forEach((d) => {
350
+ const categoryKey = String(d[xKey]);
171
351
  const value = parseValue(d[this.dataKey]);
172
352
  const valueText = String(value);
173
353
  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;
354
+ // Calculate bar position based on stacking mode
355
+ let barTop;
356
+ let barBottom;
357
+ if (mode === 'none' || mode === 'layer') {
358
+ const yPos = y(value) || 0;
359
+ barTop = Math.min(yBaseline, yPos);
360
+ barBottom = Math.max(yBaseline, yPos);
361
+ }
362
+ else if (mode === 'percent') {
363
+ const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
364
+ const total = stackingContext?.totalData.get(categoryKey) ?? 1;
365
+ const percentCumulative = (cumulative / total) * 100;
366
+ const percentValue = (value / total) * 100;
367
+ barTop = y(percentCumulative + percentValue) || 0;
368
+ barBottom = y(percentCumulative) || 0;
369
+ }
370
+ else {
371
+ const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
372
+ barTop = y(cumulative + value) || 0;
373
+ barBottom = y(cumulative) || 0;
374
+ }
375
+ const barHeight = Math.abs(barBottom - barTop);
376
+ const barCenterX = xPos +
377
+ (xScaleType === 'band' ? barOffset : -barWidth / 2) +
378
+ barWidth / 2;
179
379
  // Create temporary text to measure dimensions
180
380
  const tempText = labelGroup
181
381
  .append('text')
@@ -186,7 +386,7 @@ export class Bar {
186
386
  const textBBox = tempText.node().getBBox();
187
387
  const boxWidth = textBBox.width + padding * 2;
188
388
  const boxHeight = textBBox.height + padding * 2;
189
- let labelX = barCenterX;
389
+ const labelX = barCenterX;
190
390
  let labelY;
191
391
  let shouldRender = true;
192
392
  if (position === 'outside') {
@@ -249,9 +449,43 @@ export class Bar {
249
449
  }
250
450
  });
251
451
  }
252
- renderHorizontalValueLabels(plotGroup, data, xKey, x, y, parseValue, yScaleType, theme) {
452
+ renderHorizontalValueLabels(plotGroup, data, xKey, x, y, parseValue, yScaleType, theme, stackingContext) {
253
453
  const bandwidth = y.bandwidth ? y.bandwidth() : 20;
254
- const barHeight = this.maxBarSize ? Math.min(bandwidth, this.maxBarSize) : bandwidth;
454
+ const mode = stackingContext?.mode ?? 'normal';
455
+ // Calculate bar height based on stacking mode (same logic as renderHorizontal)
456
+ let barHeight;
457
+ let barOffset;
458
+ if (mode === 'none') {
459
+ const totalSeries = stackingContext?.totalSeries ?? 1;
460
+ const seriesIndex = stackingContext?.seriesIndex ?? 0;
461
+ const gap = stackingContext?.gap ?? 0.1;
462
+ const groupHeight = this.maxBarSize
463
+ ? Math.min(bandwidth, this.maxBarSize * totalSeries)
464
+ : bandwidth;
465
+ const totalGapSpace = groupHeight * gap * (totalSeries - 1);
466
+ const availableHeight = groupHeight - totalGapSpace;
467
+ barHeight = availableHeight / totalSeries;
468
+ const gapSize = totalSeries > 1 ? groupHeight * gap : 0;
469
+ barOffset =
470
+ (bandwidth - groupHeight) / 2 +
471
+ seriesIndex * (barHeight + gapSize);
472
+ }
473
+ else if (mode === 'layer') {
474
+ const totalSeries = stackingContext?.totalSeries ?? 1;
475
+ const seriesIndex = stackingContext?.seriesIndex ?? 0;
476
+ const maxHeight = this.maxBarSize
477
+ ? Math.min(bandwidth, this.maxBarSize)
478
+ : bandwidth;
479
+ const scaleFactor = 1 - (seriesIndex / totalSeries) * 0.7;
480
+ barHeight = maxHeight * scaleFactor;
481
+ barOffset = (bandwidth - barHeight) / 2;
482
+ }
483
+ else {
484
+ barHeight = this.maxBarSize
485
+ ? Math.min(bandwidth, this.maxBarSize)
486
+ : bandwidth;
487
+ barOffset = (bandwidth - barHeight) / 2;
488
+ }
255
489
  const domain = x.domain();
256
490
  const baselineValue = domain[0] >= 0 ? Math.max(0, domain[0]) : domain[0];
257
491
  const xBaseline = x(baselineValue) || 0;
@@ -268,16 +502,37 @@ export class Bar {
268
502
  const padding = config.padding ?? theme.valueLabel.padding;
269
503
  const labelGroup = plotGroup
270
504
  .append('g')
271
- .attr('class', `bar-value-labels-${this.dataKey.replace(/\s+/g, '-')}`);
505
+ .attr('class', `bar-value-labels-${sanitizeForCSS(this.dataKey)}`);
272
506
  data.forEach((d) => {
507
+ const categoryKey = String(d[xKey]);
273
508
  const value = parseValue(d[this.dataKey]);
274
509
  const valueText = String(value);
275
510
  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;
511
+ // Calculate bar position based on stacking mode
512
+ let barLeft;
513
+ let barRight;
514
+ if (mode === 'none' || mode === 'layer') {
515
+ const xPos = x(value) || 0;
516
+ barLeft = Math.min(xBaseline, xPos);
517
+ barRight = Math.max(xBaseline, xPos);
518
+ }
519
+ else if (mode === 'percent') {
520
+ const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
521
+ const total = stackingContext?.totalData.get(categoryKey) ?? 1;
522
+ const percentCumulative = (cumulative / total) * 100;
523
+ const percentValue = (value / total) * 100;
524
+ barLeft = x(percentCumulative) || 0;
525
+ barRight = x(percentCumulative + percentValue) || 0;
526
+ }
527
+ else {
528
+ const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
529
+ barLeft = x(cumulative) || 0;
530
+ barRight = x(cumulative + value) || 0;
531
+ }
532
+ const barWidth = Math.abs(barRight - barLeft);
533
+ const barCenterY = yPos +
534
+ (yScaleType === 'band' ? barOffset : -barHeight / 2) +
535
+ barHeight / 2;
281
536
  // Create temporary text to measure dimensions
282
537
  const tempText = labelGroup
283
538
  .append('text')
@@ -289,7 +544,7 @@ export class Bar {
289
544
  const boxWidth = textBBox.width + padding * 2;
290
545
  const boxHeight = textBBox.height + padding * 2;
291
546
  let labelX;
292
- let labelY = barCenterY;
547
+ const labelY = barCenterY;
293
548
  let shouldRender = true;
294
549
  if (position === 'outside') {
295
550
  // Place to the right of the bar
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, ExportOptions, D3Scale } 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';
@@ -30,8 +30,8 @@ export declare abstract class BaseChart {
30
30
  protected svg: Selection<SVGSVGElement, undefined, null, undefined> | null;
31
31
  protected plotGroup: Selection<SVGGElement, undefined, null, undefined> | null;
32
32
  protected container: HTMLElement | null;
33
- protected x: any;
34
- protected y: any;
33
+ protected x: D3Scale | null;
34
+ protected y: D3Scale | null;
35
35
  protected resizeObserver: ResizeObserver | null;
36
36
  protected layoutManager: LayoutManager;
37
37
  protected plotArea: PlotAreaBounds | null;
@@ -68,5 +68,18 @@ export declare abstract class BaseChart {
68
68
  * Destroys the chart and cleans up resources
69
69
  */
70
70
  destroy(): void;
71
- protected parseValue(value: any): number;
71
+ protected parseValue(value: unknown): number;
72
+ /**
73
+ * Exports the chart in the specified format
74
+ * @param format - The export format ('svg' or 'json')
75
+ * @param options - Optional export options (download, filename)
76
+ * @returns The exported content as a string if download is false/undefined, void if download is true
77
+ */
78
+ export(format: ExportFormat, options?: ExportOptions): string | void;
79
+ /**
80
+ * Downloads the exported content as a file
81
+ */
82
+ private downloadContent;
83
+ protected exportSVG(): string;
84
+ protected exportJSON(): string;
72
85
  }
package/base-chart.js CHANGED
@@ -222,6 +222,62 @@ export class BaseChart {
222
222
  this.y = null;
223
223
  }
224
224
  parseValue(value) {
225
- return typeof value === 'string' ? parseFloat(value) : value;
225
+ if (typeof value === 'string') {
226
+ return parseFloat(value);
227
+ }
228
+ if (typeof value === 'number') {
229
+ return value;
230
+ }
231
+ return 0;
232
+ }
233
+ /**
234
+ * Exports the chart in the specified format
235
+ * @param format - The export format ('svg' or 'json')
236
+ * @param options - Optional export options (download, filename)
237
+ * @returns The exported content as a string if download is false/undefined, void if download is true
238
+ */
239
+ export(format, options) {
240
+ const content = format === 'svg'
241
+ ? this.exportSVG()
242
+ : this.exportJSON();
243
+ if (options?.download) {
244
+ this.downloadContent(content, format, options);
245
+ return;
246
+ }
247
+ return content;
248
+ }
249
+ /**
250
+ * Downloads the exported content as a file
251
+ */
252
+ downloadContent(content, format, options) {
253
+ const mimeType = format === 'svg'
254
+ ? 'image/svg+xml'
255
+ : 'application/json';
256
+ const blob = new Blob([content], { type: mimeType });
257
+ const url = URL.createObjectURL(blob);
258
+ const link = document.createElement('a');
259
+ link.href = url;
260
+ link.download = options.filename || `chart.${format}`;
261
+ document.body.appendChild(link);
262
+ link.click();
263
+ document.body.removeChild(link);
264
+ URL.revokeObjectURL(url);
265
+ }
266
+ exportSVG() {
267
+ if (!this.svg) {
268
+ throw new Error('Chart must be rendered before export');
269
+ }
270
+ const clone = this.svg.node().cloneNode(true);
271
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
272
+ clone.setAttribute('width', String(this.width));
273
+ clone.setAttribute('height', String(this.theme.height));
274
+ return clone.outerHTML;
275
+ }
276
+ exportJSON() {
277
+ return JSON.stringify({
278
+ data: this.data,
279
+ theme: this.theme,
280
+ scales: this.scaleConfig,
281
+ }, null, 2);
226
282
  }
227
283
  }
package/grid.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { type Selection } from 'd3';
2
- import type { GridConfig, ChartTheme } from './types.js';
2
+ import type { GridConfig, ChartTheme, D3Scale } from './types.js';
3
3
  import type { ChartComponent } from './chart-interface.js';
4
4
  export declare class Grid implements ChartComponent {
5
5
  readonly type: "grid";
6
6
  readonly horizontal: boolean;
7
7
  readonly vertical: boolean;
8
8
  constructor(config?: GridConfig);
9
- render(plotGroup: Selection<SVGGElement, undefined, null, undefined>, x: any, y: any, theme: ChartTheme): void;
9
+ render(plotGroup: Selection<SVGGElement, undefined, null, undefined>, x: D3Scale, y: D3Scale, theme: ChartTheme): void;
10
10
  }
package/line.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type Selection } from 'd3';
2
- import type { LineConfig, DataItem, ScaleType, ChartTheme, LineValueLabelConfig } from './types.js';
2
+ import type { LineConfig, DataItem, D3Scale, 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";
@@ -8,6 +8,6 @@ export declare class Line implements ChartComponent {
8
8
  readonly strokeWidth?: number;
9
9
  readonly valueLabel?: LineValueLabelConfig;
10
10
  constructor(config: LineConfig);
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;
11
+ render(plotGroup: Selection<SVGGElement, undefined, null, undefined>, data: DataItem[], xKey: string, x: D3Scale, y: D3Scale, parseValue: (value: unknown) => number, xScaleType: ScaleType | undefined, theme: ChartTheme): void;
12
12
  private renderValueLabels;
13
13
  }