@opendata-ai/openchart-engine 7.11.0 → 8.0.0-rc.10

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.
Files changed (160) hide show
  1. package/dist/index.d.ts +124 -14
  2. package/dist/index.js +16254 -9417
  3. package/dist/index.js.map +1 -1
  4. package/package.json +11 -4
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +232 -56
  6. package/src/__tests__/annotation-behavior.test.ts +852 -0
  7. package/src/__tests__/axes.test.ts +50 -3
  8. package/src/__tests__/compile-chart.test.ts +30 -0
  9. package/src/__tests__/compile-footnotes.test.ts +277 -0
  10. package/src/__tests__/compile-layer.test.ts +120 -1
  11. package/src/__tests__/facet.test.ts +144 -0
  12. package/src/__tests__/helpers/invariants.ts +54 -20
  13. package/src/__tests__/known-bugs.test.ts +455 -2
  14. package/src/__tests__/legend.test.ts +69 -4
  15. package/src/__tests__/ranking-recipes.test.ts +260 -0
  16. package/src/__tests__/scales.test.ts +9 -3
  17. package/src/__tests__/series-search.test.ts +92 -0
  18. package/src/__tests__/spec-sugar.test.ts +801 -0
  19. package/src/__tests__/you-draw-it.test.ts +160 -0
  20. package/src/annotations/__tests__/compute.test.ts +207 -92
  21. package/src/annotations/__tests__/geometry.test.ts +66 -0
  22. package/src/annotations/__tests__/resolve-text.test.ts +527 -27
  23. package/src/annotations/__tests__/rich-text.test.ts +91 -0
  24. package/src/annotations/__tests__/thinning.test.ts +148 -0
  25. package/src/annotations/collisions.ts +130 -84
  26. package/src/annotations/compute.ts +83 -85
  27. package/src/annotations/constants.ts +116 -6
  28. package/src/annotations/geometry.ts +228 -77
  29. package/src/annotations/move.ts +135 -0
  30. package/src/annotations/placement.ts +62 -14
  31. package/src/annotations/resolve-range.ts +2 -0
  32. package/src/annotations/resolve-refline.ts +2 -0
  33. package/src/annotations/resolve-text.ts +318 -111
  34. package/src/annotations/rich-text.ts +65 -0
  35. package/src/annotations/thinning.ts +84 -10
  36. package/src/barlist/compile-barlist.ts +16 -6
  37. package/src/charts/_shared/format-label-value.ts +9 -8
  38. package/src/charts/bar/__tests__/compute.test.ts +57 -10
  39. package/src/charts/bar/__tests__/labels.test.ts +5 -4
  40. package/src/charts/bar/compute.ts +2 -6
  41. package/src/charts/bar/index.ts +8 -1
  42. package/src/charts/bar/labels.ts +3 -3
  43. package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
  44. package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
  45. package/src/charts/beeswarm/compute.ts +203 -0
  46. package/src/charts/beeswarm/dodge.ts +155 -0
  47. package/src/charts/beeswarm/index.ts +32 -0
  48. package/src/charts/builtin.ts +12 -1
  49. package/src/charts/calendar/__tests__/compute.test.ts +417 -0
  50. package/src/charts/calendar/compute.ts +319 -0
  51. package/src/charts/calendar/index.ts +32 -0
  52. package/src/charts/column/__tests__/compute.test.ts +21 -7
  53. package/src/charts/column/__tests__/labels.test.ts +76 -1
  54. package/src/charts/column/compute.ts +2 -6
  55. package/src/charts/column/index.ts +10 -2
  56. package/src/charts/column/labels.ts +95 -21
  57. package/src/charts/dot/index.ts +8 -1
  58. package/src/charts/dot/labels.ts +3 -3
  59. package/src/charts/line/__tests__/compute.test.ts +15 -19
  60. package/src/charts/line/area.ts +8 -23
  61. package/src/charts/line/compute.ts +32 -2
  62. package/src/charts/parliament/__tests__/compute.test.ts +411 -0
  63. package/src/charts/parliament/compute.ts +313 -0
  64. package/src/charts/parliament/index.ts +32 -0
  65. package/src/charts/pie/__tests__/compute.test.ts +103 -0
  66. package/src/charts/pie/compute.ts +63 -8
  67. package/src/charts/pie/labels.ts +1 -0
  68. package/src/charts/range/__tests__/compute.test.ts +486 -0
  69. package/src/charts/range/compute.ts +273 -0
  70. package/src/charts/range/index.ts +82 -0
  71. package/src/charts/range/labels.ts +169 -0
  72. package/src/charts/rect/__tests__/compute.test.ts +120 -0
  73. package/src/charts/rect/index.ts +127 -0
  74. package/src/charts/scatter/compute.ts +7 -21
  75. package/src/charts/text/__tests__/compute.test.ts +307 -0
  76. package/src/charts/text/index.ts +44 -7
  77. package/src/charts/tick/__tests__/compute.test.ts +75 -0
  78. package/src/charts/tick/index.ts +7 -4
  79. package/src/charts/utils.ts +5 -3
  80. package/src/charts/waffle/__tests__/compute.test.ts +305 -0
  81. package/src/charts/waffle/compute.ts +197 -0
  82. package/src/charts/waffle/index.ts +31 -0
  83. package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
  84. package/src/compile/__tests__/data-clip.test.ts +68 -0
  85. package/src/compile/__tests__/fill-patterns.test.ts +154 -0
  86. package/src/compile/__tests__/size-scale.test.ts +108 -0
  87. package/src/compile/color-scale-range.ts +54 -33
  88. package/src/compile/data-clip.ts +21 -6
  89. package/src/compile/fill-patterns.ts +74 -0
  90. package/src/compile/layer.ts +121 -21
  91. package/src/compile/size-scale.ts +141 -0
  92. package/src/compile/spec-sugar.ts +767 -0
  93. package/src/compile/you-draw-it.ts +123 -0
  94. package/src/compile.ts +415 -110
  95. package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
  96. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
  97. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
  98. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
  99. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
  100. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
  101. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
  102. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
  103. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
  104. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
  105. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
  106. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
  107. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
  108. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
  109. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
  110. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
  111. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
  112. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
  113. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
  114. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
  115. package/src/compiler/__tests__/keys.test.ts +3 -9
  116. package/src/compiler/__tests__/normalize.test.ts +65 -1
  117. package/src/compiler/__tests__/validate-did-you-mean.test.ts +170 -0
  118. package/src/compiler/__tests__/validate-near-miss.test.ts +251 -0
  119. package/src/compiler/__tests__/validate.test.ts +258 -0
  120. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  121. package/src/compiler/a11y-warnings.ts +110 -0
  122. package/src/compiler/keys.ts +5 -4
  123. package/src/compiler/normalize.ts +151 -7
  124. package/src/compiler/types.ts +11 -0
  125. package/src/compiler/validate.ts +961 -32
  126. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  127. package/src/endpoint-labels/compute.ts +40 -12
  128. package/src/endpoint-labels/format.ts +45 -15
  129. package/src/endpoint-labels/predict.ts +50 -36
  130. package/src/format/__tests__/field-format.test.ts +164 -0
  131. package/src/format/field-format.ts +27 -0
  132. package/src/geo/__tests__/compile-map.test.ts +1128 -0
  133. package/src/geo/compile-map.ts +1376 -0
  134. package/src/geo/join.ts +115 -0
  135. package/src/geo/projections.ts +65 -0
  136. package/src/geo/types.ts +26 -0
  137. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  138. package/src/graphs/compile-graph.ts +27 -18
  139. package/src/index.ts +20 -2
  140. package/src/layout/axes/ticks.ts +59 -10
  141. package/src/layout/axes.ts +35 -6
  142. package/src/layout/dimensions.ts +139 -83
  143. package/src/layout/facet.ts +14 -3
  144. package/src/layout/plan.ts +83 -33
  145. package/src/layout/scales.ts +132 -15
  146. package/src/legend/__tests__/continuous.test.ts +318 -0
  147. package/src/legend/__tests__/size.test.ts +348 -0
  148. package/src/legend/__tests__/suppression.test.ts +17 -1
  149. package/src/legend/compute.ts +123 -16
  150. package/src/legend/continuous.ts +327 -0
  151. package/src/legend/size.ts +407 -0
  152. package/src/legend/suppression.ts +8 -7
  153. package/src/sankey/compile-sankey.ts +31 -19
  154. package/src/tables/format-cells.ts +16 -7
  155. package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
  156. package/src/tilemap/compile-tilemap.ts +24 -7
  157. package/src/tooltips/__tests__/compute.test.ts +2 -2
  158. package/src/tooltips/compute.ts +422 -41
  159. package/src/transforms/__tests__/conditional.test.ts +18 -0
  160. package/src/transforms/aggregate.ts +2 -1
