@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 +142 -30
- package/base-chart.js +2 -6
- package/line.js +14 -5
- package/package.json +1 -1
- package/tooltip.js +58 -19
- package/types.d.ts +4 -0
- package/x-axis.d.ts +5 -0
- package/x-axis.js +109 -6
- package/xy-chart.d.ts +0 -3
- package/xy-chart.js +19 -43
- package/y-axis.js +3 -1
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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 -
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
|
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(
|
|
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,
|
|
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
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
|
|
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:
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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 +
|
|
220
|
+
svgRect.top +
|
|
221
|
+
window.scrollY +
|
|
222
|
+
dataPointPosition -
|
|
223
|
+
tooltipHeight / 2;
|
|
213
224
|
}
|
|
214
225
|
else {
|
|
215
|
-
// Vertical:
|
|
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 +
|
|
231
|
+
svgRect.left +
|
|
232
|
+
window.scrollX +
|
|
233
|
+
dataPointPosition +
|
|
234
|
+
offsetX;
|
|
218
235
|
tooltipY =
|
|
219
|
-
svgRect.top +
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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 &&
|
|
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(
|
|
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
|
|
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,
|
|
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(
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
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);
|