@internetstiftelsen/charts 0.9.2 → 0.10.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/dist/bar.js CHANGED
@@ -4,7 +4,6 @@ const LABEL_INSET_DEFAULT = 4;
4
4
  const LABEL_INSET_STACKED = 6;
5
5
  const LABEL_MIN_PADDING_DEFAULT = 8;
6
6
  const LABEL_MIN_PADDING_STACKED = 16;
7
- const LAYER_LABEL_GAP = 6;
8
7
  function getLabelSpacing(mode) {
9
8
  const stacked = mode !== 'none';
10
9
  return {
@@ -14,6 +13,93 @@ function getLabelSpacing(mode) {
14
13
  : LABEL_MIN_PADDING_DEFAULT,
15
14
  };
16
15
  }
16
+ function getBarSlotLayout(bandwidth, mode, maxBarSize, totalSeries, seriesIndex, gap) {
17
+ if (mode === 'none') {
18
+ const groupSize = maxBarSize
19
+ ? Math.min(bandwidth, maxBarSize * totalSeries)
20
+ : bandwidth;
21
+ const totalGapSpace = groupSize * gap * (totalSeries - 1);
22
+ const availableSize = groupSize - totalGapSpace;
23
+ const thickness = availableSize / totalSeries;
24
+ const gapSize = totalSeries > 1 ? groupSize * gap : 0;
25
+ return {
26
+ thickness,
27
+ offset: (bandwidth - groupSize) / 2 +
28
+ seriesIndex * (thickness + gapSize),
29
+ };
30
+ }
31
+ if (mode === 'layer') {
32
+ const maxSize = maxBarSize
33
+ ? Math.min(bandwidth, maxBarSize)
34
+ : bandwidth;
35
+ const scaleFactor = 1 - (seriesIndex / totalSeries) * 0.7;
36
+ const thickness = maxSize * scaleFactor;
37
+ return {
38
+ thickness,
39
+ offset: (bandwidth - thickness) / 2,
40
+ };
41
+ }
42
+ const thickness = maxBarSize ? Math.min(bandwidth, maxBarSize) : bandwidth;
43
+ return {
44
+ thickness,
45
+ offset: (bandwidth - thickness) / 2,
46
+ };
47
+ }
48
+ function getBarValueRange(categoryKey, value, stackingContext) {
49
+ const mode = stackingContext?.mode ?? 'normal';
50
+ if (mode === 'none' || mode === 'layer') {
51
+ return {
52
+ start: 0,
53
+ end: value,
54
+ };
55
+ }
56
+ if (mode === 'percent') {
57
+ if (value >= 0) {
58
+ const cumulative = stackingContext?.positiveCumulativeData.get(categoryKey) ?? 0;
59
+ const total = stackingContext?.positiveTotalData.get(categoryKey) ?? 0;
60
+ if (total === 0) {
61
+ return { start: 0, end: 0 };
62
+ }
63
+ return {
64
+ start: (cumulative / total) * 100,
65
+ end: ((cumulative + value) / total) * 100,
66
+ };
67
+ }
68
+ const cumulativeMagnitude = stackingContext?.negativeCumulativeData.get(categoryKey) ?? 0;
69
+ const totalMagnitude = stackingContext?.negativeTotalData.get(categoryKey) ?? 0;
70
+ if (totalMagnitude === 0) {
71
+ return { start: 0, end: 0 };
72
+ }
73
+ return {
74
+ start: -((cumulativeMagnitude / totalMagnitude) * 100),
75
+ end: -(((cumulativeMagnitude + Math.abs(value)) / totalMagnitude) *
76
+ 100),
77
+ };
78
+ }
79
+ if (value >= 0) {
80
+ const cumulative = stackingContext?.positiveCumulativeData.get(categoryKey) ?? 0;
81
+ return {
82
+ start: cumulative,
83
+ end: cumulative + value,
84
+ };
85
+ }
86
+ const cumulativeMagnitude = stackingContext?.negativeCumulativeData.get(categoryKey) ?? 0;
87
+ const start = -cumulativeMagnitude;
88
+ return {
89
+ start,
90
+ end: start + value,
91
+ };
92
+ }
93
+ function getScaledValueRange(scale, startValue, endValue) {
94
+ const start = scale(startValue) ?? 0;
95
+ const end = scale(endValue) ?? 0;
96
+ return {
97
+ start,
98
+ end,
99
+ min: Math.min(start, end),
100
+ max: Math.max(start, end),
101
+ };
102
+ }
17
103
  export class Bar {
18
104
  constructor(config) {
19
105
  Object.defineProperty(this, "type", {
@@ -46,6 +132,12 @@ export class Bar {
46
132
  writable: true,
47
133
  value: void 0
48
134
  });
135
+ Object.defineProperty(this, "side", {
136
+ enumerable: true,
137
+ configurable: true,
138
+ writable: true,
139
+ value: void 0
140
+ });
49
141
  Object.defineProperty(this, "valueLabel", {
50
142
  enumerable: true,
51
143
  configurable: true,
@@ -62,6 +154,7 @@ export class Bar {
62
154
  this.fill = config.fill || '#8884d8';
63
155
  this.colorAdapter = config.colorAdapter;
64
156
  this.maxBarSize = config.maxBarSize;
157
+ this.side = config.side ?? 'right';
65
158
  this.valueLabel = config.valueLabel;
66
159
  this.exportHooks = config.exportHooks;
67
160
  }
@@ -71,6 +164,7 @@ export class Bar {
71
164
  fill: this.fill,
72
165
  colorAdapter: this.colorAdapter,
73
166
  maxBarSize: this.maxBarSize,
167
+ side: this.side,
74
168
  valueLabel: this.valueLabel,
75
169
  };
76
170
  }
@@ -81,6 +175,12 @@ export class Bar {
81
175
  exportHooks: this.exportHooks,
82
176
  });
83
177
  }
178
+ getRenderedValue(value, orientation = 'vertical') {
179
+ if (orientation === 'horizontal' && this.side === 'left') {
180
+ return -Math.abs(value);
181
+ }
182
+ return value;
183
+ }
84
184
  render(plotGroup, data, xKey, x, y, parseValue, xScaleType = 'band', theme, stackingContext, orientation = 'vertical') {
85
185
  if (orientation === 'vertical') {
86
186
  this.renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext);
@@ -101,49 +201,13 @@ export class Bar {
101
201
  renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext) {
102
202
  const bandwidth = x.bandwidth ? x.bandwidth() : 20;
103
203
  const mode = stackingContext?.mode ?? 'normal';
104
- // Calculate bar width based on stacking mode
105
- let barWidth;
106
- let barOffset;
107
- if (mode === 'none') {
108
- // Grouped bars: divide bandwidth among series with gap
109
- const totalSeries = stackingContext?.totalSeries ?? 1;
110
- const seriesIndex = stackingContext?.seriesIndex ?? 0;
111
- const gap = stackingContext?.gap ?? 0.1;
112
- const groupWidth = this.maxBarSize
113
- ? Math.min(bandwidth, this.maxBarSize * totalSeries)
114
- : bandwidth;
115
- // Calculate total gap space and individual bar width
116
- const totalGapSpace = groupWidth * gap * (totalSeries - 1);
117
- const availableWidth = groupWidth - totalGapSpace;
118
- barWidth = availableWidth / totalSeries;
119
- const gapSize = totalSeries > 1 ? groupWidth * gap : 0;
120
- barOffset =
121
- (bandwidth - groupWidth) / 2 +
122
- seriesIndex * (barWidth + gapSize);
123
- }
124
- else if (mode === 'layer') {
125
- // Layer mode: each subsequent series has smaller bars
126
- const totalSeries = stackingContext?.totalSeries ?? 1;
127
- const seriesIndex = stackingContext?.seriesIndex ?? 0;
128
- const maxWidth = this.maxBarSize
129
- ? Math.min(bandwidth, this.maxBarSize)
130
- : bandwidth;
131
- // Scale from 100% to a minimum (e.g., 30%) based on series position
132
- const scaleFactor = 1 - (seriesIndex / totalSeries) * 0.7;
133
- barWidth = maxWidth * scaleFactor;
134
- barOffset = (bandwidth - barWidth) / 2;
135
- }
136
- else {
137
- // Normal and Percent modes: full width stacked bars
138
- barWidth = this.maxBarSize
139
- ? Math.min(bandwidth, this.maxBarSize)
140
- : bandwidth;
141
- barOffset = (bandwidth - barWidth) / 2;
142
- }
143
- // Get the baseline value from the Y scale's domain
144
- const yDomain = y.domain();
145
- const baselineValue = yDomain[0] >= 0 ? Math.max(0, yDomain[0]) : yDomain[0];
146
- const yBaseline = y(baselineValue) || 0;
204
+ const { thickness: barWidth, offset: barOffset } = getBarSlotLayout(bandwidth, mode, this.maxBarSize, stackingContext?.totalSeries ?? 1, stackingContext?.seriesIndex ?? 0, stackingContext?.gap ?? 0.1);
205
+ const getVerticalBounds = (d) => {
206
+ const categoryKey = String(d[xKey]);
207
+ const value = this.getRenderedValue(parseValue(d[this.dataKey]), 'vertical');
208
+ const { start, end } = getBarValueRange(categoryKey, value, stackingContext);
209
+ return getScaledValueRange(y, start, end);
210
+ };
147
211
  // Add bar rectangles
148
212
  const sanitizedKey = sanitizeForCSS(this.dataKey);
149
213
  plotGroup
@@ -158,96 +222,24 @@ export class Bar {
158
222
  ? xPos + barOffset
159
223
  : xPos - barWidth / 2;
160
224
  })
161
- .attr('y', (d) => {
162
- const categoryKey = String(d[xKey]);
163
- const value = parseValue(d[this.dataKey]);
164
- if (mode === 'none' || mode === 'layer') {
165
- // No stacking - each bar starts from baseline
166
- const yPos = y(value) || 0;
167
- return Math.min(yBaseline, yPos);
168
- }
169
- else if (mode === 'percent') {
170
- // Percent mode: calculate position based on cumulative percentage
171
- const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
172
- const total = stackingContext?.totalData.get(categoryKey) ?? 1;
173
- const percentCumulative = (cumulative / total) * 100;
174
- const percentValue = (value / total) * 100;
175
- return y(percentCumulative + percentValue) || 0;
176
- }
177
- else {
178
- // Normal stacking mode
179
- const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
180
- return y(cumulative + value) || 0;
181
- }
182
- })
225
+ .attr('y', (d) => getVerticalBounds(d).min)
183
226
  .attr('width', barWidth)
184
227
  .attr('height', (d) => {
185
- const categoryKey = String(d[xKey]);
186
- const value = parseValue(d[this.dataKey]);
187
- if (mode === 'none' || mode === 'layer') {
188
- const yPos = y(value) || 0;
189
- return Math.abs(yBaseline - yPos);
190
- }
191
- else if (mode === 'percent') {
192
- const total = stackingContext?.totalData.get(categoryKey) ?? 1;
193
- const percentValue = (value / total) * 100;
194
- const yTop = y(percentValue) || 0;
195
- const yBottom = y(0) || 0;
196
- return Math.abs(yBottom - yTop);
197
- }
198
- else {
199
- // Normal stacking mode
200
- const yTop = y(value) || 0;
201
- return Math.abs(yBaseline - yTop);
202
- }
228
+ const bounds = getVerticalBounds(d);
229
+ return Math.abs(bounds.max - bounds.min);
203
230
  })
204
231
  .attr('fill', (d, i) => this.colorAdapter ? this.colorAdapter(d, i) : this.fill);
205
232
  }
206
233
  renderHorizontal(plotGroup, data, xKey, x, y, parseValue, yScaleType, stackingContext) {
207
234
  const bandwidth = y.bandwidth ? y.bandwidth() : 20;
208
235
  const mode = stackingContext?.mode ?? 'normal';
209
- // Calculate bar height based on stacking mode
210
- let barHeight;
211
- let barOffset;
212
- if (mode === 'none') {
213
- // Grouped bars: divide bandwidth among series with gap
214
- const totalSeries = stackingContext?.totalSeries ?? 1;
215
- const seriesIndex = stackingContext?.seriesIndex ?? 0;
216
- const gap = stackingContext?.gap ?? 0.1;
217
- const groupHeight = this.maxBarSize
218
- ? Math.min(bandwidth, this.maxBarSize * totalSeries)
219
- : bandwidth;
220
- // Calculate total gap space and individual bar height
221
- const totalGapSpace = groupHeight * gap * (totalSeries - 1);
222
- const availableHeight = groupHeight - totalGapSpace;
223
- barHeight = availableHeight / totalSeries;
224
- const gapSize = totalSeries > 1 ? groupHeight * gap : 0;
225
- barOffset =
226
- (bandwidth - groupHeight) / 2 +
227
- seriesIndex * (barHeight + gapSize);
228
- }
229
- else if (mode === 'layer') {
230
- // Layer mode: each subsequent series has smaller bars
231
- const totalSeries = stackingContext?.totalSeries ?? 1;
232
- const seriesIndex = stackingContext?.seriesIndex ?? 0;
233
- const maxHeight = this.maxBarSize
234
- ? Math.min(bandwidth, this.maxBarSize)
235
- : bandwidth;
236
- const scaleFactor = 1 - (seriesIndex / totalSeries) * 0.7;
237
- barHeight = maxHeight * scaleFactor;
238
- barOffset = (bandwidth - barHeight) / 2;
239
- }
240
- else {
241
- // Normal and Percent modes: full height stacked bars
242
- barHeight = this.maxBarSize
243
- ? Math.min(bandwidth, this.maxBarSize)
244
- : bandwidth;
245
- barOffset = (bandwidth - barHeight) / 2;
246
- }
247
- // Get the baseline value from the scale's domain
248
- const domain = x.domain();
249
- const baselineValue = domain[0] >= 0 ? Math.max(0, domain[0]) : domain[0];
250
- const xBaseline = x(baselineValue) || 0;
236
+ const { thickness: barHeight, offset: barOffset } = getBarSlotLayout(bandwidth, mode, this.maxBarSize, stackingContext?.totalSeries ?? 1, stackingContext?.seriesIndex ?? 0, stackingContext?.gap ?? 0.1);
237
+ const getHorizontalBounds = (d) => {
238
+ const categoryKey = String(d[xKey]);
239
+ const value = this.getRenderedValue(parseValue(d[this.dataKey]), 'horizontal');
240
+ const { start, end } = getBarValueRange(categoryKey, value, stackingContext);
241
+ return getScaledValueRange(x, start, end);
242
+ };
251
243
  // Add bar rectangles (horizontal)
252
244
  const sanitizedKey = sanitizeForCSS(this.dataKey);
253
245
  plotGroup
@@ -256,25 +248,7 @@ export class Bar {
256
248
  .join('rect')
257
249
  .attr('class', `bar-${sanitizedKey}`)
258
250
  .attr('data-index', (_, i) => i)
259
- .attr('x', (d) => {
260
- const categoryKey = String(d[xKey]);
261
- const value = parseValue(d[this.dataKey]);
262
- if (mode === 'none' || mode === 'layer') {
263
- const xPos = x(value) || 0;
264
- return Math.min(xBaseline, xPos);
265
- }
266
- else if (mode === 'percent') {
267
- const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
268
- const total = stackingContext?.totalData.get(categoryKey) ?? 1;
269
- const percentCumulative = (cumulative / total) * 100;
270
- return x(percentCumulative) || 0;
271
- }
272
- else {
273
- // Normal stacking mode
274
- const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
275
- return x(cumulative) || 0;
276
- }
277
- })
251
+ .attr('x', (d) => getHorizontalBounds(d).min)
278
252
  .attr('y', (d) => {
279
253
  const yPos = getScalePosition(y, d[xKey], yScaleType);
280
254
  return yScaleType === 'band'
@@ -282,25 +256,8 @@ export class Bar {
282
256
  : yPos - barHeight / 2;
283
257
  })
284
258
  .attr('width', (d) => {
285
- const categoryKey = String(d[xKey]);
286
- const value = parseValue(d[this.dataKey]);
287
- if (mode === 'none' || mode === 'layer') {
288
- const xPos = x(value) || 0;
289
- return Math.abs(xPos - xBaseline);
290
- }
291
- else if (mode === 'percent') {
292
- const total = stackingContext?.totalData.get(categoryKey) ?? 1;
293
- const percentValue = (value / total) * 100;
294
- const xLeft = x(0) || 0;
295
- const xRight = x(percentValue) || 0;
296
- return Math.abs(xRight - xLeft);
297
- }
298
- else {
299
- // Normal stacking mode
300
- const xLeft = x(0) || 0;
301
- const xRight = x(value) || 0;
302
- return Math.abs(xRight - xLeft);
303
- }
259
+ const bounds = getHorizontalBounds(d);
260
+ return Math.abs(bounds.max - bounds.min);
304
261
  })
305
262
  .attr('height', barHeight)
306
263
  .attr('fill', (d, i) => this.colorAdapter ? this.colorAdapter(d, i) : this.fill);
@@ -308,43 +265,7 @@ export class Bar {
308
265
  renderVerticalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme, stackingContext) {
309
266
  const bandwidth = x.bandwidth ? x.bandwidth() : 20;
310
267
  const mode = stackingContext?.mode ?? 'normal';
311
- // Calculate bar width based on stacking mode (same logic as renderVertical)
312
- let barWidth;
313
- let barOffset;
314
- if (mode === 'none') {
315
- const totalSeries = stackingContext?.totalSeries ?? 1;
316
- const seriesIndex = stackingContext?.seriesIndex ?? 0;
317
- const gap = stackingContext?.gap ?? 0.1;
318
- const groupWidth = this.maxBarSize
319
- ? Math.min(bandwidth, this.maxBarSize * totalSeries)
320
- : bandwidth;
321
- const totalGapSpace = groupWidth * gap * (totalSeries - 1);
322
- const availableWidth = groupWidth - totalGapSpace;
323
- barWidth = availableWidth / totalSeries;
324
- const gapSize = totalSeries > 1 ? groupWidth * gap : 0;
325
- barOffset =
326
- (bandwidth - groupWidth) / 2 +
327
- seriesIndex * (barWidth + gapSize);
328
- }
329
- else if (mode === 'layer') {
330
- const totalSeries = stackingContext?.totalSeries ?? 1;
331
- const seriesIndex = stackingContext?.seriesIndex ?? 0;
332
- const maxWidth = this.maxBarSize
333
- ? Math.min(bandwidth, this.maxBarSize)
334
- : bandwidth;
335
- const scaleFactor = 1 - (seriesIndex / totalSeries) * 0.7;
336
- barWidth = maxWidth * scaleFactor;
337
- barOffset = (bandwidth - barWidth) / 2;
338
- }
339
- else {
340
- barWidth = this.maxBarSize
341
- ? Math.min(bandwidth, this.maxBarSize)
342
- : bandwidth;
343
- barOffset = (bandwidth - barWidth) / 2;
344
- }
345
- const yDomain = y.domain();
346
- const baselineValue = yDomain[0] >= 0 ? Math.max(0, yDomain[0]) : yDomain[0];
347
- const yBaseline = y(baselineValue) || 0;
268
+ const { thickness: barWidth, offset: barOffset } = getBarSlotLayout(bandwidth, mode, this.maxBarSize, stackingContext?.totalSeries ?? 1, stackingContext?.seriesIndex ?? 0, stackingContext?.gap ?? 0.1);
348
269
  const config = this.valueLabel;
349
270
  const position = config.position || 'outside';
350
271
  const insidePosition = config.insidePosition || 'top';
@@ -361,33 +282,18 @@ export class Bar {
361
282
  .attr('class', `bar-value-labels-${sanitizeForCSS(this.dataKey)}`);
362
283
  data.forEach((d, i) => {
363
284
  const categoryKey = String(d[xKey]);
364
- const value = parseValue(d[this.dataKey]);
365
- const valueText = String(value);
285
+ const rawValue = parseValue(d[this.dataKey]);
286
+ const renderedValue = this.getRenderedValue(rawValue, 'vertical');
287
+ const valueText = String(rawValue);
366
288
  const xPos = getScalePosition(x, d[xKey], xScaleType);
367
289
  const barColor = this.colorAdapter
368
290
  ? this.colorAdapter(d, i)
369
291
  : this.fill;
370
- // Calculate bar position based on stacking mode
371
- let barTop;
372
- let barBottom;
373
- if (mode === 'none' || mode === 'layer') {
374
- const yPos = y(value) || 0;
375
- barTop = Math.min(yBaseline, yPos);
376
- barBottom = Math.max(yBaseline, yPos);
377
- }
378
- else if (mode === 'percent') {
379
- const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
380
- const total = stackingContext?.totalData.get(categoryKey) ?? 1;
381
- const percentCumulative = (cumulative / total) * 100;
382
- const percentValue = (value / total) * 100;
383
- barTop = y(percentCumulative + percentValue) || 0;
384
- barBottom = y(percentCumulative) || 0;
385
- }
386
- else {
387
- const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
388
- barTop = y(cumulative + value) || 0;
389
- barBottom = y(cumulative) || 0;
390
- }
292
+ const { start, end } = getBarValueRange(categoryKey, renderedValue, stackingContext);
293
+ const bounds = getScaledValueRange(y, start, end);
294
+ const barTop = bounds.min;
295
+ const barBottom = bounds.max;
296
+ const isNegative = renderedValue < 0;
391
297
  const barHeight = Math.abs(barBottom - barTop);
392
298
  const barCenterX = xPos +
393
299
  (xScaleType === 'band' ? barOffset : -barWidth / 2) +
@@ -406,11 +312,13 @@ export class Bar {
406
312
  let labelY = (barTop + barBottom) / 2; // Default to middle
407
313
  let shouldRender = true;
408
314
  if (position === 'outside') {
409
- // Place above the bar
410
- labelY = barTop - boxHeight / 2 - 4;
411
- // Check if it fits (not going above plot area)
412
315
  const plotTop = y.range()[1];
413
- if (labelY - boxHeight / 2 < plotTop) {
316
+ const plotBottom = y.range()[0];
317
+ labelY = isNegative
318
+ ? barBottom + boxHeight / 2 + 4
319
+ : barTop - boxHeight / 2 - 4;
320
+ if ((!isNegative && labelY - boxHeight / 2 < plotTop) ||
321
+ (isNegative && labelY + boxHeight / 2 > plotBottom)) {
414
322
  shouldRender = false;
415
323
  }
416
324
  }
@@ -432,25 +340,9 @@ export class Bar {
432
340
  labelY = barBottom - boxHeight / 2 - inset;
433
341
  break;
434
342
  }
435
- // Check if it fits inside the bar
436
343
  if (boxHeight + minPadding > barHeight) {
437
344
  shouldRender = false;
438
345
  }
439
- // In layer mode, check the label fits in the visible gap
440
- // above the next layer's bar top
441
- if (shouldRender &&
442
- mode === 'layer' &&
443
- insidePosition === 'top' &&
444
- stackingContext?.nextLayerData) {
445
- const nextValue = stackingContext.nextLayerData.get(categoryKey);
446
- if (nextValue !== undefined) {
447
- const nextBarTop = y(nextValue) || 0;
448
- const labelBottom = labelY + boxHeight / 2;
449
- if (labelBottom + LAYER_LABEL_GAP > nextBarTop) {
450
- shouldRender = false;
451
- }
452
- }
453
- }
454
346
  }
455
347
  }
456
348
  tempText.remove();
@@ -492,43 +384,7 @@ export class Bar {
492
384
  renderHorizontalValueLabels(plotGroup, data, xKey, x, y, parseValue, yScaleType, theme, stackingContext) {
493
385
  const bandwidth = y.bandwidth ? y.bandwidth() : 20;
494
386
  const mode = stackingContext?.mode ?? 'normal';
495
- // Calculate bar height based on stacking mode (same logic as renderHorizontal)
496
- let barHeight;
497
- let barOffset;
498
- if (mode === 'none') {
499
- const totalSeries = stackingContext?.totalSeries ?? 1;
500
- const seriesIndex = stackingContext?.seriesIndex ?? 0;
501
- const gap = stackingContext?.gap ?? 0.1;
502
- const groupHeight = this.maxBarSize
503
- ? Math.min(bandwidth, this.maxBarSize * totalSeries)
504
- : bandwidth;
505
- const totalGapSpace = groupHeight * gap * (totalSeries - 1);
506
- const availableHeight = groupHeight - totalGapSpace;
507
- barHeight = availableHeight / totalSeries;
508
- const gapSize = totalSeries > 1 ? groupHeight * gap : 0;
509
- barOffset =
510
- (bandwidth - groupHeight) / 2 +
511
- seriesIndex * (barHeight + gapSize);
512
- }
513
- else if (mode === 'layer') {
514
- const totalSeries = stackingContext?.totalSeries ?? 1;
515
- const seriesIndex = stackingContext?.seriesIndex ?? 0;
516
- const maxHeight = this.maxBarSize
517
- ? Math.min(bandwidth, this.maxBarSize)
518
- : bandwidth;
519
- const scaleFactor = 1 - (seriesIndex / totalSeries) * 0.7;
520
- barHeight = maxHeight * scaleFactor;
521
- barOffset = (bandwidth - barHeight) / 2;
522
- }
523
- else {
524
- barHeight = this.maxBarSize
525
- ? Math.min(bandwidth, this.maxBarSize)
526
- : bandwidth;
527
- barOffset = (bandwidth - barHeight) / 2;
528
- }
529
- const domain = x.domain();
530
- const baselineValue = domain[0] >= 0 ? Math.max(0, domain[0]) : domain[0];
531
- const xBaseline = x(baselineValue) || 0;
387
+ const { thickness: barHeight, offset: barOffset } = getBarSlotLayout(bandwidth, mode, this.maxBarSize, stackingContext?.totalSeries ?? 1, stackingContext?.seriesIndex ?? 0, stackingContext?.gap ?? 0.1);
532
388
  const config = this.valueLabel;
533
389
  const position = config.position || 'outside';
534
390
  const insidePosition = config.insidePosition || 'top';
@@ -545,33 +401,18 @@ export class Bar {
545
401
  .attr('class', `bar-value-labels-${sanitizeForCSS(this.dataKey)}`);
546
402
  data.forEach((d, i) => {
547
403
  const categoryKey = String(d[xKey]);
548
- const value = parseValue(d[this.dataKey]);
549
- const valueText = String(value);
404
+ const rawValue = parseValue(d[this.dataKey]);
405
+ const renderedValue = this.getRenderedValue(rawValue, 'horizontal');
406
+ const valueText = String(rawValue);
550
407
  const yPos = getScalePosition(y, d[xKey], yScaleType);
551
408
  const barColor = this.colorAdapter
552
409
  ? this.colorAdapter(d, i)
553
410
  : this.fill;
554
- // Calculate bar position based on stacking mode
555
- let barLeft;
556
- let barRight;
557
- if (mode === 'none' || mode === 'layer') {
558
- const xPos = x(value) || 0;
559
- barLeft = Math.min(xBaseline, xPos);
560
- barRight = Math.max(xBaseline, xPos);
561
- }
562
- else if (mode === 'percent') {
563
- const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
564
- const total = stackingContext?.totalData.get(categoryKey) ?? 1;
565
- const percentCumulative = (cumulative / total) * 100;
566
- const percentValue = (value / total) * 100;
567
- barLeft = x(percentCumulative) || 0;
568
- barRight = x(percentCumulative + percentValue) || 0;
569
- }
570
- else {
571
- const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
572
- barLeft = x(cumulative) || 0;
573
- barRight = x(cumulative + value) || 0;
574
- }
411
+ const { start, end } = getBarValueRange(categoryKey, renderedValue, stackingContext);
412
+ const bounds = getScaledValueRange(x, start, end);
413
+ const barLeft = bounds.min;
414
+ const barRight = bounds.max;
415
+ const isNegative = renderedValue < 0;
575
416
  const barWidth = Math.abs(barRight - barLeft);
576
417
  const barCenterY = yPos +
577
418
  (yScaleType === 'band' ? barOffset : -barHeight / 2) +
@@ -590,16 +431,17 @@ export class Bar {
590
431
  const labelY = barCenterY;
591
432
  let shouldRender = true;
592
433
  if (position === 'outside') {
593
- // Place to the right of the bar
594
- labelX = barRight + boxWidth / 2 + 4;
595
- // Check if it fits (not going beyond plot area)
596
- const plotRight = x.range()[1];
597
- if (labelX + boxWidth / 2 > plotRight) {
434
+ const plotLeft = Math.min(...x.range());
435
+ const plotRight = Math.max(...x.range());
436
+ labelX = isNegative
437
+ ? barLeft - boxWidth / 2 - 4
438
+ : barRight + boxWidth / 2 + 4;
439
+ if ((!isNegative && labelX + boxWidth / 2 > plotRight) ||
440
+ (isNegative && labelX - boxWidth / 2 < plotLeft)) {
598
441
  shouldRender = false;
599
442
  }
600
443
  }
601
444
  else {
602
- // Map top/middle/bottom to start/middle/end for horizontal
603
445
  if (mode === 'layer' && insidePosition === 'bottom') {
604
446
  // Bottom labels in layer mode are visually ambiguous and often hidden by overlap.
605
447
  shouldRender = false;
@@ -607,33 +449,31 @@ export class Bar {
607
449
  else {
608
450
  const { inset, minPadding } = getLabelSpacing(mode);
609
451
  switch (insidePosition) {
610
- case 'top': // start of bar (left side)
611
- labelX = barLeft + boxWidth / 2 + inset;
452
+ case 'top':
453
+ labelX = isNegative
454
+ ? barRight - boxWidth / 2 - inset
455
+ : barLeft + boxWidth / 2 + inset;
612
456
  break;
613
457
  case 'middle':
614
458
  labelX = (barLeft + barRight) / 2;
615
459
  break;
616
- case 'bottom': // end of bar (right side)
617
- labelX = barRight - boxWidth / 2 - inset;
460
+ case 'bottom':
461
+ labelX = isNegative
462
+ ? barLeft + boxWidth / 2 + inset
463
+ : barRight - boxWidth / 2 - inset;
618
464
  break;
619
465
  }
620
- // Check if it fits inside the bar
621
466
  if (boxWidth + minPadding > barWidth) {
622
467
  shouldRender = false;
623
468
  }
624
- // In layer mode, check the label fits in the visible gap
625
- // before the next layer's bar end
626
469
  if (shouldRender &&
627
- mode === 'layer' &&
628
- insidePosition === 'top' &&
629
- stackingContext?.nextLayerData) {
630
- const nextValue = stackingContext.nextLayerData.get(categoryKey);
631
- if (nextValue !== undefined) {
632
- const nextBarRight = x(nextValue) || 0;
633
- const labelRight = labelX + boxWidth / 2;
634
- if (labelRight + LAYER_LABEL_GAP > nextBarRight) {
635
- shouldRender = false;
636
- }
470
+ position === 'inside' &&
471
+ insidePosition !== 'middle') {
472
+ const labelLeft = labelX - boxWidth / 2;
473
+ const labelRight = labelX + boxWidth / 2;
474
+ if (labelLeft < barLeft + 1 ||
475
+ labelRight > barRight - 1) {
476
+ shouldRender = false;
637
477
  }
638
478
  }
639
479
  }