@@ -0,0 +1,160 @@
1
+ /**
2
+ * "You draw it" (`youDrawIt`) engine tests: spec validation and geometry
3
+ * resolution through the public compile path. Asserts on the resolved
4
+ * ChartLayout.youDrawIt (samples, fromX, yInvert) and on validation errors
5
+ * for the line-only / single-series / from-required rules.
6
+ */
7
+
8
+ import type { ChartSpec } from '@opendata-ai/openchart-core';
9
+ import { describe, expect, it } from 'vitest';
10
+ import { compileChart } from '../compile';
11
+
12
+ const OPTS = { width: 600, height: 400 } as const;
13
+
14
+ function lineData() {
15
+ // 2000..2020 in steps of 5, single series, quantitative y.
16
+ return [2000, 2005, 2010, 2015, 2020].map((year, i) => ({
17
+ year: `${year}`,
18
+ value: 100 + i * 10,
19
+ }));
20
+ }
21
+
22
+ function baseSpec(overrides: Partial<ChartSpec> = {}): ChartSpec {
23
+ return {
24
+ mark: 'line',
25
+ data: lineData(),
26
+ encoding: {
27
+ x: { field: 'year', type: 'temporal' },
28
+ y: { field: 'value', type: 'quantitative' },
29
+ },
30
+ youDrawIt: { from: '2010' },
31
+ ...overrides,
32
+ } as ChartSpec;
33
+ }
34
+
35
+ describe('youDrawIt resolution', () => {
36
+ it('resolves geometry with samples at/after `from` in data coordinates', () => {
37
+ const layout = compileChart(baseSpec(), OPTS);
38
+ const ydi = layout.youDrawIt;
39
+ expect(ydi).toBeDefined();
40
+
41
+ // fromX sits inside the plot area.
42
+ expect(ydi!.fromX).toBeGreaterThan(ydi!.area.x);
43
+ expect(ydi!.fromX).toBeLessThanOrEqual(ydi!.area.x + ydi!.area.width);
44
+
45
+ // Samples start at 2010 and run to the last x, carrying data x values.
46
+ const xValues = ydi!.samples.map((s) => s.xValue);
47
+ expect(xValues).toEqual(['2010', '2015', '2020']);
48
+
49
+ // Samples ascend by pixel x.
50
+ const pxs = ydi!.samples.map((s) => s.px);
51
+ expect([...pxs].sort((a, b) => a - b)).toEqual(pxs);
52
+ });
53
+
54
+ it('resolves invertible y anchors so guesses map to data coordinates', () => {
55
+ const layout = compileChart(baseSpec(), OPTS);
56
+ const inv = layout.youDrawIt!.yInvert;
57
+ expect(inv).toBeDefined();
58
+ // Top pixel = larger data value, bottom pixel = smaller (y axis flips).
59
+ expect(inv!.topData).toBeGreaterThan(inv!.bottomData);
60
+ expect(inv!.topPixel).toBeLessThan(inv!.bottomPixel);
61
+ });
62
+
63
+ it('applies default prompt and revealLabel', () => {
64
+ const layout = compileChart(baseSpec(), OPTS);
65
+ expect(layout.youDrawIt!.prompt).toBe('Draw your guess');
66
+ expect(layout.youDrawIt!.revealLabel).toBe('Show me');
67
+ });
68
+
69
+ it('honors custom prompt and revealLabel', () => {
70
+ const layout = compileChart(
71
+ baseSpec({ youDrawIt: { from: '2010', prompt: 'Your turn', revealLabel: 'Reveal' } }),
72
+ OPTS,
73
+ );
74
+ expect(layout.youDrawIt!.prompt).toBe('Your turn');
75
+ expect(layout.youDrawIt!.revealLabel).toBe('Reveal');
76
+ });
77
+
78
+ it('resolves a comparison line to pixel points', () => {
79
+ const layout = compileChart(
80
+ baseSpec({
81
+ youDrawIt: {
82
+ from: '2010',
83
+ comparisonLine: [
84
+ { x: '2010', y: 120 },
85
+ { x: '2020', y: 150 },
86
+ ],
87
+ },
88
+ }),
89
+ OPTS,
90
+ );
91
+ const comp = layout.youDrawIt!.comparisonPoints;
92
+ expect(comp).toBeDefined();
93
+ expect(comp!.length).toBe(2);
94
+ expect(comp![0].x).toBeLessThan(comp![1].x);
95
+ });
96
+
97
+ it('omits youDrawIt from the layout when not requested', () => {
98
+ const layout = compileChart(baseSpec({ youDrawIt: undefined }), OPTS);
99
+ expect(layout.youDrawIt).toBeUndefined();
100
+ });
101
+ });
102
+
103
+ describe('youDrawIt validation', () => {
104
+ it('rejects non-line marks', () => {
105
+ expect(() =>
106
+ compileChart(
107
+ baseSpec({ mark: 'bar', encoding: { x: { field: 'year' }, y: { field: 'value' } } }),
108
+ OPTS,
109
+ ),
110
+ ).toThrow(/only supported on line charts/i);
111
+ });
112
+
113
+ it('requires from', () => {
114
+ expect(() => compileChart(baseSpec({ youDrawIt: {} as ChartSpec['youDrawIt'] }), OPTS)).toThrow(
115
+ /from is required/i,
116
+ );
117
+ });
118
+
119
+ it('accepts from: 0 (falsy but valid)', () => {
120
+ const numericData = [0, 1, 2, 3].map((step) => ({ step: `${step}`, value: 10 + step }));
121
+ const layout = compileChart(
122
+ {
123
+ mark: 'line',
124
+ data: numericData,
125
+ encoding: {
126
+ x: { field: 'step', type: 'ordinal' },
127
+ y: { field: 'value', type: 'quantitative' },
128
+ },
129
+ youDrawIt: { from: 0 },
130
+ } as ChartSpec,
131
+ OPTS,
132
+ );
133
+ expect(layout.youDrawIt).toBeDefined();
134
+ expect(layout.youDrawIt!.samples.length).toBeGreaterThan(0);
135
+ });
136
+
137
+ it('rejects multi-series (categorical color with >1 value)', () => {
138
+ const multi = [
139
+ { year: '2010', value: 10, region: 'A' },
140
+ { year: '2011', value: 12, region: 'A' },
141
+ { year: '2010', value: 20, region: 'B' },
142
+ { year: '2011', value: 22, region: 'B' },
143
+ ];
144
+ expect(() =>
145
+ compileChart(
146
+ {
147
+ mark: 'line',
148
+ data: multi,
149
+ encoding: {
150
+ x: { field: 'year', type: 'temporal' },
151
+ y: { field: 'value', type: 'quantitative' },
152
+ color: { field: 'region', type: 'nominal' },
153
+ },
154
+ youDrawIt: { from: '2010' },
155
+ } as ChartSpec,
156
+ OPTS,
157
+ ),
158
+ ).toThrow(/single-series/i);
159
+ });
160
+ });
@@ -68,7 +68,11 @@ describe('computeAnnotations', () => {
68
68
  });
