@opendata-ai/openchart-engine 2.9.1 → 2.11.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/index.js +317 -62
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +4 -0
- package/src/__tests__/axes.test.ts +183 -2
- package/src/__tests__/legend.test.ts +4 -0
- package/src/annotations/__tests__/compute.test.ts +173 -4
- package/src/annotations/compute.ts +158 -41
- package/src/charts/column/__tests__/labels.test.ts +104 -0
- package/src/charts/dot/__tests__/labels.test.ts +98 -0
- package/src/charts/pie/__tests__/labels.test.ts +132 -0
- package/src/compile.ts +63 -13
- package/src/layout/axes.ts +131 -11
- package/src/layout/dimensions.ts +77 -4
- package/src/legend/compute.ts +105 -7
|
@@ -24,7 +24,7 @@ import type {
|
|
|
24
24
|
TextAnnotation,
|
|
25
25
|
TextStyle,
|
|
26
26
|
} from '@opendata-ai/openchart-core';
|
|
27
|
-
import { estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
27
|
+
import { detectCollision, estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
28
28
|
import type { ScaleBand, ScaleLinear, ScaleTime } from 'd3-scale';
|
|
29
29
|
import type { NormalizedChartSpec } from '../compiler/types';
|
|
30
30
|
import type { ResolvedScales } from '../layout/scales';
|
|
@@ -549,14 +549,43 @@ function estimateLabelBounds(label: ResolvedLabel): Rect {
|
|
|
549
549
|
return computeTextBounds(label.x, label.y, label.text, fontSize, fontWeight);
|
|
550
550
|
}
|
|
551
551
|
|
|
552
|
-
/** Check if two rects overlap. */
|
|
553
|
-
function rectsOverlap(a: Rect, b: Rect): boolean {
|
|
554
|
-
return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
552
|
/** Padding between annotation and obstacle when nudging. */
|
|
558
553
|
const NUDGE_PADDING = 6;
|
|
559
554
|
|
|
555
|
+
/**
|
|
556
|
+
* Generate candidate displacement vectors to move `selfBounds` clear of each
|
|
557
|
+
* obstacle in 4 directions (below, above, left, right), sorted by smallest
|
|
558
|
+
* movement first.
|
|
559
|
+
*/
|
|
560
|
+
function generateNudgeCandidates(
|
|
561
|
+
selfBounds: Rect,
|
|
562
|
+
obstacles: Rect[],
|
|
563
|
+
padding: number,
|
|
564
|
+
): { dx: number; dy: number; distance: number }[] {
|
|
565
|
+
const candidates: { dx: number; dy: number; distance: number }[] = [];
|
|
566
|
+
|
|
567
|
+
for (const obs of obstacles) {
|
|
568
|
+
// Below: shift self so its top edge clears the obstacle bottom
|
|
569
|
+
const belowDy = obs.y + obs.height + padding - selfBounds.y;
|
|
570
|
+
candidates.push({ dx: 0, dy: belowDy, distance: Math.abs(belowDy) });
|
|
571
|
+
|
|
572
|
+
// Above: shift self so its bottom edge clears the obstacle top
|
|
573
|
+
const aboveDy = obs.y - padding - (selfBounds.y + selfBounds.height);
|
|
574
|
+
candidates.push({ dx: 0, dy: aboveDy, distance: Math.abs(aboveDy) });
|
|
575
|
+
|
|
576
|
+
// Left: shift self so its right edge clears the obstacle left
|
|
577
|
+
const leftDx = obs.x - padding - (selfBounds.x + selfBounds.width);
|
|
578
|
+
candidates.push({ dx: leftDx, dy: 0, distance: Math.abs(leftDx) });
|
|
579
|
+
|
|
580
|
+
// Right: shift self so its left edge clears the obstacle right
|
|
581
|
+
const rightDx = obs.x + obs.width + padding - selfBounds.x;
|
|
582
|
+
candidates.push({ dx: rightDx, dy: 0, distance: Math.abs(rightDx) });
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
candidates.sort((a, b) => a.distance - b.distance);
|
|
586
|
+
return candidates;
|
|
587
|
+
}
|
|
588
|
+
|
|
560
589
|
/**
|
|
561
590
|
* Try to reposition a text annotation to avoid overlapping with obstacle rects
|
|
562
591
|
* (legend bounds, etc.). First tries standard anchor alternatives, then
|
|
@@ -573,7 +602,7 @@ function nudgeAnnotationFromObstacles(
|
|
|
573
602
|
|
|
574
603
|
const labelBounds = estimateLabelBounds(annotation.label);
|
|
575
604
|
const collidingObs = obstacles.filter(
|
|
576
|
-
(obs) => obs.width > 0 && obs.height > 0 &&
|
|
605
|
+
(obs) => obs.width > 0 && obs.height > 0 && detectCollision(labelBounds, obs),
|
|
577
606
|
);
|
|
578
607
|
|
|
579
608
|
if (collidingObs.length === 0) return false;
|
|
@@ -583,39 +612,9 @@ function nudgeAnnotationFromObstacles(
|
|
|
583
612
|
const py = resolvePosition(originalAnnotation.y, scales.y);
|
|
584
613
|
if (px === null || py === null) return false;
|
|
585
614
|
|
|
586
|
-
|
|
587
|
-
const candidates: { dx: number; dy: number; distance: number }[] = [];
|
|
615
|
+
const candidates = generateNudgeCandidates(labelBounds, collidingObs, NUDGE_PADDING);
|
|
588
616
|
const fontSize = labelBounds.height / Math.max(1, annotation.label.text.split('\n').length);
|
|
589
617
|
|
|
590
|
-
for (const obs of collidingObs) {
|
|
591
|
-
// Below obstacle: shift label so its top edge clears the obstacle bottom
|
|
592
|
-
const currentLabelTop = labelBounds.y;
|
|
593
|
-
const targetLabelTop = obs.y + obs.height + NUDGE_PADDING;
|
|
594
|
-
const belowDy = targetLabelTop - currentLabelTop;
|
|
595
|
-
candidates.push({ dx: 0, dy: belowDy, distance: Math.abs(belowDy) });
|
|
596
|
-
|
|
597
|
-
// Above obstacle: shift label so its bottom edge clears the obstacle top
|
|
598
|
-
const currentLabelBottom = labelBounds.y + labelBounds.height;
|
|
599
|
-
const targetLabelBottom = obs.y - NUDGE_PADDING;
|
|
600
|
-
const aboveDy = targetLabelBottom - currentLabelBottom;
|
|
601
|
-
candidates.push({ dx: 0, dy: aboveDy, distance: Math.abs(aboveDy) });
|
|
602
|
-
|
|
603
|
-
// Left of obstacle: shift label so its right edge clears the obstacle left
|
|
604
|
-
const currentLabelRight = labelBounds.x + labelBounds.width;
|
|
605
|
-
const targetLabelRight = obs.x - NUDGE_PADDING;
|
|
606
|
-
const leftDx = targetLabelRight - currentLabelRight;
|
|
607
|
-
candidates.push({ dx: leftDx, dy: 0, distance: Math.abs(leftDx) });
|
|
608
|
-
|
|
609
|
-
// Right of obstacle: shift label so its left edge clears the obstacle right
|
|
610
|
-
const currentLabelLeft = labelBounds.x;
|
|
611
|
-
const targetLabelLeft = obs.x + obs.width + NUDGE_PADDING;
|
|
612
|
-
const rightDx = targetLabelLeft - currentLabelLeft;
|
|
613
|
-
candidates.push({ dx: rightDx, dy: 0, distance: Math.abs(rightDx) });
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
// Sort candidates by distance (prefer smallest movement)
|
|
617
|
-
candidates.sort((a, b) => a.distance - b.distance);
|
|
618
|
-
|
|
619
618
|
for (const { dx, dy } of candidates) {
|
|
620
619
|
const newLabelX = annotation.label.x + dx;
|
|
621
620
|
const newLabelY = annotation.label.y + dy;
|
|
@@ -651,7 +650,7 @@ function nudgeAnnotationFromObstacles(
|
|
|
651
650
|
|
|
652
651
|
// Check no collisions with any obstacle
|
|
653
652
|
const stillCollides = obstacles.some(
|
|
654
|
-
(obs) => obs.width > 0 && obs.height > 0 &&
|
|
653
|
+
(obs) => obs.width > 0 && obs.height > 0 && detectCollision(candidateBounds, obs),
|
|
655
654
|
);
|
|
656
655
|
if (stillCollides) continue;
|
|
657
656
|
|
|
@@ -660,11 +659,14 @@ function nudgeAnnotationFromObstacles(
|
|
|
660
659
|
// the text doesn't go completely off-screen.
|
|
661
660
|
const labelCenterX = candidateBounds.x + candidateBounds.width / 2;
|
|
662
661
|
const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
|
|
662
|
+
// Allow nudged labels to extend into the chrome region below the chart
|
|
663
|
+
// (source/footer area) since annotations near the bottom edge often
|
|
664
|
+
// need to shift into that space to avoid marks or the brand watermark.
|
|
663
665
|
const inBounds =
|
|
664
666
|
labelCenterX >= chartArea.x &&
|
|
665
667
|
labelCenterX <= chartArea.x + chartArea.width + 100 &&
|
|
666
668
|
labelCenterY >= chartArea.y - fontSize &&
|
|
667
|
-
labelCenterY <= chartArea.y + chartArea.height + fontSize;
|
|
669
|
+
labelCenterY <= chartArea.y + chartArea.height + fontSize * 3;
|
|
668
670
|
|
|
669
671
|
if (inBounds) {
|
|
670
672
|
// When nudged vertically (directly above/below the data), use a caret
|
|
@@ -683,6 +685,117 @@ function nudgeAnnotationFromObstacles(
|
|
|
683
685
|
return false;
|
|
684
686
|
}
|
|
685
687
|
|
|
688
|
+
// ---------------------------------------------------------------------------
|
|
689
|
+
// Annotation-to-annotation collision resolution
|
|
690
|
+
// ---------------------------------------------------------------------------
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Resolve collisions between text annotation labels using a greedy algorithm.
|
|
694
|
+
*
|
|
695
|
+
* Iterates through text annotations in order, building a list of "placed"
|
|
696
|
+
* bounding rects. When a later annotation overlaps an already-placed one,
|
|
697
|
+
* it tries offset positions (below, above, left, right) to find a
|
|
698
|
+
* non-colliding spot. Recomputes the connector origin after nudging.
|
|
699
|
+
*/
|
|
700
|
+
function resolveAnnotationCollisions(
|
|
701
|
+
annotations: ResolvedAnnotation[],
|
|
702
|
+
originalSpecs: NormalizedChartSpec['annotations'],
|
|
703
|
+
scales: ResolvedScales,
|
|
704
|
+
chartArea: Rect,
|
|
705
|
+
): void {
|
|
706
|
+
const placedBounds: Rect[] = [];
|
|
707
|
+
|
|
708
|
+
for (let i = 0; i < annotations.length; i++) {
|
|
709
|
+
const annotation = annotations[i];
|
|
710
|
+
if (annotation.type !== 'text' || !annotation.label) {
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const bounds = estimateLabelBounds(annotation.label);
|
|
715
|
+
|
|
716
|
+
// Check against all previously placed annotation labels
|
|
717
|
+
const collidingBounds = placedBounds.filter(
|
|
718
|
+
(pb) => pb.width > 0 && pb.height > 0 && detectCollision(bounds, pb),
|
|
719
|
+
);
|
|
720
|
+
|
|
721
|
+
if (collidingBounds.length > 0) {
|
|
722
|
+
// Find the original spec to get data point coordinates for connector recomputation
|
|
723
|
+
const originalSpec = originalSpecs[i];
|
|
724
|
+
|
|
725
|
+
if (originalSpec?.type === 'text') {
|
|
726
|
+
const px = resolvePosition(originalSpec.x, scales.x);
|
|
727
|
+
const py = resolvePosition(originalSpec.y, scales.y);
|
|
728
|
+
|
|
729
|
+
if (px !== null && py !== null) {
|
|
730
|
+
const candidates = generateNudgeCandidates(bounds, collidingBounds, NUDGE_PADDING);
|
|
731
|
+
const fontSize = bounds.height / Math.max(1, annotation.label.text.split('\n').length);
|
|
732
|
+
|
|
733
|
+
for (const { dx, dy } of candidates) {
|
|
734
|
+
const newLabelX = annotation.label.x + dx;
|
|
735
|
+
const newLabelY = annotation.label.y + dy;
|
|
736
|
+
|
|
737
|
+
const candidateLabel: ResolvedLabel = {
|
|
738
|
+
...annotation.label,
|
|
739
|
+
x: newLabelX,
|
|
740
|
+
y: newLabelY,
|
|
741
|
+
};
|
|
742
|
+
const candidateBounds = estimateLabelBounds(candidateLabel);
|
|
743
|
+
|
|
744
|
+
// Check no collisions with any placed label
|
|
745
|
+
const stillCollides = placedBounds.some(
|
|
746
|
+
(pb) => pb.width > 0 && pb.height > 0 && detectCollision(candidateBounds, pb),
|
|
747
|
+
);
|
|
748
|
+
if (stillCollides) continue;
|
|
749
|
+
|
|
750
|
+
// Check the label center stays reasonably in bounds
|
|
751
|
+
const labelCenterX = candidateBounds.x + candidateBounds.width / 2;
|
|
752
|
+
const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
|
|
753
|
+
const inBounds =
|
|
754
|
+
labelCenterX >= chartArea.x &&
|
|
755
|
+
labelCenterX <= chartArea.x + chartArea.width + 100 &&
|
|
756
|
+
labelCenterY >= chartArea.y - fontSize &&
|
|
757
|
+
labelCenterY <= chartArea.y + chartArea.height + fontSize;
|
|
758
|
+
|
|
759
|
+
if (inBounds) {
|
|
760
|
+
// Recompute connector origin for the new position
|
|
761
|
+
let newConnector = annotation.label.connector;
|
|
762
|
+
if (newConnector) {
|
|
763
|
+
const annFontSize = annotation.label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
|
|
764
|
+
const annFontWeight =
|
|
765
|
+
annotation.label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
|
|
766
|
+
const connStyle =
|
|
767
|
+
newConnector.style === 'curve' ? ('curve' as const) : ('straight' as const);
|
|
768
|
+
const newFrom = computeConnectorOrigin(
|
|
769
|
+
newLabelX,
|
|
770
|
+
newLabelY,
|
|
771
|
+
annotation.label.text,
|
|
772
|
+
annFontSize,
|
|
773
|
+
annFontWeight,
|
|
774
|
+
px,
|
|
775
|
+
py,
|
|
776
|
+
connStyle,
|
|
777
|
+
);
|
|
778
|
+
newConnector = { ...newConnector, from: newFrom };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
annotation.label = {
|
|
782
|
+
...annotation.label,
|
|
783
|
+
x: newLabelX,
|
|
784
|
+
y: newLabelY,
|
|
785
|
+
connector: newConnector,
|
|
786
|
+
};
|
|
787
|
+
break;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Add this annotation's final bounds to the placed list
|
|
795
|
+
placedBounds.push(estimateLabelBounds(annotation.label));
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
686
799
|
// ---------------------------------------------------------------------------
|
|
687
800
|
// Public API
|
|
688
801
|
// ---------------------------------------------------------------------------
|
|
@@ -696,7 +809,8 @@ function nudgeAnnotationFromObstacles(
|
|
|
696
809
|
*
|
|
697
810
|
* When obstacle rects are provided (e.g. legend bounds), text annotations
|
|
698
811
|
* that overlap with them are automatically repositioned using alternate
|
|
699
|
-
* anchor directions.
|
|
812
|
+
* anchor directions. After individual obstacle avoidance, annotation-to-
|
|
813
|
+
* annotation collisions are resolved using a greedy placement algorithm.
|
|
700
814
|
*/
|
|
701
815
|
export function computeAnnotations(
|
|
702
816
|
spec: NormalizedChartSpec,
|
|
@@ -737,6 +851,9 @@ export function computeAnnotations(
|
|
|
737
851
|
}
|
|
738
852
|
}
|
|
739
853
|
|
|
854
|
+
// Resolve annotation-to-annotation collisions (greedy, order-preserving)
|
|
855
|
+
resolveAnnotationCollisions(annotations, spec.annotations, scales, chartArea);
|
|
856
|
+
|
|
740
857
|
// Sort by zIndex (lower first, undefined treated as 0)
|
|
741
858
|
annotations.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0));
|
|
742
859
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { RectMark } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { computeColumnLabels } from '../labels';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Fixtures
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
const chartArea = { x: 0, y: 0, width: 400, height: 300 };
|
|
10
|
+
|
|
11
|
+
function makeMark(index: number, value: number): RectMark {
|
|
12
|
+
const height = Math.abs(value) * 5;
|
|
13
|
+
const y = value >= 0 ? 300 - height : 300;
|
|
14
|
+
return {
|
|
15
|
+
type: 'rect',
|
|
16
|
+
x: index * 80,
|
|
17
|
+
y,
|
|
18
|
+
width: 60,
|
|
19
|
+
height,
|
|
20
|
+
fill: '#4e79a7',
|
|
21
|
+
data: { category: `Cat${index}`, value },
|
|
22
|
+
aria: { label: `Cat${index}: ${value}` },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const marks: RectMark[] = [
|
|
27
|
+
makeMark(0, 10),
|
|
28
|
+
makeMark(1, 20),
|
|
29
|
+
makeMark(2, 30),
|
|
30
|
+
makeMark(3, 40),
|
|
31
|
+
makeMark(4, 50),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Tests
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
describe('computeColumnLabels density modes', () => {
|
|
39
|
+
it('density "auto" runs collision detection and produces labels', () => {
|
|
40
|
+
const labels = computeColumnLabels(marks, chartArea, 'auto');
|
|
41
|
+
expect(labels.length).toBeGreaterThan(0);
|
|
42
|
+
expect(labels.every((l) => typeof l.visible === 'boolean')).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('density "all" shows every label as visible', () => {
|
|
46
|
+
const labels = computeColumnLabels(marks, chartArea, 'all');
|
|
47
|
+
expect(labels).toHaveLength(marks.length);
|
|
48
|
+
expect(labels.every((l) => l.visible === true)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('density "none" returns empty array', () => {
|
|
52
|
+
const labels = computeColumnLabels(marks, chartArea, 'none');
|
|
53
|
+
expect(labels).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('density "endpoints" returns only first and last labels', () => {
|
|
57
|
+
const labels = computeColumnLabels(marks, chartArea, 'endpoints');
|
|
58
|
+
expect(labels).toHaveLength(2);
|
|
59
|
+
expect(labels[0].text).toBe('10');
|
|
60
|
+
expect(labels[1].text).toBe('50');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('density "endpoints" with single mark returns that mark', () => {
|
|
64
|
+
const labels = computeColumnLabels([marks[0]], chartArea, 'endpoints');
|
|
65
|
+
expect(labels).toHaveLength(1);
|
|
66
|
+
expect(labels[0].text).toBe('10');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('default density is "auto"', () => {
|
|
70
|
+
const withAuto = computeColumnLabels(marks, chartArea, 'auto');
|
|
71
|
+
const withDefault = computeColumnLabels(marks, chartArea);
|
|
72
|
+
expect(withDefault.length).toBe(withAuto.length);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('computeColumnLabels positioning', () => {
|
|
77
|
+
it('places positive value labels above the column', () => {
|
|
78
|
+
const labels = computeColumnLabels([makeMark(0, 20)], chartArea, 'all');
|
|
79
|
+
expect(labels).toHaveLength(1);
|
|
80
|
+
const mark = makeMark(0, 20);
|
|
81
|
+
// Label y should be above the column top
|
|
82
|
+
expect(labels[0].y).toBeLessThan(mark.y);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('places negative value labels below the column', () => {
|
|
86
|
+
const negativeMark = makeMark(0, -15);
|
|
87
|
+
const labels = computeColumnLabels([negativeMark], chartArea, 'all');
|
|
88
|
+
expect(labels).toHaveLength(1);
|
|
89
|
+
// Label y should be below the column bottom
|
|
90
|
+
expect(labels[0].y).toBeGreaterThan(negativeMark.y + negativeMark.height);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('centers labels horizontally on the column', () => {
|
|
94
|
+
const labels = computeColumnLabels([makeMark(0, 20)], chartArea, 'all');
|
|
95
|
+
const mark = makeMark(0, 20);
|
|
96
|
+
const markCenter = mark.x + mark.width / 2;
|
|
97
|
+
expect(labels[0].x).toBe(markCenter);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('applies labelFormat to numeric values', () => {
|
|
101
|
+
const labels = computeColumnLabels([makeMark(0, 1234)], chartArea, 'all', ',.0f');
|
|
102
|
+
expect(labels[0].text).toBe('1,234');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { PointMark } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { computeDotLabels } from '../labels';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Fixtures
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
const chartArea = { x: 0, y: 0, width: 400, height: 300 };
|
|
10
|
+
|
|
11
|
+
function makeMark(index: number, value: number): PointMark {
|
|
12
|
+
return {
|
|
13
|
+
type: 'point',
|
|
14
|
+
cx: value * 5,
|
|
15
|
+
cy: index * 40 + 20,
|
|
16
|
+
r: 6,
|
|
17
|
+
fill: '#4e79a7',
|
|
18
|
+
stroke: '#4e79a7',
|
|
19
|
+
strokeWidth: 1,
|
|
20
|
+
data: { category: `Cat${index}`, value },
|
|
21
|
+
aria: { label: `Cat${index}: ${value}` },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const marks: PointMark[] = [
|
|
26
|
+
makeMark(0, 10),
|
|
27
|
+
makeMark(1, 20),
|
|
28
|
+
makeMark(2, 30),
|
|
29
|
+
makeMark(3, 40),
|
|
30
|
+
makeMark(4, 50),
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Tests
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
describe('computeDotLabels density modes', () => {
|
|
38
|
+
it('density "auto" runs collision detection and produces labels', () => {
|
|
39
|
+
const labels = computeDotLabels(marks, chartArea, 'auto');
|
|
40
|
+
expect(labels.length).toBeGreaterThan(0);
|
|
41
|
+
expect(labels.every((l) => typeof l.visible === 'boolean')).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('density "all" shows every label as visible', () => {
|
|
45
|
+
const labels = computeDotLabels(marks, chartArea, 'all');
|
|
46
|
+
expect(labels).toHaveLength(marks.length);
|
|
47
|
+
expect(labels.every((l) => l.visible === true)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('density "none" returns empty array', () => {
|
|
51
|
+
const labels = computeDotLabels(marks, chartArea, 'none');
|
|
52
|
+
expect(labels).toHaveLength(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('density "endpoints" returns only first and last labels', () => {
|
|
56
|
+
const labels = computeDotLabels(marks, chartArea, 'endpoints');
|
|
57
|
+
expect(labels).toHaveLength(2);
|
|
58
|
+
expect(labels[0].text).toBe('10');
|
|
59
|
+
expect(labels[1].text).toBe('50');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('density "endpoints" with single mark returns that mark', () => {
|
|
63
|
+
const labels = computeDotLabels([marks[0]], chartArea, 'endpoints');
|
|
64
|
+
expect(labels).toHaveLength(1);
|
|
65
|
+
expect(labels[0].text).toBe('10');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('default density is "auto"', () => {
|
|
69
|
+
const withAuto = computeDotLabels(marks, chartArea, 'auto');
|
|
70
|
+
const withDefault = computeDotLabels(marks, chartArea);
|
|
71
|
+
expect(withDefault.length).toBe(withAuto.length);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('computeDotLabels positioning', () => {
|
|
76
|
+
it('places labels to the right of the dot', () => {
|
|
77
|
+
const labels = computeDotLabels([marks[0]], chartArea, 'all');
|
|
78
|
+
expect(labels).toHaveLength(1);
|
|
79
|
+
// Label x should be to the right of the dot center + radius
|
|
80
|
+
expect(labels[0].x).toBeGreaterThan(marks[0].cx + marks[0].r);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('vertically centers labels on the dot', () => {
|
|
84
|
+
const labels = computeDotLabels([marks[0]], chartArea, 'all');
|
|
85
|
+
const textHeight = 11 * 1.2; // LABEL_FONT_SIZE * 1.2
|
|
86
|
+
// Label y should be roughly centered on the dot's cy
|
|
87
|
+
expect(labels[0].y).toBeCloseTo(marks[0].cy - textHeight / 2, 0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('returns empty for marks with no parseable value', () => {
|
|
91
|
+
const badMark: PointMark = {
|
|
92
|
+
...marks[0],
|
|
93
|
+
aria: { label: 'no-colon-here' },
|
|
94
|
+
};
|
|
95
|
+
const labels = computeDotLabels([badMark], chartArea, 'all');
|
|
96
|
+
expect(labels).toHaveLength(0);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { ArcMark } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { computePieLabels } from '../labels';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Fixtures
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
const chartArea = { x: 0, y: 0, width: 400, height: 400 };
|
|
10
|
+
const center = { x: 200, y: 200 };
|
|
11
|
+
const outerRadius = 150;
|
|
12
|
+
|
|
13
|
+
function makeArc(category: string, value: number, startAngle: number, endAngle: number): ArcMark {
|
|
14
|
+
const midAngle = (startAngle + endAngle) / 2;
|
|
15
|
+
const centroidRadius = outerRadius * 0.6;
|
|
16
|
+
return {
|
|
17
|
+
type: 'arc',
|
|
18
|
+
path: '', // SVG path not needed for label computation
|
|
19
|
+
centroid: {
|
|
20
|
+
x: center.x + Math.sin(midAngle) * centroidRadius,
|
|
21
|
+
y: center.y - Math.cos(midAngle) * centroidRadius,
|
|
22
|
+
},
|
|
23
|
+
center,
|
|
24
|
+
innerRadius: 0,
|
|
25
|
+
outerRadius,
|
|
26
|
+
startAngle,
|
|
27
|
+
endAngle,
|
|
28
|
+
fill: '#4e79a7',
|
|
29
|
+
stroke: '#ffffff',
|
|
30
|
+
strokeWidth: 2,
|
|
31
|
+
data: { category, value },
|
|
32
|
+
aria: { label: `${category}: ${value} (${Math.round(value)}%)` },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Three slices: top-right, bottom-right, left
|
|
37
|
+
const marks: ArcMark[] = [
|
|
38
|
+
makeArc('Alpha', 50, 0, Math.PI * 0.8),
|
|
39
|
+
makeArc('Beta', 30, Math.PI * 0.8, Math.PI * 1.4),
|
|
40
|
+
makeArc('Gamma', 20, Math.PI * 1.4, Math.PI * 2),
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Tests
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
describe('computePieLabels density modes', () => {
|
|
48
|
+
it('density "auto" runs collision detection and produces labels', () => {
|
|
49
|
+
const labels = computePieLabels(marks, chartArea, 'auto');
|
|
50
|
+
expect(labels.length).toBeGreaterThan(0);
|
|
51
|
+
expect(labels.every((l) => typeof l.visible === 'boolean')).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('density "all" shows every label as visible', () => {
|
|
55
|
+
const labels = computePieLabels(marks, chartArea, 'all');
|
|
56
|
+
expect(labels).toHaveLength(marks.length);
|
|
57
|
+
expect(labels.every((l) => l.visible === true)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('density "none" returns empty array', () => {
|
|
61
|
+
const labels = computePieLabels(marks, chartArea, 'none');
|
|
62
|
+
expect(labels).toHaveLength(0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('density "endpoints" returns only first and last labels', () => {
|
|
66
|
+
const labels = computePieLabels(marks, chartArea, 'endpoints');
|
|
67
|
+
expect(labels).toHaveLength(2);
|
|
68
|
+
expect(labels[0].text).toBe('Alpha');
|
|
69
|
+
expect(labels[1].text).toBe('Gamma');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('density "endpoints" with single mark returns that mark', () => {
|
|
73
|
+
const labels = computePieLabels([marks[0]], chartArea, 'endpoints');
|
|
74
|
+
expect(labels).toHaveLength(1);
|
|
75
|
+
expect(labels[0].text).toBe('Alpha');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('default density is "auto"', () => {
|
|
79
|
+
const withAuto = computePieLabels(marks, chartArea, 'auto');
|
|
80
|
+
const withDefault = computePieLabels(marks, chartArea);
|
|
81
|
+
expect(withDefault.length).toBe(withAuto.length);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('returns empty for empty marks array', () => {
|
|
85
|
+
const labels = computePieLabels([], chartArea, 'all');
|
|
86
|
+
expect(labels).toHaveLength(0);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('computePieLabels positioning', () => {
|
|
91
|
+
it('labels use category name (not value) as text', () => {
|
|
92
|
+
const labels = computePieLabels(marks, chartArea, 'all');
|
|
93
|
+
expect(labels[0].text).toBe('Alpha');
|
|
94
|
+
expect(labels[1].text).toBe('Beta');
|
|
95
|
+
expect(labels[2].text).toBe('Gamma');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('labels are positioned outside the outer radius', () => {
|
|
99
|
+
const labels = computePieLabels(marks, chartArea, 'all');
|
|
100
|
+
for (const label of labels) {
|
|
101
|
+
const dx = label.x - center.x;
|
|
102
|
+
const dy = label.y - center.y;
|
|
103
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
104
|
+
// Label should be at least at the outer radius distance from center
|
|
105
|
+
// (accounting for text width offset, the anchor point may vary)
|
|
106
|
+
expect(dist).toBeGreaterThan(outerRadius * 0.5);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('visible labels have connector lines to centroid', () => {
|
|
111
|
+
const labels = computePieLabels(marks, chartArea, 'all');
|
|
112
|
+
const visibleLabels = labels.filter((l) => l.visible);
|
|
113
|
+
for (const label of visibleLabels) {
|
|
114
|
+
expect(label.connector).toBeDefined();
|
|
115
|
+
expect(label.connector!.from).toEqual({ x: label.x, y: label.y });
|
|
116
|
+
expect(label.connector!.to).toBeDefined();
|
|
117
|
+
expect(label.connector!.stroke).toBeDefined();
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('right-side labels use "start" text anchor', () => {
|
|
122
|
+
// First mark (0 to 0.8*PI) has midAngle ~0.4*PI, sin > 0 => right side
|
|
123
|
+
const labels = computePieLabels([marks[0]], chartArea, 'all');
|
|
124
|
+
expect(labels[0].style.textAnchor).toBe('start');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('left-side labels use "end" text anchor', () => {
|
|
128
|
+
// Third mark (1.4*PI to 2*PI) has midAngle ~1.7*PI, sin(1.7*PI) < 0 => left side
|
|
129
|
+
const labels = computePieLabels([marks[2]], chartArea, 'all');
|
|
130
|
+
expect(labels[0].style.textAnchor).toBe('end');
|
|
131
|
+
});
|
|
132
|
+
});
|