@internetstiftelsen/charts 0.1.0 → 0.2.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.js CHANGED
@@ -387,7 +387,7 @@ export class Bar {
387
387
  const boxWidth = textBBox.width + padding * 2;
388
388
  const boxHeight = textBBox.height + padding * 2;
389
389
  const labelX = barCenterX;
390
- let labelY;
390
+ let labelY = (barTop + barBottom) / 2; // Default to middle
391
391
  let shouldRender = true;
392
392
  if (position === 'outside') {
393
393
  // Place above the bar
@@ -399,21 +399,76 @@ export class Bar {
399
399
  }
400
400
  }
401
401
  else {
402
- // Inside the bar
403
- switch (insidePosition) {
404
- case 'top':
405
- labelY = barTop + boxHeight / 2 + 4;
406
- break;
407
- case 'middle':
408
- labelY = (barTop + barBottom) / 2;
409
- break;
410
- case 'bottom':
411
- labelY = barBottom - boxHeight / 2 - 4;
412
- break;
402
+ // Inside the bar - with special handling for layer mode
403
+ if (mode === 'layer') {
404
+ const totalSeries = stackingContext?.totalSeries ?? 1;
405
+ const seriesIndex = stackingContext?.seriesIndex ?? 0;
406
+ const isTopLayer = seriesIndex === totalSeries - 1;
407
+ switch (insidePosition) {
408
+ case 'top':
409
+ // For layer mode + inside + top: check if there's enough space in the gap
410
+ if (seriesIndex < totalSeries - 1) {
411
+ // Calculate the gap to the next layer
412
+ const nextLayerScaleFactor = 1 - ((seriesIndex + 1) / totalSeries) * 0.7;
413
+ const nextLayerWidth = (this.maxBarSize
414
+ ? Math.min(bandwidth, this.maxBarSize)
415
+ : bandwidth) * nextLayerScaleFactor;
416
+ const gap = (barWidth - nextLayerWidth) / 2;
417
+ const marginBelow = 4; // Minimum margin below text
418
+ if (boxHeight + marginBelow <= gap) {
419
+ labelY =
420
+ barTop + boxHeight / 2 + marginBelow;
421
+ }
422
+ else {
423
+ shouldRender = false;
424
+ }
425
+ }
426
+ else {
427
+ // Top layer - use normal top position if it fits
428
+ labelY = barTop + boxHeight / 2 + 4;
429
+ if (boxHeight + 8 > barHeight) {
430
+ shouldRender = false;
431
+ }
432
+ }
433
+ break;
434
+ case 'middle':
435
+ // For layer mode + inside + middle: only show what fits
436
+ labelY = (barTop + barBottom) / 2;
437
+ if (boxHeight + 8 > barHeight) {
438
+ shouldRender = false;
439
+ }
440
+ break;
441
+ case 'bottom':
442
+ // For layer mode + inside + bottom: only show for top layer if it fits
443
+ if (isTopLayer) {
444
+ labelY = barBottom - boxHeight / 2 - 4;
445
+ if (boxHeight + 8 > barHeight) {
446
+ shouldRender = false;
447
+ }
448
+ }
449
+ else {
450
+ shouldRender = false;
451
+ }
452
+ break;
453
+ }
413
454
  }
414
- // Check if it fits inside the bar
415
- if (boxHeight + 8 > barHeight) {
416
- shouldRender = false;
455
+ else {
456
+ // Non-layer modes - use existing logic
457
+ switch (insidePosition) {
458
+ case 'top':
459
+ labelY = barTop + boxHeight / 2 + 4;
460
+ break;
461
+ case 'middle':
462
+ labelY = (barTop + barBottom) / 2;
463
+ break;
464
+ case 'bottom':
465
+ labelY = barBottom - boxHeight / 2 - 4;
466
+ break;
467
+ }
468
+ // Check if it fits inside the bar
469
+ if (boxHeight + 8 > barHeight) {
470
+ shouldRender = false;
471
+ }
417
472
  }
418
473
  }
419
474
  tempText.remove();
@@ -543,7 +598,7 @@ export class Bar {
543
598
  const textBBox = tempText.node().getBBox();
544
599
  const boxWidth = textBBox.width + padding * 2;
545
600
  const boxHeight = textBBox.height + padding * 2;
546
- let labelX;
601
+ let labelX = (barLeft + barRight) / 2; // Default to middle
547
602
  const labelY = barCenterY;
548
603
  let shouldRender = true;
549
604
  if (position === 'outside') {
@@ -556,21 +611,78 @@ export class Bar {
556
611
  }
557
612
  }
558
613
  else {
559
- // Inside the bar - map top/middle/bottom to start/middle/end for horizontal
560
- switch (insidePosition) {
561
- case 'top': // start of bar (left side)
562
- labelX = barLeft + boxWidth / 2 + 4;
563
- break;
564
- case 'middle':
565
- labelX = (barLeft + barRight) / 2;
566
- break;
567
- case 'bottom': // end of bar (right side)
568
- labelX = barRight - boxWidth / 2 - 4;
569
- break;
614
+ // Inside the bar - with special handling for layer mode
615
+ if (mode === 'layer') {
616
+ const totalSeries = stackingContext?.totalSeries ?? 1;
617
+ const seriesIndex = stackingContext?.seriesIndex ?? 0;
618
+ const isTopLayer = seriesIndex === totalSeries - 1;
619
+ // Map top/middle/bottom to start/middle/end for horizontal
620
+ switch (insidePosition) {
621
+ case 'top': // start of bar (left side)
622
+ // For layer mode + inside + top(left): check if there's enough space in the gap
623
+ if (seriesIndex < totalSeries - 1) {
624
+ // Calculate the gap to the next layer
625
+ const nextLayerScaleFactor = 1 - ((seriesIndex + 1) / totalSeries) * 0.7;
626
+ const nextLayerHeight = (this.maxBarSize
627
+ ? Math.min(bandwidth, this.maxBarSize)
628
+ : bandwidth) * nextLayerScaleFactor;
629
+ const gap = (barHeight - nextLayerHeight) / 2;
630
+ const marginRight = 4; // Minimum margin to the right of text
631
+ if (boxWidth + marginRight <= gap) {
632
+ labelX =
633
+ barLeft + boxWidth / 2 + marginRight;
634
+ }
635
+ else {
636
+ shouldRender = false;
637
+ }
638
+ }
639
+ else {
640
+ // Top layer - use normal left position if it fits
641
+ labelX = barLeft + boxWidth / 2 + 4;
642
+ if (boxWidth + 8 > barWidth) {
643
+ shouldRender = false;
644
+ }
645
+ }
646
+ break;
647
+ case 'middle':
648
+ // For layer mode + inside + middle: only show what fits
649
+ labelX = (barLeft + barRight) / 2;
650
+ if (boxWidth + 8 > barWidth) {
651
+ shouldRender = false;
652
+ }
653
+ break;
654
+ case 'bottom': // end of bar (right side)
655
+ // For layer mode + inside + bottom(right): only show for top layer if it fits
656
+ if (isTopLayer) {
657
+ labelX = barRight - boxWidth / 2 - 4;
658
+ if (boxWidth + 8 > barWidth) {
659
+ shouldRender = false;
660
+ }
661
+ }
662
+ else {
663
+ shouldRender = false;
664
+ }
665
+ break;
666
+ }
570
667
  }
571
- // Check if it fits inside the bar
572
- if (boxWidth + 8 > barWidth) {
573
- shouldRender = false;
668
+ else {
669
+ // Non-layer modes - use existing logic
670
+ // Map top/middle/bottom to start/middle/end for horizontal
671
+ switch (insidePosition) {
672
+ case 'top': // start of bar (left side)
673
+ labelX = barLeft + boxWidth / 2 + 4;
674
+ break;
675
+ case 'middle':
676
+ labelX = (barLeft + barRight) / 2;
677
+ break;
678
+ case 'bottom': // end of bar (right side)
679
+ labelX = barRight - boxWidth / 2 - 4;
680
+ break;
681
+ }
682
+ // Check if it fits inside the bar
683
+ if (boxWidth + 8 > barWidth) {
684
+ shouldRender = false;
685
+ }
574
686
  }
575
687
  }
576
688
  tempText.remove();
package/base-chart.js CHANGED
@@ -237,9 +237,7 @@ export class BaseChart {
237
237
  * @returns The exported content as a string if download is false/undefined, void if download is true
238
238
  */
239
239
  export(format, options) {
240
- const content = format === 'svg'
241
- ? this.exportSVG()
242
- : this.exportJSON();
240
+ const content = format === 'svg' ? this.exportSVG() : this.exportJSON();
243
241
  if (options?.download) {
244
242
  this.downloadContent(content, format, options);
245
243
  return;
@@ -250,9 +248,7 @@ export class BaseChart {
250
248
  * Downloads the exported content as a file
251
249
  */
252
250
  downloadContent(content, format, options) {
253
- const mimeType = format === 'svg'
254
- ? 'image/svg+xml'
255
- : 'application/json';
251
+ const mimeType = format === 'svg' ? 'image/svg+xml' : 'application/json';
256
252
  const blob = new Blob([content], { type: mimeType });
257
253
  const url = URL.createObjectURL(blob);
258
254
  const link = document.createElement('a');
package/line.js CHANGED
@@ -50,7 +50,9 @@ export class Line {
50
50
  case 'time':
51
51
  // Time scale - convert to Date
52
52
  scaledValue =
53
- xValue instanceof Date ? xValue : new Date(String(xValue));
53
+ xValue instanceof Date
54
+ ? xValue
55
+ : new Date(String(xValue));
54
56
  break;
55
57
  case 'linear':
56
58
  case 'log':
@@ -63,7 +65,13 @@ export class Line {
63
65
  // Handle band scales with bandwidth
64
66
  return (scaled || 0) + (x.bandwidth ? x.bandwidth() / 2 : 0);
65
67
  };
68
+ // Helper to check if a data point has a valid (non-null) value
69
+ const hasValidValue = (d) => {
70
+ const value = d[this.dataKey];
71
+ return value !== null && value !== undefined;
72
+ };
66
73
  const lineGenerator = line()
74
+ .defined(hasValidValue)
67
75
  .x(getXPosition)
68
76
  .y((d) => y(parseValue(d[this.dataKey])) || 0);
69
77
  const lineStrokeWidth = this.strokeWidth ?? theme.line.strokeWidth;
@@ -79,11 +87,12 @@ export class Line {
79
87
  .attr('stroke', this.stroke)
80
88
  .attr('stroke-width', lineStrokeWidth)
81
89
  .attr('d', lineGenerator);
82
- // Add data point circles
90
+ // Add data point circles (only for valid values)
91
+ const validData = data.filter(hasValidValue);
83
92
  const sanitizedKey = sanitizeForCSS(this.dataKey);
84
93
  plotGroup
85
94
  .selectAll(`.circle-${sanitizedKey}`)
86
- .data(data)
95
+ .data(validData)
87
96
  .join('circle')
88
97
  .attr('class', `circle-${sanitizedKey}`)
89
98
  .attr('cx', getXPosition)
@@ -92,9 +101,9 @@ export class Line {
92
101
  .attr('fill', pointColor)
93
102
  .attr('stroke', pointStrokeColor)
94
103
  .attr('stroke-width', pointStrokeWidth);
95
- // Render value labels if enabled
104
+ // Render value labels if enabled (only for valid values)
96
105
  if (this.valueLabel?.show) {
97
- this.renderValueLabels(plotGroup, data, y, parseValue, theme, getXPosition);
106
+ this.renderValueLabels(plotGroup, validData, y, parseValue, theme, getXPosition);
98
107
  }
99
108
  }
100
109
  renderValueLabels(plotGroup, data, y, parseValue, theme, getXPosition) {
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0",
2
+ "version": "0.2.0",
3
3
  "name": "@internetstiftelsen/charts",
4
4
  "type": "module",
5
5
  "sideEffects": false,
package/tooltip.js CHANGED
@@ -59,7 +59,6 @@ export class Tooltip {
59
59
  .style('font-family', theme.axis.fontFamily)
60
60
  .style('font-size', '12px')
61
61
  .style('pointer-events', 'none')
62
- .style('transition', 'left 0.1s ease-out, top 0.1s ease-out')
63
62
  .style('z-index', '1000');
64
63
  }
65
64
  attachToArea(svg, data, series, xKey, x, y, theme, plotArea, parseValue, isHorizontal = false) {
@@ -171,8 +170,7 @@ export class Tooltip {
171
170
  if (hasBarSeries) {
172
171
  barSeries.forEach((s) => {
173
172
  const sanitizedKey = sanitizeForCSS(s.dataKey);
174
- svg.selectAll(`.bar-${sanitizedKey}`)
175
- .style('opacity', (_, i) => i === closestIndex ? 1 : 0.5);
173
+ svg.selectAll(`.bar-${sanitizedKey}`).style('opacity', (_, i) => (i === closestIndex ? 1 : 0.5));
176
174
  });
177
175
  }
178
176
  // Build tooltip content
@@ -197,33 +195,74 @@ export class Tooltip {
197
195
  }
198
196
  });
199
197
  }
200
- // Position tooltip relative to the data point
198
+ // Position tooltip: X anchored to data point, Y at midpoint of values
199
+ tooltip.style('visibility', 'visible').html(content);
200
+ // Get tooltip dimensions after content is set
201
+ const tooltipNode = tooltip.node();
202
+ const tooltipRect = tooltipNode.getBoundingClientRect();
203
+ const tooltipWidth = tooltipRect.width;
204
+ const tooltipHeight = tooltipRect.height;
201
205
  const svgRect = svg.node().getBoundingClientRect();
206
+ const offsetX = 12;
207
+ // Calculate min/max values across all series for this data point
208
+ const values = series.map((s) => parseValue(dataPoint[s.dataKey]));
209
+ const minValue = Math.min(...values);
210
+ const maxValue = Math.max(...values);
202
211
  let tooltipX;
203
212
  let tooltipY;
204
213
  if (isHorizontal) {
205
- // Horizontal: position near bar end (X = value, Y = category)
206
- tooltipX =
207
- svgRect.left +
208
- window.scrollX +
209
- x(parseValue(dataPoint[series[0].dataKey])) +
210
- 10;
214
+ // Horizontal: X at midpoint of values, Y anchored to category
215
+ const minX = x(minValue);
216
+ const maxX = x(maxValue);
217
+ const midX = (minX + maxX) / 2;
218
+ tooltipX = svgRect.left + window.scrollX + midX + offsetX;
211
219
  tooltipY =
212
- svgRect.top + window.scrollY + dataPointPosition - 10;
220
+ svgRect.top +
221
+ window.scrollY +
222
+ dataPointPosition -
223
+ tooltipHeight / 2;
213
224
  }
214
225
  else {
215
- // Vertical: position near data point (X = category, Y = value)
226
+ // Vertical: X anchored to category, Y at midpoint of values
227
+ const minY = y(maxValue); // Note: Y scale is inverted
228
+ const maxY = y(minValue);
229
+ const midY = (minY + maxY) / 2;
216
230
  tooltipX =
217
- svgRect.left + window.scrollX + dataPointPosition + 10;
231
+ svgRect.left +
232
+ window.scrollX +
233
+ dataPointPosition +
234
+ offsetX;
218
235
  tooltipY =
219
- svgRect.top +
220
- window.scrollY +
221
- y(parseValue(dataPoint[series[0].dataKey])) -
222
- 10;
236
+ svgRect.top + window.scrollY + midY - tooltipHeight / 2;
237
+ }
238
+ // Edge detection - flip horizontally if approaching right edge
239
+ const viewportWidth = window.innerWidth;
240
+ if (tooltipX + tooltipWidth > viewportWidth - 10) {
241
+ if (isHorizontal) {
242
+ const minX = x(minValue);
243
+ tooltipX =
244
+ svgRect.left +
245
+ window.scrollX +
246
+ minX -
247
+ tooltipWidth -
248
+ offsetX;
249
+ }
250
+ else {
251
+ tooltipX =
252
+ svgRect.left +
253
+ window.scrollX +
254
+ dataPointPosition -
255
+ tooltipWidth -
256
+ offsetX;
257
+ }
223
258
  }
259
+ // Ensure tooltip doesn't go off edges
260
+ tooltipX = Math.max(10, tooltipX);
261
+ tooltipY = Math.max(10, Math.min(tooltipY, window.innerHeight +
262
+ window.scrollY -
263
+ tooltipHeight -
264
+ 10));
224
265
  tooltip
225
- .style('visibility', 'visible')
226
- .html(content)
227
266
  .style('left', `${tooltipX}px`)
228
267
  .style('top', `${tooltipY}px`);
229
268
  })
package/types.d.ts CHANGED
@@ -97,6 +97,10 @@ export type XAxisConfig = {
97
97
  rotatedLabels?: boolean;
98
98
  maxLabelWidth?: number;
99
99
  oversizedBehavior?: LabelOversizedBehavior;
100
+ tickFormat?: string | ((value: number) => string) | null;
101
+ autoHideOverlapping?: boolean;
102
+ minLabelGap?: number;
103
+ preserveEndLabels?: boolean;
100
104
  };
101
105
  export type YAxisConfig = {
102
106
  tickFormat?: string | ((value: number) => string) | null;
package/x-axis.d.ts CHANGED
@@ -9,7 +9,11 @@ export declare class XAxis implements LayoutAwareComponent {
9
9
  private readonly fontSize;
10
10
  private readonly maxLabelWidth?;
11
11
  private readonly oversizedBehavior;
12
+ private readonly tickFormat;
12
13
  private wrapLineCount;
14
+ private readonly autoHideOverlapping;
15
+ private readonly minLabelGap;
16
+ private readonly preserveEndLabels;
13
17
  constructor(config?: XAxisConfig);
14
18
  /**
15
19
  * Returns the space required by the x-axis
@@ -19,4 +23,5 @@ export declare class XAxis implements LayoutAwareComponent {
19
23
  private applyLabelConstraints;
20
24
  private wrapTextElement;
21
25
  private addTitleTooltip;
26
+ private applyAutoHiding;
22
27
  }
package/x-axis.js CHANGED
@@ -44,16 +44,45 @@ export class XAxis {
44
44
  writable: true,
45
45
  value: void 0
46
46
  });
47
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
48
+ Object.defineProperty(this, "tickFormat", {
49
+ enumerable: true,
50
+ configurable: true,
51
+ writable: true,
52
+ value: void 0
53
+ });
47
54
  Object.defineProperty(this, "wrapLineCount", {
48
55
  enumerable: true,
49
56
  configurable: true,
50
57
  writable: true,
51
58
  value: 1
52
59
  });
60
+ Object.defineProperty(this, "autoHideOverlapping", {
61
+ enumerable: true,
62
+ configurable: true,
63
+ writable: true,
64
+ value: void 0
65
+ });
66
+ Object.defineProperty(this, "minLabelGap", {
67
+ enumerable: true,
68
+ configurable: true,
69
+ writable: true,
70
+ value: void 0
71
+ });
72
+ Object.defineProperty(this, "preserveEndLabels", {
73
+ enumerable: true,
74
+ configurable: true,
75
+ writable: true,
76
+ value: void 0
77
+ });
53
78
  this.dataKey = config?.dataKey;
54
79
  this.rotatedLabels = config?.rotatedLabels ?? false;
55
80
  this.maxLabelWidth = config?.maxLabelWidth;
56
81
  this.oversizedBehavior = config?.oversizedBehavior ?? 'truncate';
82
+ this.tickFormat = config?.tickFormat ?? null;
83
+ this.autoHideOverlapping = config?.autoHideOverlapping ?? false;
84
+ this.minLabelGap = config?.minLabelGap ?? 8;
85
+ this.preserveEndLabels = config?.preserveEndLabels ?? true;
57
86
  }
58
87
  /**
59
88
  * Returns the space required by the x-axis
@@ -64,7 +93,9 @@ export class XAxis {
64
93
  const baseHeight = this.tickPadding + this.fontSize + 5;
65
94
  let height = this.rotatedLabels ? baseHeight * 2.5 : baseHeight;
66
95
  // Account for wrapped text height (multiply by estimated line count)
67
- if (this.maxLabelWidth && this.oversizedBehavior === 'wrap' && this.wrapLineCount > 1) {
96
+ if (this.maxLabelWidth &&
97
+ this.oversizedBehavior === 'wrap' &&
98
+ this.wrapLineCount > 1) {
68
99
  height += (this.wrapLineCount - 1) * this.fontSize * 1.2;
69
100
  }
70
101
  return {
@@ -74,14 +105,24 @@ export class XAxis {
74
105
  };
75
106
  }
76
107
  render(svg, x, theme, yPosition) {
108
+ const axisGenerator = axisBottom(x)
109
+ .tickSizeOuter(0)
110
+ .tickSize(0)
111
+ .tickPadding(this.tickPadding);
112
+ // Apply tick formatting if specified
113
+ if (this.tickFormat) {
114
+ if (typeof this.tickFormat === 'function') {
115
+ axisGenerator.tickFormat(this.tickFormat);
116
+ }
117
+ else {
118
+ axisGenerator.ticks(5, this.tickFormat);
119
+ }
120
+ }
77
121
  const axis = svg
78
122
  .append('g')
79
123
  .attr('class', 'x-axis')
80
124
  .attr('transform', `translate(0,${yPosition})`)
81
- .call(axisBottom(x)
82
- .tickSizeOuter(0)
83
- .tickSize(0)
84
- .tickPadding(this.tickPadding))
125
+ .call(axisGenerator)
85
126
  .attr('font-size', theme.axis.fontSize)
86
127
  .attr('font-family', theme.axis.fontFamily)
87
128
  .attr('font-weight', theme.axis.fontWeight || 'normal')
@@ -96,6 +137,8 @@ export class XAxis {
96
137
  .style('text-anchor', 'end')
97
138
  .attr('transform', 'rotate(-45)');
98
139
  }
140
+ // Apply auto-hiding for overlapping labels
141
+ this.applyAutoHiding(axis, x);
99
142
  axis.selectAll('.domain').remove();
100
143
  }
101
144
  applyLabelConstraints(axisGroup, svg, fontSize, fontFamily, fontWeight) {
@@ -103,7 +146,9 @@ export class XAxis {
103
146
  return;
104
147
  const maxWidth = this.maxLabelWidth;
105
148
  const behavior = this.oversizedBehavior;
106
- axisGroup.selectAll('text').each((_d, i, nodes) => {
149
+ axisGroup
150
+ .selectAll('text')
151
+ .each((_d, i, nodes) => {
107
152
  const textEl = nodes[i];
108
153
  const originalText = textEl.textContent || '';
109
154
  const textWidth = measureTextWidth(originalText, fontSize, fontFamily, fontWeight, svg);
@@ -154,4 +199,62 @@ export class XAxis {
154
199
  title.textContent = text;
155
200
  textEl.insertBefore(title, textEl.firstChild);
156
201
  }
202
+ applyAutoHiding(axisGroup, scale) {
203
+ if (!this.autoHideOverlapping)
204
+ return;
205
+ const textElements = axisGroup
206
+ .selectAll('text')
207
+ .nodes();
208
+ const labelCount = textElements.length;
209
+ if (labelCount <= 1)
210
+ return;
211
+ // Measure all label widths
212
+ let maxLabelWidth = 0;
213
+ for (const textEl of textElements) {
214
+ const bbox = textEl.getBBox();
215
+ // For rotated labels, use the horizontal footprint
216
+ const effectiveWidth = this.rotatedLabels
217
+ ? bbox.width * Math.cos(Math.PI / 4)
218
+ : bbox.width;
219
+ maxLabelWidth = Math.max(maxLabelWidth, effectiveWidth);
220
+ }
221
+ // Calculate available space per label
222
+ const bandwidth = typeof scale.bandwidth === 'function' ? scale.bandwidth() : 0;
223
+ const availableSpace = bandwidth > 0
224
+ ? bandwidth
225
+ : (scale.range()[1] - scale.range()[0]) / labelCount;
226
+ // Calculate skip interval
227
+ const requiredSpace = maxLabelWidth + this.minLabelGap;
228
+ const skipInterval = Math.ceil(requiredSpace / availableSpace);
229
+ // If no skipping needed, show all labels
230
+ if (skipInterval <= 1)
231
+ return;
232
+ // Apply visibility
233
+ textElements.forEach((textEl, index) => {
234
+ const isFirst = index === 0;
235
+ const isLast = index === labelCount - 1;
236
+ const isAtInterval = index % skipInterval === 0;
237
+ if (this.preserveEndLabels && (isFirst || isLast)) {
238
+ textEl.style.visibility = 'visible';
239
+ }
240
+ else if (isAtInterval) {
241
+ textEl.style.visibility = 'visible';
242
+ }
243
+ else {
244
+ textEl.style.visibility = 'hidden';
245
+ }
246
+ });
247
+ // Handle edge case: if last label is preserved but would overlap with the last visible interval label
248
+ if (this.preserveEndLabels && labelCount > 1) {
249
+ const lastVisibleIntervalIndex = Math.floor((labelCount - 2) / skipInterval) * skipInterval;
250
+ if (lastVisibleIntervalIndex !== labelCount - 1 &&
251
+ labelCount - 1 - lastVisibleIntervalIndex < skipInterval) {
252
+ // Hide the last interval label to avoid overlap with preserved last label
253
+ const textEl = textElements[lastVisibleIntervalIndex];
254
+ if (textEl && lastVisibleIntervalIndex !== 0) {
255
+ textEl.style.visibility = 'hidden';
256
+ }
257
+ }
258
+ }
259
+ }
157
260
  }
package/xy-chart.d.ts CHANGED
@@ -6,8 +6,6 @@ export type XYChartConfig = BaseChartConfig & {
6
6
  };
7
7
  export declare class XYChart extends BaseChart {
8
8
  private readonly series;
9
- private sortedDataCache;
10
- private xKeyCache;
11
9
  private barStackMode;
12
10
  private barStackGap;
13
11
  constructor(config: XYChartConfig);
@@ -15,7 +13,6 @@ export declare class XYChart extends BaseChart {
15
13
  private rerender;
16
14
  protected renderChart(): void;
17
15
  private getXKey;
18
- private getSortedData;
19
16
  private setupScales;
20
17
  private isHorizontalOrientation;
21
18
  private createScale;
package/xy-chart.js CHANGED
@@ -11,18 +11,6 @@ export class XYChart extends BaseChart {
11
11
  writable: true,
12
12
  value: []
13
13
  });
14
- Object.defineProperty(this, "sortedDataCache", {
15
- enumerable: true,
16
- configurable: true,
17
- writable: true,
18
- value: null
19
- });
20
- Object.defineProperty(this, "xKeyCache", {
21
- enumerable: true,
22
- configurable: true,
23
- writable: true,
24
- value: null
25
- });
26
14
  Object.defineProperty(this, "barStackMode", {
27
15
  enumerable: true,
28
16
  configurable: true,
@@ -97,9 +85,7 @@ export class XYChart extends BaseChart {
97
85
  if (this.xAxis?.dataKey) {
98
86
  ChartValidator.validateDataKey(this.data, this.xAxis.dataKey, 'XAxis');
99
87
  }
100
- // Cache sorted data
101
88
  const xKey = this.getXKey();
102
- const sortedData = this.getSortedData(xKey);
103
89
  this.setupScales();
104
90
  // Render title if present
105
91
  if (this.title) {
@@ -124,7 +110,7 @@ export class XYChart extends BaseChart {
124
110
  // Render tooltip
125
111
  if (this.tooltip && this.x && this.y) {
126
112
  this.tooltip.initialize(this.theme);
127
- this.tooltip.attachToArea(this.svg, sortedData, this.series, xKey, this.x, this.y, this.theme, this.plotArea, this.parseValue.bind(this), this.isHorizontalOrientation());
113
+ this.tooltip.attachToArea(this.svg, this.data, this.series, xKey, this.x, this.y, this.theme, this.plotArea, this.parseValue.bind(this), this.isHorizontalOrientation());
128
114
  }
129
115
  // Render legend if present
130
116
  if (this.legend) {
@@ -138,16 +124,6 @@ export class XYChart extends BaseChart {
138
124
  }
139
125
  return (Object.keys(this.data[0]).find((key) => !this.series.some((s) => s.dataKey === key)) || 'column');
140
126
  }
141
- getSortedData(xKey) {
142
- // Return cached data if xKey matches
143
- if (this.sortedDataCache && this.xKeyCache === xKey) {
144
- return this.sortedDataCache;
145
- }
146
- // Sort and cache
147
- this.xKeyCache = xKey;
148
- this.sortedDataCache = [...this.data].sort((a, b) => String(a[xKey]).localeCompare(String(b[xKey])));
149
- return this.sortedDataCache;
150
- }
151
127
  setupScales() {
152
128
  const xKey = this.getXKey();
153
129
  const isHorizontal = this.isHorizontalOrientation();
@@ -285,7 +261,6 @@ export class XYChart extends BaseChart {
285
261
  if (!this.plotGroup || !this.x || !this.y)
286
262
  return;
287
263
  const xKey = this.getXKey();
288
- const sortedData = this.getSortedData(xKey);
289
264
  const isHorizontal = this.isHorizontalOrientation();
290
265
  // For horizontal bars, the category scale is on Y (user's X config becomes Y)
291
266
  // For vertical bars, the category scale is on X (user's X config stays X)
@@ -299,23 +274,24 @@ export class XYChart extends BaseChart {
299
274
  // Get only bar series for stacking calculations
300
275
  const barSeries = visibleSeries.filter((s) => s.type === 'bar');
301
276
  // Compute stacking data for bar charts
302
- const { cumulativeDataBySeriesIndex, totalData } = this.computeStackingData(sortedData, xKey, barSeries);
303
- visibleSeries.forEach((series) => {
304
- if (series.type === 'bar') {
305
- const barIndex = barSeries.indexOf(series);
306
- const stackingContext = {
307
- mode: this.barStackMode,
308
- seriesIndex: barIndex,
309
- totalSeries: barSeries.length,
310
- cumulativeData: cumulativeDataBySeriesIndex.get(barIndex) ?? new Map(),
311
- totalData,
312
- gap: this.barStackGap,
313
- };
314
- series.render(this.plotGroup, sortedData, xKey, this.x, this.y, this.parseValue, categoryScaleType, this.theme, stackingContext);
315
- }
316
- else {
317
- series.render(this.plotGroup, sortedData, xKey, this.x, this.y, this.parseValue, categoryScaleType, this.theme);
318
- }
277
+ const { cumulativeDataBySeriesIndex, totalData } = this.computeStackingData(this.data, xKey, barSeries);
278
+ // Render bars first, then lines, so lines always appear on top
279
+ const lineSeries = visibleSeries.filter((s) => s.type !== 'bar');
280
+ // Render all bar series first
281
+ barSeries.forEach((series, barIndex) => {
282
+ const stackingContext = {
283
+ mode: this.barStackMode,
284
+ seriesIndex: barIndex,
285
+ totalSeries: barSeries.length,
286
+ cumulativeData: cumulativeDataBySeriesIndex.get(barIndex) ?? new Map(),
287
+ totalData,
288
+ gap: this.barStackGap,
289
+ };
290
+ series.render(this.plotGroup, this.data, xKey, this.x, this.y, this.parseValue, categoryScaleType, this.theme, stackingContext);
291
+ });
292
+ // Render all line series on top
293
+ lineSeries.forEach((series) => {
294
+ series.render(this.plotGroup, this.data, xKey, this.x, this.y, this.parseValue, categoryScaleType, this.theme);
319
295
  });
320
296
  }
321
297
  computeStackingData(data, xKey, barSeries) {
package/y-axis.js CHANGED
@@ -100,7 +100,9 @@ export class YAxis {
100
100
  applyLabelConstraints(axisGroup, svg, fontSize, fontFamily, fontWeight) {
101
101
  const maxWidth = this.maxLabelWidth;
102
102
  const behavior = this.oversizedBehavior;
103
- axisGroup.selectAll('text').each((_d, i, nodes) => {
103
+ axisGroup
104
+ .selectAll('text')
105
+ .each((_d, i, nodes) => {
104
106
  const textEl = nodes[i];
105
107
  const originalText = textEl.textContent || '';
106
108
  const textWidth = measureTextWidth(originalText, fontSize, fontFamily, fontWeight, svg);