69
69
 
70
70
  it('text annotation label has a connector to the data point', () => {
71
- const spec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'Note' }]);
71
+ // The default 8px anchor offset produces a sub-MIN_CONNECTOR_LENGTH stub,
72
+ // which is suppressed. Push the label out so a real leader survives.
73
+ const spec = makeSpec([
74
+ { type: 'text', x: '2020-01-01', y: 20, text: 'Note', offset: { dx: 0, dy: -60 } },
75
+ ]);
72
76
  const scales = computeScales(spec, chartArea, spec.data);
73
77
  const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
74
78
 
@@ -809,8 +813,16 @@ describe('computeAnnotations', () => {
809
813
  });
810
814
 
811
815
  describe('text annotation connector', () => {
812
- it('connector is present by default', () => {
813
- const spec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'Has connector' }]);
816
+ it('connector is present by default once the label clears the data point', () => {
817
+ const spec = makeSpec([
818
+ {
819
+ type: 'text',
820
+ x: '2020-01-01',
821
+ y: 20,
822
+ text: 'Has connector',
823
+ offset: { dx: 0, dy: -60 },
824
+ },
825
+ ]);
814
826
  const scales = computeScales(spec, chartArea, spec.data);
815
827
  const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
816
828
 
@@ -827,22 +839,31 @@ describe('computeAnnotations', () => {
827
839
  expect(annotations[0].label!.connector).toBeUndefined();
828
840
  });
829
841
 
830
- it('connector "to" points near the data position with a small gap', () => {
831
- const spec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'Connector check' }]);
842
+ it('connector "to" stops short of the endpoint marker', () => {
843
+ const spec = makeSpec([
844
+ {
845
+ type: 'text',
846
+ x: '2020-01-01',
847
+ y: 20,
848
+ text: 'Connector check',
849
+ offset: { dx: 0, dy: -60 },
850
+ },
851
+ ]);
832
852
  const scales = computeScales(spec, chartArea, spec.data);
833
853
 
834
854
  const px = scales.x?.scale(new Date('2020-01-01')) as number;
835
855
  const py = scales.y?.scale(20) as number;
836
856
  const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
837
857
 
838
- const connector = annotations[0].label!.connector!;
839
- // The "to" endpoint is pulled back along the connector direction by a
840
- // small gap (~4px), so it won't exactly match the data point.
841
- const dx = px - connector.to.x;
842
- const dy = py - connector.to.y;
843
- const dist = Math.sqrt(dx * dx + dy * dy);
844
- expect(dist).toBeGreaterThan(0);
845
- expect(dist).toBeLessThanOrEqual(5);
858
+ const resolved = annotations[0];
859
+ const connector = resolved.label!.connector!;
860
+ const dot = resolved.dot!;
861
+
862
+ // The "to" endpoint is pulled back so the line clears the marker's edge
863
+ // instead of piercing it.
864
+ const dist = Math.hypot(px - connector.to.x, py - connector.to.y);
865
+ expect(dist).toBeGreaterThan(dot.radius);
866
+ expect(dist).toBeLessThanOrEqual(dot.radius + dot.strokeWidth / 2 + 3 + 0.001);
846
867
  });
847
868
  });
848
869
 
@@ -1032,12 +1053,16 @@ describe('computeAnnotations', () => {
1032
1053
  });
1033
1054
 
1034
1055
  // -----------------------------------------------------------------
1035
- // Connector origin auto-selection
1056
+ // Connector origin (ray-box exit with standoff)
1036
1057
  // -----------------------------------------------------------------
1037
1058
 
1038
1059
  describe('connector origin auto-selection', () => {
1039
- it('connector starts from top edge when data point is above label', () => {
1040
- // Push label far below AND to the right so the top-center is unambiguously closest
1060
+ // The origin is now a ray-box intersection plus a CONNECTOR_STANDOFF gap,
1061
+ // so it sits just OUTSIDE the edge it exits from rather than on it.
1062
+ const STANDOFF = 6;
1063
+ const INFLATE = 2;
1064
+
1065
+ it('connector exits above the label when the data point is above it', () => {
1041
1066
  const spec = makeSpec([
1042
1067
  {
1043
1068
  type: 'text',
@@ -1051,17 +1076,16 @@ describe('computeAnnotations', () => {
1051
1076
  const scales = computeScales(spec, chartArea, spec.data);
1052
1077
  const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
1053
1078
 
1054
- const label = annotations[0].label!;
1055
- const connector = label.connector!;
1056
- const fontSize = 12; // DEFAULT_ANNOTATION_FONT_SIZE
1079
+ const ann = annotations[0];
1080
+ const connector = ann.label!.connector!;
1081
+ const bounds = ann.bounds!;
1057
1082
 
1058
- // Data point is far above the label, so connector should exit from the top edge
1059
- const topEdgeY = label.y - fontSize;
1060
- expect(connector.from.y).toBeCloseTo(topEdgeY, 0);
1083
+ expect(connector.exit).toBe('vertical');
1084
+ // Origin sits above the (inflated) top edge by the standoff gap.
1085
+ expect(connector.from.y).toBeCloseTo(bounds.y - INFLATE - STANDOFF, 0);
1061
1086
  });
1062
1087
 
1063
- it('connector starts from bottom edge when data point is below label', () => {
1064
- // Push label far above the data point
1088
+ it('connector exits below the label when the data point is below it', () => {
1065
1089
  const spec = makeSpec([
1066
1090
  {
1067
1091
  type: 'text',
@@ -1075,19 +1099,15 @@ describe('computeAnnotations', () => {
1075
1099
  const scales = computeScales(spec, chartArea, spec.data);
1076
1100
  const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
1077
1101
 
1078
- const label = annotations[0].label!;
1079
- const connector = label.connector!;
1080
- const fontSize = 12;
1081
- const lineHeight = 1.3;
1082
- const lines = label.text.split('\n');
1102
+ const ann = annotations[0];
1103
+ const connector = ann.label!.connector!;
1104
+ const bounds = ann.bounds!;
1083
1105
 
1084
- // Data point is below the label, so connector should exit from the bottom edge
1085
- const bottomEdgeY = label.y - fontSize + lines.length * fontSize * lineHeight;
1086
- expect(connector.from.y).toBeCloseTo(bottomEdgeY, 0);
1106
+ expect(connector.exit).toBe('vertical');
1107
+ expect(connector.from.y).toBeCloseTo(bounds.y + bounds.height + INFLATE + STANDOFF, 0);
1087
1108
  });
1088
1109
 
1089
- it('connector starts from left edge when data point is left of label', () => {
1090
- // Push label far to the right of the data point
1110
+ it('connector exits left of the label when the data point is left of it', () => {
1091
1111
  const spec = makeSpec([
1092
1112
  {
1093
1113
  type: 'text',
@@ -1101,16 +1121,18 @@ describe('computeAnnotations', () => {
1101
1121
  const scales = computeScales(spec, chartArea, spec.data);
1102
1122
  const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
1103
1123
 
1104
- const label = annotations[0].label!;
1105
- const connector = label.connector!;
1124
+ const ann = annotations[0];
1125
+ const connector = ann.label!.connector!;
1126
+ const bounds = ann.bounds!;
1106
1127
 
1107
- // Data point is to the left, so connector should exit from the left edge
1108
- // For single-line text, left edge x = label.x
1109
- expect(connector.from.x).toBeCloseTo(label.x, 0);
1128
+ expect(connector.exit).toBe('horizontal');
1129
+ // Origin sits left of the (inflated) left edge by the standoff gap. Asserting
1130
+ // only `< bounds.x` would pass for an origin anywhere off to the left, which
1131
+ // is what a broken ray-box intersection produces.
1132
+ expect(connector.from.x).toBeCloseTo(bounds.x - INFLATE - STANDOFF, 1);
1110
1133
  });
1111
1134
 
1112
- it('connector starts from right edge when data point is right of label', () => {
1113
- // Push label far to the left of the data point
1135
+ it('connector exits right of the label when the data point is right of it', () => {
1114
1136
  const spec = makeSpec([
1115
1137
  {
1116
1138
  type: 'text',
@@ -1124,18 +1146,16 @@ describe('computeAnnotations', () => {
1124
1146
  const scales = computeScales(spec, chartArea, spec.data);
1125
1147
  const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
1126
1148
 
1127
- const label = annotations[0].label!;
1128
- const connector = label.connector!;
1149
+ const ann = annotations[0];
1150
+ const connector = ann.label!.connector!;
1151
+ const bounds = ann.bounds!;
1129
1152
 
1130
- // Data point is to the right, so connector should exit from the right edge
1131
- // Right edge is at label.x + textWidth
1132
- // For single-line "Left of point" with fontSize=12, weight=400:
1133
- // textWidth = 14 chars * 12 * 0.55 * 1.0 = 92.4
1134
- expect(connector.from.x).toBeGreaterThan(label.x + 50); // well past the center
1153
+ expect(connector.exit).toBe('horizontal');
1154
+ // Mirror of the left-exit case: one standoff gap past the inflated right edge.
1155
+ expect(connector.from.x).toBeCloseTo(bounds.x + bounds.width + INFLATE + STANDOFF, 1);
1135
1156
  });
1136
1157
 
1137
- it('multi-line text connector uses correct centered bounding box', () => {
1138
- // Multi-line text with label pushed below data point
1158
+ it('the connector origin always sits outside the annotation block', () => {
1139
1159
  const spec = makeSpec([
1140
1160
  {
1141
1161
  type: 'text',
@@ -1149,17 +1169,15 @@ describe('computeAnnotations', () => {
1149
1169
  const scales = computeScales(spec, chartArea, spec.data);
1150
1170
  const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
1151
1171
 
1152
- const label = annotations[0].label!;
1153
- const connector = label.connector!;
1154
-
1155
- // For multi-line, labelX is the center. The connector should exit
1156
- // from the top-center since data point is above.
1157
- // Top-center x should be at labelX (the center of the multi-line text)
1158
- expect(connector.from.x).toBeCloseTo(label.x, 0);
1172
+ const ann = annotations[0];
1173
+ const { from } = ann.label!.connector!;
1174
+ const b = ann.bounds!;
1175
+ const inside =
1176
+ from.x >= b.x && from.x <= b.x + b.width && from.y >= b.y && from.y <= b.y + b.height;
1177
+ expect(inside).toBe(false);
1159
1178
  });
1160
1179
 
1161
- it('connector origin changes when label moves to the other side', () => {
1162
- // Same data point, label pushed right vs left
1180
+ it('connector origin flips sides when the label moves to the other side', () => {
1163
1181
  const specRight = makeSpec([
1164
1182
  {
1165
1183
  type: 'text',
@@ -1183,21 +1201,18 @@ describe('computeAnnotations', () => {
1183
1201
  const scalesRight = computeScales(specRight, chartArea, specRight.data);
1184
1202
  const scalesLeft = computeScales(specLeft, chartArea, specLeft.data);
1185
1203
 
1186
- const annotationsRight = computeAnnotations(specRight, scalesRight, chartArea, fullStrategy);
1187
- const annotationsLeft = computeAnnotations(specLeft, scalesLeft, chartArea, fullStrategy);
1204
+ const annRight = computeAnnotations(specRight, scalesRight, chartArea, fullStrategy)[0];
1205
+ const annLeft = computeAnnotations(specLeft, scalesLeft, chartArea, fullStrategy)[0];
1188
1206
 
1189
- const labelRight = annotationsRight[0].label!;
1190
- const labelLeft = annotationsLeft[0].label!;
1191
- const fromRight = labelRight.connector!.from;
1192
- const fromLeft = labelLeft.connector!.from;
1193
-
1194
- // When label is to the right of data, connector exits from left edge (= label.x)
1195
- expect(fromRight.x).toBeCloseTo(labelRight.x, 0);
1196
- // When label is to the left of data, connector exits from right edge (label.x + textWidth)
1197
- expect(fromLeft.x).toBeGreaterThan(labelLeft.x + 30); // well past the left edge
1207
+ // Label right of the point: exit off the left edge.
1208
+ expect(annRight.label!.connector!.from.x).toBeLessThan(annRight.bounds!.x);
1209
+ // Label left of the point: exit off the right edge.
1210
+ expect(annLeft.label!.connector!.from.x).toBeGreaterThan(
1211
+ annLeft.bounds!.x + annLeft.bounds!.width,
1212
+ );
1198
1213
  });
1199
1214
 
1200
- it('connectorOffset is still applied on top of auto-selected origin', () => {
1215
+ it('connectorOffset is still applied on top of the auto-selected origin', () => {
1201
1216
  const specWithOffset = makeSpec([
1202
1217
  {
1203
1218
  type: 'text',
@@ -1231,8 +1246,9 @@ describe('computeAnnotations', () => {
1231
1246
  expect(fromWith.y - fromWithout.y).toBeCloseTo(5, 0);
1232
1247
  });
1233
1248
 
1234
- it('curve connector still uses right edge regardless of data point position', () => {
1235
- // Push label below and to the right, but curve should still start from right edge
1249
+ // Curve connectors used to hardcode a right-edge exit regardless of where the
1250
+ // target was; they now use the same ray-box exit as straight connectors.
1251
+ it('curve connector exits toward the data point, not always from the right edge', () => {
1236
1252
  const spec = makeSpec([
1237
1253
  {
1238
1254
  type: 'text',
@@ -1247,13 +1263,13 @@ describe('computeAnnotations', () => {
1247
1263
  const scales = computeScales(spec, chartArea, spec.data);
1248
1264
  const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
1249
1265
 
1250
- const label = annotations[0].label!;
1251
- const connector = label.connector!;
1266
+ const ann = annotations[0];
1267
+ const connector = ann.label!.connector!;
1268
+ const bounds = ann.bounds!;
1252
1269
 
1253
- // Curve should start from right edge of text, not top edge
1254
- // Right edge x ≈ label.x + textWidth
1255
- // Per-char lookup: "Curve test" = 4.557 * 12 = 54.684
1256
- expect(connector.from.x).toBeCloseTo(label.x + 54.684, 1);
1270
+ // Data point is straight above the label, so the curve leaves the top.
1271
+ expect(connector.exit).toBe('vertical');
1272
+ expect(connector.from.y).toBeLessThan(bounds.y);
1257
1273
  });
1258
1274
  });
1259
1275
 
@@ -1306,28 +1322,120 @@ describe('computeAnnotations', () => {
1306
1322
 
1307
1323
  it('recomputes connector origin after nudging', () => {
1308
1324
  const spec = makeSpec([
1309
- { type: 'text', x: '2020-01-01', y: 20, text: 'First note' },
1310
- { type: 'text', x: '2020-01-01', y: 20, text: 'Second note' },
1325
+ {
1326
+ type: 'text',
1327
+ x: '2020-01-01',
1328
+ y: 20,
1329
+ text: 'First note',
1330
+ offset: { dx: 0, dy: -60 },
1331
+ },
1332
+ {
1333
+ type: 'text',
1334
+ x: '2020-01-01',
1335
+ y: 20,
1336
+ text: 'Second note',
1337
+ offset: { dx: 0, dy: -60 },
1338
+ },
1311
1339
  ]);
1312
1340
  const scales = computeScales(spec, chartArea, spec.data);
1313
1341
  const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
1314
1342
 
1315
- const nudgedLabel = annotations[1].label!;
1343
+ const nudged = annotations[1];
1344
+ const nudgedLabel = nudged.label!;
1316
1345
 
1317
1346
  // The nudged annotation should still have a connector
1318
1347
  expect(nudgedLabel.connector).toBeDefined();
1319
1348
 
1320
- // The connector "from" should be near the nudged label position, not the original
1321
- const connFrom = nudgedLabel.connector!.from;
1322
- const labelCenterX = nudgedLabel.x;
1323
- const labelCenterY = nudgedLabel.y;
1349
+ // The origin must sit just outside the nudged block, not the original one.
1350
+ const b = nudged.bounds!;
1351
+ const { from } = nudgedLabel.connector!;
1352
+ const inside =
1353
+ from.x >= b.x && from.x <= b.x + b.width && from.y >= b.y && from.y <= b.y + b.height;
1354
+ expect(inside).toBe(false);
1355
+
1356
+ const distFromBlock = Math.min(
1357
+ Math.abs(from.y - b.y),
1358
+ Math.abs(from.y - (b.y + b.height)),
1359
+ Math.abs(from.x - b.x),
1360
+ Math.abs(from.x - (b.x + b.width)),
1361
+ );
1362
+ // The origin clears the NUDGED block by the standoff gap and no more. The ray
1363
+ // leaves the box diagonally, so the CONNECTOR_STANDOFF (6) splits across x and
1364
+ // y and the perpendicular clearance lands somewhere in (BOX_INFLATE,
1365
+ // BOX_INFLATE + CONNECTOR_STANDOFF] = (2, 8]. A connector rebuilt against the
1366
+ // pre-nudge bounds strands the origin tens of pixels off the block, which the
1367
+ // old 20px window still admitted.
1368
+ expect(distFromBlock).toBeGreaterThan(2);
1369
+ expect(distFromBlock).toBeLessThanOrEqual(8);
1370
+ });
1371
+
1372
+ // The subtitle carries absolute coordinates, so a nudge that only moved the
1373
+ // label used to leave the subtitle stranded at the original position.
1374
+ it('subtitle moves with a nudged label', () => {
1375
+ const spec = makeSpec([
1376
+ { type: 'text', x: '2020-01-01', y: 20, text: 'First note', subtitle: 'Context one' },
1377
+ { type: 'text', x: '2020-01-01', y: 20, text: 'Second note', subtitle: 'Context two' },
1378
+ ]);
1379
+ const scales = computeScales(spec, chartArea, spec.data);
1380
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
1324
1381
 
1325
- // Connector origin should be within a reasonable distance of the label
1326
- const distFromLabel = Math.sqrt(
1327
- (connFrom.x - labelCenterX) ** 2 + (connFrom.y - labelCenterY) ** 2,
1382
+ const single = computeAnnotations(
1383
+ makeSpec([spec.annotations[1]]),
1384
+ scales,
1385
+ chartArea,
1386
+ fullStrategy,
1387
+ )[0];
1388
+
1389
+ const nudged = annotations[1];
1390
+ const dx = nudged.label!.x - single.label!.x;
1391
+ const dy = nudged.label!.y - single.label!.y;
1392
+ expect(Math.abs(dx) + Math.abs(dy)).toBeGreaterThan(0);
1393
+
1394
+ // The subtitle shifted by the same delta as its label.
1395
+ expect(nudged.subtitle!.x).toBeCloseTo(single.subtitle!.x + dx, 5);
1396
+ expect(nudged.subtitle!.y).toBeCloseTo(single.subtitle!.y + dy, 5);
1397
+
1398
+ // And the label/subtitle gap is unchanged.
1399
+ expect(nudged.subtitle!.y - nudged.label!.y).toBeCloseTo(
1400
+ single.subtitle!.y - single.label!.y,
1401
+ 5,
1328
1402
  );
1329
- // Should be within the label's bounding box range (text width + height)
1330
- expect(distFromLabel).toBeLessThan(200);
1403
+ });
1404
+
1405
+ // Auto-placement (compute.ts Pass 2) stamps a new label position from the
1406
+ // placement search; the subtitle must land where the search scored it.
1407
+ it('subtitle follows an auto-placed label', () => {
1408
+ const spec = makeSpec([
1409
+ {
1410
+ type: 'text',
1411
+ x: '2020-01-01',
1412
+ y: 20,
1413
+ text: 'Auto placed',
1414
+ subtitle: 'Its context line',
1415
+ },
1416
+ ]);
1417
+ const scales = computeScales(spec, chartArea, spec.data);
1418
+ const annotations = computeAnnotations(spec, {
1419
+ scales,
1420
+ chartArea,
1421
+ strategy: fullStrategy,
1422
+ isDark: false,
1423
+ obstacles: [],
1424
+ svg: { width: 600, height: 400 },
1425
+ measure: (text, font) => text.length * font.fontSize * 0.6,
1426
+ });
1427
+
1428
+ const ann = annotations[0];
1429
+ const label = ann.label!;
1430
+ const sub = ann.subtitle!;
1431
+ const fontSize = label.style.fontSize as number;
1432
+ const lineHeight = label.style.lineHeight as number;
1433
+
1434
+ expect(sub.x).toBeCloseTo(label.x, 5);
1435
+ expect(sub.y).toBeCloseTo(label.y + fontSize * lineHeight * 1 + 2, 5);
1436
+ expect(sub.style.textAnchor).toBe(label.style.textAnchor);
1437
+ // The stamped bounds cover both lines.
1438
+ expect(ann.bounds!.height).toBeGreaterThan(fontSize * lineHeight);
1331
1439
  });
1332
1440
 
1333
1441
  it('resolves three overlapping annotations without any collision', () => {
@@ -1428,7 +1536,14 @@ describe('computeAnnotations', () => {
1428
1536
  it('preserves connector style when nudged away from obstacle', () => {
1429
1537
  // connector: true means "straight line" - obstacle avoidance should not change it
1430
1538
  const spec = makeSpec([
1431
- { type: 'text', x: '2020-01-01', y: 20, text: 'Explicit connector', connector: true },
1539
+ {
1540
+ type: 'text',
1541
+ x: '2020-01-01',
1542
+ y: 20,
1543
+ text: 'Explicit connector',
1544
+ connector: true,
1545
+ offset: { dx: 0, dy: -60 },
1546
+ },
1432
1547
  ]);
1433
1548
  const scales = computeScales(spec, chartArea, spec.data);
1434
1549
 
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { computeArrowheadPoints } from '../geometry';
3
+
4
+ describe('computeArrowheadPoints', () => {
5
+ it('produces a downward-pointing arrow for a downward tangent', () => {
6
+ const head = computeArrowheadPoints(100, 200, 0, 1);
7
+ expect(head.tip).toEqual({ x: 100, y: 200 });
8
+ // Base should be above the tip (tangent points down)
9
+ expect(head.baseLeft.y).toBeLessThan(head.tip.y);
10
+ expect(head.baseRight.y).toBeLessThan(head.tip.y);
11
+ // Base corners symmetric around the tangent axis
12
+ expect(head.baseLeft.x).toBeLessThan(head.tip.x);
13
+ expect(head.baseRight.x).toBeGreaterThan(head.tip.x);
14
+ });
15
+
16
+ it('produces a rightward-pointing arrow for a rightward tangent', () => {
17
+ const head = computeArrowheadPoints(200, 100, 1, 0);
18
+ expect(head.tip).toEqual({ x: 200, y: 100 });
19
+ // Base should be to the left of the tip
20
+ expect(head.baseLeft.x).toBeLessThan(head.tip.x);
21
+ expect(head.baseRight.x).toBeLessThan(head.tip.x);
22
+ });
23
+
24
+ it('respects custom length and halfWidth', () => {
25
+ const defaultHead = computeArrowheadPoints(100, 200, 0, 1);
26
+ const bigHead = computeArrowheadPoints(100, 200, 0, 1, 16, 8);
27
+
28
+ // Bigger arrow should have base further from tip
29
+ const defaultBaseDist = Math.abs(defaultHead.baseLeft.y - defaultHead.tip.y);
30
+ const bigBaseDist = Math.abs(bigHead.baseLeft.y - bigHead.tip.y);
31
+ expect(bigBaseDist).toBeGreaterThan(defaultBaseDist);
32
+
33
+ // Bigger arrow should have wider base
34
+ const defaultWidth = Math.abs(defaultHead.baseLeft.x - defaultHead.baseRight.x);
35
+ const bigWidth = Math.abs(bigHead.baseLeft.x - bigHead.baseRight.x);
36
+ expect(bigWidth).toBeGreaterThan(defaultWidth);
37
+ });
38
+
39
+ it('handles diagonal tangent', () => {
40
+ const head = computeArrowheadPoints(100, 100, 1, 1);
41
+ expect(head.tip).toEqual({ x: 100, y: 100 });
42
+ // Base points should be behind the tip along the diagonal
43
+ const baseMidX = (head.baseLeft.x + head.baseRight.x) / 2;
44
+ const baseMidY = (head.baseLeft.y + head.baseRight.y) / 2;
45
+ expect(baseMidX).toBeLessThan(head.tip.x);
46
+ expect(baseMidY).toBeLessThan(head.tip.y);
47
+ });
48
+
49
+ it('uses defaults of length=7, halfWidth=3.5', () => {
50
+ const head = computeArrowheadPoints(100, 200, 0, 1);
51
+ // For a pure downward tangent, base is 7px above tip
52
+ expect(head.baseLeft.y).toBeCloseTo(193);
53
+ expect(head.baseRight.y).toBeCloseTo(193);
54
+ // Base corners are 3.5px to each side
55
+ expect(head.baseLeft.x).toBeCloseTo(96.5);
56
+ expect(head.baseRight.x).toBeCloseTo(103.5);
57
+ });
58
+
59
+ it('handles zero-length tangent gracefully', () => {
60
+ const head = computeArrowheadPoints(100, 100, 0, 0);
61
+ expect(head.tip).toEqual({ x: 100, y: 100 });
62
+ // Should not produce NaN
63
+ expect(Number.isFinite(head.baseLeft.x)).toBe(true);
64
+ expect(Number.isFinite(head.baseLeft.y)).toBe(true);
65
+ });
66
+ });