@opendata-ai/openchart-engine 6.11.0 → 6.13.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.
Files changed (45) hide show
  1. package/dist/index.d.ts +7 -0
  2. package/dist/index.js +944 -629
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__test-fixtures__/specs.ts +3 -0
  6. package/src/__tests__/axes.test.ts +12 -30
  7. package/src/__tests__/compile-chart.test.ts +4 -4
  8. package/src/__tests__/dimensions.test.ts +2 -2
  9. package/src/__tests__/encoding-sugar.test.ts +389 -0
  10. package/src/annotations/collisions.ts +268 -0
  11. package/src/annotations/compute.ts +9 -912
  12. package/src/annotations/constants.ts +32 -0
  13. package/src/annotations/geometry.ts +167 -0
  14. package/src/annotations/position.ts +95 -0
  15. package/src/annotations/resolve-range.ts +98 -0
  16. package/src/annotations/resolve-refline.ts +148 -0
  17. package/src/annotations/resolve-text.ts +134 -0
  18. package/src/charts/__tests__/post-process.test.ts +258 -0
  19. package/src/charts/bar/__tests__/labels.test.ts +31 -0
  20. package/src/charts/bar/compute.ts +27 -6
  21. package/src/charts/bar/labels.ts +7 -1
  22. package/src/charts/column/__tests__/compute.test.ts +99 -0
  23. package/src/charts/column/compute.ts +27 -6
  24. package/src/charts/line/area.ts +19 -2
  25. package/src/charts/post-process.ts +215 -0
  26. package/src/compile.ts +113 -169
  27. package/src/compiler/__tests__/normalize.test.ts +110 -0
  28. package/src/compiler/normalize.ts +22 -3
  29. package/src/compiler/types.ts +4 -0
  30. package/src/graphs/compile-graph.ts +8 -0
  31. package/src/graphs/types.ts +2 -0
  32. package/src/layout/axes.ts +10 -13
  33. package/src/layout/dimensions.ts +6 -3
  34. package/src/layout/scales.ts +106 -29
  35. package/src/legend/compute.ts +3 -1
  36. package/src/sankey/compile-sankey.ts +12 -2
  37. package/src/sankey/types.ts +1 -0
  38. package/src/tables/compile-table.ts +5 -0
  39. package/src/tooltips/__tests__/compute.test.ts +188 -0
  40. package/src/tooltips/compute.ts +25 -11
  41. package/src/transforms/__tests__/aggregate.test.ts +159 -0
  42. package/src/transforms/__tests__/fold.test.ts +79 -0
  43. package/src/transforms/aggregate.ts +130 -0
  44. package/src/transforms/fold.ts +49 -0
  45. package/src/transforms/index.ts +8 -0
@@ -0,0 +1,32 @@
1
+ /** Default font size for annotation labels. */
2
+ export const DEFAULT_ANNOTATION_FONT_SIZE = 12;
3
+
4
+ /** Default font weight for annotation labels. */
5
+ export const DEFAULT_ANNOTATION_FONT_WEIGHT = 400;
6
+
7
+ /** Default line height multiplier for annotation text. */
8
+ export const DEFAULT_LINE_HEIGHT = 1.3;
9
+
10
+ /** Default fill color for range annotations. */
11
+ export const DEFAULT_RANGE_FILL = '#f0c040';
12
+
13
+ /** Default opacity for range annotations. */
14
+ export const DEFAULT_RANGE_OPACITY = 0.15;
15
+
16
+ /** Default dash pattern for reference lines. */
17
+ export const DEFAULT_REFLINE_DASH = '4 3';
18
+
19
+ // Theme-aware defaults for text and stroke colors
20
+ export const LIGHT_TEXT_FILL = '#333333';
21
+ export const DARK_TEXT_FILL = '#d1d5db';
22
+ export const LIGHT_REFLINE_STROKE = '#888888';
23
+ export const DARK_REFLINE_STROKE = '#9ca3af';
24
+
25
+ /** Default label offset when using anchor directions. */
26
+ export const ANCHOR_OFFSET = 8;
27
+
28
+ /** Padding between annotation and obstacle when nudging. */
29
+ export const NUDGE_PADDING = 6;
30
+
31
+ /** Small inset margin so labels don't touch the SVG edge. */
32
+ export const CLAMP_MARGIN = 4;
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Geometry utilities for annotation text bounds, connector origins, and offsets.
3
+ */
4
+
5
+ import type {
6
+ AnnotationAnchor,
7
+ AnnotationOffset,
8
+ Rect,
9
+ ResolvedLabel,
10
+ } from '@opendata-ai/openchart-core';
11
+ import { estimateTextWidth } from '@opendata-ai/openchart-core';
12
+ import {
13
+ ANCHOR_OFFSET,
14
+ DEFAULT_ANNOTATION_FONT_SIZE,
15
+ DEFAULT_ANNOTATION_FONT_WEIGHT,
16
+ DEFAULT_LINE_HEIGHT,
17
+ } from './constants';
18
+
19
+ /**
20
+ * Compute the bounding box of annotation text at a given label position.
21
+ * Multi-line text is centered at labelX; single-line starts at labelX.
22
+ */
23
+ export function computeTextBounds(
24
+ labelX: number,
25
+ labelY: number,
26
+ text: string,
27
+ fontSize: number,
28
+ fontWeight: number,
29
+ ): Rect {
30
+ const lines = text.split('\n');
31
+ const isMultiLine = lines.length > 1;
32
+ const maxWidth = Math.max(...lines.map((line) => estimateTextWidth(line, fontSize, fontWeight)));
33
+ const totalHeight = lines.length * fontSize * DEFAULT_LINE_HEIGHT;
34
+ const x = isMultiLine ? labelX - maxWidth / 2 : labelX;
35
+
36
+ return {
37
+ x,
38
+ y: labelY - fontSize,
39
+ width: maxWidth,
40
+ height: totalHeight,
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Apply anchor direction to compute label offset from data point.
46
+ * Returns { dx, dy } pixel offsets.
47
+ */
48
+ export function computeAnchorOffset(
49
+ anchor: AnnotationAnchor | undefined,
50
+ _px: number,
51
+ py: number,
52
+ chartArea: Rect,
53
+ ): { dx: number; dy: number } {
54
+ if (!anchor || anchor === 'auto') {
55
+ // Auto: place above if in the lower half, below if upper half
56
+ const isUpperHalf = py < chartArea.y + chartArea.height / 2;
57
+ return isUpperHalf
58
+ ? { dx: ANCHOR_OFFSET, dy: ANCHOR_OFFSET } // below-right
59
+ : { dx: ANCHOR_OFFSET, dy: -ANCHOR_OFFSET }; // above-right
60
+ }
61
+
62
+ switch (anchor) {
63
+ case 'top':
64
+ return { dx: 0, dy: -ANCHOR_OFFSET };
65
+ case 'bottom':
66
+ return { dx: 0, dy: ANCHOR_OFFSET };
67
+ case 'left':
68
+ return { dx: -ANCHOR_OFFSET, dy: 0 };
69
+ case 'right':
70
+ return { dx: ANCHOR_OFFSET, dy: 0 };
71
+ }
72
+ }
73
+
74
+ /** Apply user offset on top of computed anchor offset. */
75
+ export function applyOffset(
76
+ base: { dx: number; dy: number },
77
+ offset: AnnotationOffset | undefined,
78
+ ): { dx: number; dy: number } {
79
+ if (!offset) return base;
80
+ return {
81
+ dx: base.dx + (offset.dx ?? 0),
82
+ dy: base.dy + (offset.dy ?? 0),
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Compute the connector origin point on the text bounding box.
88
+ * For straight connectors, finds the edge midpoint (top, bottom, left, right)
89
+ * closest to the data point. For curve connectors, always uses the right edge.
90
+ */
91
+ export function computeConnectorOrigin(
92
+ labelX: number,
93
+ labelY: number,
94
+ text: string,
95
+ fontSize: number,
96
+ fontWeight: number,
97
+ targetX: number,
98
+ targetY: number,
99
+ connectorStyle: 'straight' | 'curve',
100
+ ): { x: number; y: number } {
101
+ const box = computeTextBounds(labelX, labelY, text, fontSize, fontWeight);
102
+ const boxCenterX = box.x + box.width / 2;
103
+ const boxCenterY = box.y + box.height / 2;
104
+
105
+ // Curve connectors always start from the right edge
106
+ if (connectorStyle === 'curve') {
107
+ return {
108
+ x: box.x + box.width,
109
+ y: boxCenterY,
110
+ };
111
+ }
112
+
113
+ // Normalize the vector from box center to target by the box half-dimensions.
114
+ // This accounts for the box aspect ratio: a wide text box should prefer
115
+ // top/bottom exits even when the target is also offset horizontally.
116
+ const halfW = box.width / 2 || 1;
117
+ const halfH = box.height / 2 || 1;
118
+ const ndx = (targetX - boxCenterX) / halfW;
119
+ const ndy = (targetY - boxCenterY) / halfH;
120
+
121
+ if (Math.abs(ndy) >= Math.abs(ndx)) {
122
+ // Target is more above/below than left/right → use top or bottom edge
123
+ return ndy < 0
124
+ ? { x: boxCenterX, y: box.y } // top
125
+ : { x: boxCenterX, y: box.y + box.height }; // bottom
126
+ }
127
+ // Target is more left/right → use left or right edge
128
+ return ndx < 0
129
+ ? { x: box.x, y: boxCenterY } // left
130
+ : { x: box.x + box.width, y: boxCenterY }; // right
131
+ }
132
+
133
+ /** Estimate the bounding box of an annotation label. */
134
+ export function estimateLabelBounds(label: ResolvedLabel): Rect {
135
+ const fontSize = label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
136
+ const fontWeight = label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
137
+ return computeTextBounds(label.x, label.y, label.text, fontSize, fontWeight);
138
+ }
139
+
140
+ /**
141
+ * Recompute the connector origin for a label after it has been repositioned.
142
+ * Encapsulates the pattern of recalculating which edge of the text box the
143
+ * connector should exit from based on the target data point.
144
+ */
145
+ export function recomputeConnector(
146
+ label: ResolvedLabel,
147
+ targetX: number,
148
+ targetY: number,
149
+ ): ResolvedLabel['connector'] {
150
+ const connector = label.connector;
151
+ if (!connector) return connector;
152
+
153
+ const fontSize = label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
154
+ const fontWeight = label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
155
+ const connStyle = connector.style === 'curve' ? ('curve' as const) : ('straight' as const);
156
+ const newFrom = computeConnectorOrigin(
157
+ label.x,
158
+ label.y,
159
+ label.text,
160
+ fontSize,
161
+ fontWeight,
162
+ targetX,
163
+ targetY,
164
+ connStyle,
165
+ );
166
+ return { ...connector, from: newFrom };
167
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Data-coordinate to pixel-coordinate resolution for annotations.
3
+ */
4
+
5
+ import type { ScaleBand, ScaleLinear, ScaleTime } from 'd3-scale';
6
+ import type { ResolvedScales } from '../layout/scales';
7
+
8
+ /**
9
+ * Interpolate a numeric value between sorted domain entries.
10
+ * Used when an annotation references a value not present in a categorical domain
11
+ * (e.g. "2008" on an axis with data points at "2007" and "2009").
12
+ * Returns null if domain values aren't numeric or the domain is too small.
13
+ */
14
+ export function interpolateInDomain(
15
+ numValue: number,
16
+ domain: string[],
17
+ positionOf: (entry: string) => number,
18
+ ): number | null {
19
+ if (domain.length < 2) return null;
20
+ const nums = domain.map(Number);
21
+ if (!nums.every(Number.isFinite)) return null;
22
+
23
+ // Sort by numeric value so bracket-finding works regardless of data order
24
+ const sorted = nums.map((n, i) => ({ n, i })).sort((a, b) => a.n - b.n);
25
+
26
+ // Find the two sorted neighbors that bracket this value
27
+ let lower = 0;
28
+ let upper = sorted.length - 1;
29
+ for (let i = 0; i < sorted.length; i++) {
30
+ if (sorted[i].n <= numValue) lower = i;
31
+ if (sorted[i].n >= numValue) {
32
+ upper = i;
33
+ break;
34
+ }
35
+ }
36
+
37
+ const lowerPos = positionOf(domain[sorted[lower].i]);
38
+ const upperPos = positionOf(domain[sorted[upper].i]);
39
+ if (lower === upper) return lowerPos;
40
+ const t = (numValue - sorted[lower].n) / (sorted[upper].n - sorted[lower].n);
41
+ return lowerPos + t * (upperPos - lowerPos);
42
+ }
43
+
44
+ /** Resolve a data value to a pixel position on a given axis. */
45
+ export function resolvePosition(
46
+ value: string | number,
47
+ scale: ResolvedScales['x'] | ResolvedScales['y'],
48
+ ): number | null {
49
+ if (!scale) return null;
50
+
51
+ const s = scale.scale;
52
+ const type = scale.type;
53
+
54
+ if (type === 'time') {
55
+ const date = new Date(String(value));
56
+ if (Number.isNaN(date.getTime())) return null;
57
+ return (s as ScaleTime<number, number>)(date);
58
+ }
59
+
60
+ if (type === 'linear' || type === 'log') {
61
+ const num = typeof value === 'number' ? value : Number(value);
62
+ if (!Number.isFinite(num)) return null;
63
+ return (s as ScaleLinear<number, number>)(num);
64
+ }
65
+
66
+ if (type === 'band') {
67
+ const bandScale = s as ScaleBand<string>;
68
+ const strValue = String(value);
69
+ const pos = bandScale(strValue);
70
+ if (pos !== undefined) return pos + (bandScale.bandwidth?.() ?? 0) / 2;
71
+
72
+ const bw = bandScale.bandwidth?.() ?? 0;
73
+ return interpolateInDomain(
74
+ Number(strValue),
75
+ bandScale.domain(),
76
+ (entry) => (bandScale(entry) ?? 0) + bw / 2,
77
+ );
78
+ }
79
+
80
+ // point or ordinal: try direct lookup, fall back to interpolation
81
+ const strValue = String(value);
82
+ const directResult = (s as (v: string) => number | undefined)(strValue);
83
+ if (directResult !== undefined) return directResult;
84
+
85
+ if (type === 'point' || type === 'ordinal') {
86
+ const domain = (s as { domain(): string[] }).domain();
87
+ return interpolateInDomain(
88
+ Number(strValue),
89
+ domain,
90
+ (entry) => (s as (v: string) => number)(entry) ?? 0,
91
+ );
92
+ }
93
+
94
+ return null;
95
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Range annotation resolver: creates a highlighted rectangular band
3
+ * between two data values on x or y axis.
4
+ */
5
+
6
+ import type {
7
+ RangeAnnotation,
8
+ Rect,
9
+ ResolvedAnnotation,
10
+ ResolvedLabel,
11
+ } from '@opendata-ai/openchart-core';
12
+ import type { ResolvedScales } from '../layout/scales';
13
+ import { DEFAULT_RANGE_FILL, DEFAULT_RANGE_OPACITY } from './constants';
14
+ import { applyOffset } from './geometry';
15
+ import { resolvePosition } from './position';
16
+ import { makeAnnotationLabelStyle } from './resolve-text';
17
+
18
+ export function resolveRangeAnnotation(
19
+ annotation: RangeAnnotation,
20
+ scales: ResolvedScales,
21
+ chartArea: Rect,
22
+ isDark: boolean,
23
+ ): ResolvedAnnotation | null {
24
+ let x = chartArea.x;
25
+ let y = chartArea.y;
26
+ let width = chartArea.width;
27
+ let height = chartArea.height;
28
+
29
+ // X-range (vertical band)
30
+ if (annotation.x1 !== undefined && annotation.x2 !== undefined) {
31
+ const x1px = resolvePosition(annotation.x1, scales.x);
32
+ const x2px = resolvePosition(annotation.x2, scales.x);
33
+ if (x1px === null || x2px === null) return null;
34
+
35
+ x = Math.min(x1px, x2px);
36
+ width = Math.abs(x2px - x1px);
37
+ }
38
+
39
+ // Y-range (horizontal band)
40
+ if (annotation.y1 !== undefined && annotation.y2 !== undefined) {
41
+ const y1px = resolvePosition(annotation.y1, scales.y);
42
+ const y2px = resolvePosition(annotation.y2, scales.y);
43
+ if (y1px === null || y2px === null) return null;
44
+
45
+ y = Math.min(y1px, y2px);
46
+ height = Math.abs(y2px - y1px);
47
+ }
48
+
49
+ const rect: Rect = { x, y, width, height };
50
+
51
+ // Label positioned within the range, with optional offset.
52
+ // labelAnchor controls horizontal placement:
53
+ // "top" (default): horizontally centered, text-anchor middle
54
+ // "left": left edge, text-anchor start
55
+ // "right": right edge, text-anchor end
56
+ // "bottom"/"auto": horizontally centered, text-anchor middle
57
+ let label: ResolvedLabel | undefined;
58
+ if (annotation.label) {
59
+ const anchor = annotation.labelAnchor ?? 'top';
60
+ const centered = anchor === 'top' || anchor === 'bottom' || anchor === 'auto';
61
+ const baseDx = centered ? 0 : anchor === 'right' ? -4 : 4;
62
+ const baseDy = 14;
63
+ const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset);
64
+
65
+ const style = makeAnnotationLabelStyle(11, 500, undefined, isDark);
66
+ if (centered) {
67
+ style.textAnchor = 'middle';
68
+ } else if (anchor === 'right') {
69
+ style.textAnchor = 'end';
70
+ }
71
+
72
+ // Position label horizontally centered within the range band by default.
73
+ // For left/right anchors, position at the respective edge.
74
+ const baseX = centered ? x + width / 2 : anchor === 'right' ? x + width : x;
75
+
76
+ label = {
77
+ text: annotation.label,
78
+ x: baseX + labelDelta.dx,
79
+ y: y + labelDelta.dy,
80
+ style,
81
+ visible: true,
82
+ };
83
+ }
84
+
85
+ // In dark mode, boost range opacity slightly for better visibility
86
+ const defaultOpacity = isDark ? 0.2 : DEFAULT_RANGE_OPACITY;
87
+
88
+ return {
89
+ type: 'range',
90
+ id: annotation.id,
91
+ rect,
92
+ label,
93
+ fill: annotation.fill ?? DEFAULT_RANGE_FILL,
94
+ opacity: annotation.opacity ?? defaultOpacity,
95
+ stroke: annotation.stroke,
96
+ zIndex: annotation.zIndex,
97
+ };
98
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Reference line annotation resolver: creates a horizontal or vertical
3
+ * reference line at a data value with optional label.
4
+ */
5
+
6
+ import type {
7
+ Point,
8
+ Rect,
9
+ RefLineAnnotation,
10
+ ResolvedAnnotation,
11
+ ResolvedLabel,
12
+ } from '@opendata-ai/openchart-core';
13
+ import type { ResolvedScales } from '../layout/scales';
14
+ import { DARK_REFLINE_STROKE, DEFAULT_REFLINE_DASH, LIGHT_REFLINE_STROKE } from './constants';
15
+ import { applyOffset } from './geometry';
16
+ import { resolvePosition } from './position';
17
+ import { makeAnnotationLabelStyle } from './resolve-text';
18
+
19
+ export function resolveRefLineAnnotation(
20
+ annotation: RefLineAnnotation,
21
+ scales: ResolvedScales,
22
+ chartArea: Rect,
23
+ isDark: boolean,
24
+ ): ResolvedAnnotation | null {
25
+ let start: Point;
26
+ let end: Point;
27
+
28
+ if (annotation.y !== undefined) {
29
+ // Horizontal reference line
30
+ const yPx = resolvePosition(annotation.y, scales.y);
31
+ if (yPx === null) return null;
32
+
33
+ start = { x: chartArea.x, y: yPx };
34
+ end = { x: chartArea.x + chartArea.width, y: yPx };
35
+ } else if (annotation.x !== undefined) {
36
+ // Vertical reference line
37
+ const xPx = resolvePosition(annotation.x, scales.x);
38
+ if (xPx === null) return null;
39
+
40
+ start = { x: xPx, y: chartArea.y };
41
+ end = { x: xPx, y: chartArea.y + chartArea.height };
42
+ } else {
43
+ return null;
44
+ }
45
+
46
+ // Determine dash pattern from style
47
+ let strokeDasharray: string | undefined;
48
+ if (annotation.style === 'dashed' || annotation.style === undefined) {
49
+ strokeDasharray = DEFAULT_REFLINE_DASH;
50
+ } else if (annotation.style === 'dotted') {
51
+ strokeDasharray = '2 2';
52
+ }
53
+ // 'solid' gets no dasharray
54
+
55
+ // Label placement on reflines. labelAnchor controls position:
56
+ //
57
+ // Horizontal reflines (y set):
58
+ // "left": left end of line, above "right"/"top" (default): right end, above
59
+ // "bottom": right end of line, below
60
+ //
61
+ // Vertical reflines (x set):
62
+ // "right": label to the left of the line, near top
63
+ // "bottom": label to the right of the line, near bottom
64
+ // "left"/"top" (default): label to the right of the line, near top
65
+ let label: ResolvedLabel | undefined;
66
+ if (annotation.label) {
67
+ const isHorizontal = annotation.y !== undefined;
68
+ const anchor = annotation.labelAnchor ?? (isHorizontal ? 'top' : 'left');
69
+
70
+ let baseDx: number;
71
+ let baseDy: number;
72
+ let labelX: number;
73
+ let labelY: number;
74
+ let textAnchor: 'start' | 'middle' | 'end';
75
+
76
+ if (isHorizontal) {
77
+ if (anchor === 'left') {
78
+ baseDx = 4;
79
+ baseDy = -4;
80
+ labelX = start.x;
81
+ labelY = start.y;
82
+ textAnchor = 'start';
83
+ } else if (anchor === 'bottom') {
84
+ baseDx = -4;
85
+ baseDy = 14;
86
+ labelX = end.x;
87
+ labelY = end.y;
88
+ textAnchor = 'end';
89
+ } else {
90
+ // 'right', 'top' (default), 'auto'
91
+ baseDx = -4;
92
+ baseDy = -4;
93
+ labelX = end.x;
94
+ labelY = end.y;
95
+ textAnchor = 'end';
96
+ }
97
+ } else {
98
+ // Vertical refline
99
+ if (anchor === 'right') {
100
+ baseDx = -4;
101
+ baseDy = 14;
102
+ labelX = start.x;
103
+ labelY = start.y;
104
+ textAnchor = 'end';
105
+ } else if (anchor === 'bottom') {
106
+ baseDx = 4;
107
+ baseDy = -4;
108
+ labelX = start.x;
109
+ labelY = end.y;
110
+ textAnchor = 'start';
111
+ } else {
112
+ // 'left', 'top' (default), 'auto' — label to the right of the line, near top
113
+ baseDx = 4;
114
+ baseDy = 14;
115
+ labelX = start.x;
116
+ labelY = start.y;
117
+ textAnchor = 'start';
118
+ }
119
+ }
120
+
121
+ const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset);
122
+
123
+ const defaultStroke = isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE;
124
+ const style = makeAnnotationLabelStyle(11, 400, annotation.stroke ?? defaultStroke, isDark);
125
+ style.textAnchor = textAnchor;
126
+
127
+ label = {
128
+ text: annotation.label,
129
+ x: labelX + labelDelta.dx,
130
+ y: labelY + labelDelta.dy,
131
+ style,
132
+ visible: true,
133
+ };
134
+ }
135
+
136
+ const defaultStroke = isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE;
137
+
138
+ return {
139
+ type: 'refline',
140
+ id: annotation.id,
141
+ line: { start, end },
142
+ label,
143
+ stroke: annotation.stroke ?? defaultStroke,
144
+ strokeDasharray,
145
+ strokeWidth: annotation.strokeWidth ?? 1,
146
+ zIndex: annotation.zIndex,
147
+ };
148
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Text annotation resolver: positions a label at data coordinates with an
3
+ * optional callout connector to the data point.
4
+ */
5
+
6
+ import type {
7
+ Rect,
8
+ ResolvedAnnotation,
9
+ ResolvedLabel,
10
+ TextAnnotation,
11
+ TextStyle,
12
+ } from '@opendata-ai/openchart-core';
13
+ import type { ResolvedScales } from '../layout/scales';
14
+ import {
15
+ DARK_TEXT_FILL,
16
+ DEFAULT_ANNOTATION_FONT_SIZE,
17
+ DEFAULT_ANNOTATION_FONT_WEIGHT,
18
+ DEFAULT_LINE_HEIGHT,
19
+ LIGHT_TEXT_FILL,
20
+ } from './constants';
21
+ import { applyOffset, computeAnchorOffset, computeConnectorOrigin } from './geometry';
22
+ import { resolvePosition } from './position';
23
+
24
+ export function makeAnnotationLabelStyle(
25
+ fontSize?: number,
26
+ fontWeight?: number,
27
+ fill?: string,
28
+ isDark?: boolean,
29
+ ): TextStyle {
30
+ const defaultFill = isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL;
31
+ return {
32
+ fontFamily: 'Inter, system-ui, sans-serif',
33
+ fontSize: fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE,
34
+ fontWeight: fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT,
35
+ fill: fill ?? defaultFill,
36
+ lineHeight: DEFAULT_LINE_HEIGHT,
37
+ textAnchor: 'start',
38
+ };
39
+ }
40
+
41
+ export function resolveTextAnnotation(
42
+ annotation: TextAnnotation,
43
+ scales: ResolvedScales,
44
+ chartArea: Rect,
45
+ isDark: boolean,
46
+ ): ResolvedAnnotation | null {
47
+ const px = resolvePosition(annotation.x, scales.x);
48
+ const py = resolvePosition(annotation.y, scales.y);
49
+
50
+ if (px === null || py === null) return null;
51
+
52
+ const defaultTextFill = isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL;
53
+ const labelStyle = makeAnnotationLabelStyle(
54
+ annotation.fontSize,
55
+ annotation.fontWeight,
56
+ annotation.fill ?? defaultTextFill,
57
+ isDark,
58
+ );
59
+
60
+ // Compute position from anchor direction + user offset
61
+ const anchorDelta = computeAnchorOffset(annotation.anchor, px, py, chartArea);
62
+ const finalDelta = applyOffset(anchorDelta, annotation.offset);
63
+
64
+ const labelX = px + finalDelta.dx;
65
+ const labelY = py + finalDelta.dy;
66
+
67
+ // Connector: draw unless explicitly disabled
68
+ const showConnector = annotation.connector !== false;
69
+ const connectorStyle = annotation.connector === 'curve' ? 'curve' : 'straight';
70
+
71
+ // Compute connector origin: pick the edge midpoint closest to the data point
72
+ const fontSize = annotation.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
73
+ const fontWeight = annotation.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
74
+ const { x: connectorFromX, y: connectorFromY } = computeConnectorOrigin(
75
+ labelX,
76
+ labelY,
77
+ annotation.text,
78
+ fontSize,
79
+ fontWeight,
80
+ px,
81
+ py,
82
+ connectorStyle,
83
+ );
84
+
85
+ // Apply user-provided connector endpoint offsets
86
+ const baseFrom = { x: connectorFromX, y: connectorFromY };
87
+ const baseTo = { x: px, y: py };
88
+ const adjustedFrom = {
89
+ x: baseFrom.x + (annotation.connectorOffset?.from?.dx ?? 0),
90
+ y: baseFrom.y + (annotation.connectorOffset?.from?.dy ?? 0),
91
+ };
92
+ const adjustedToRaw = {
93
+ x: baseTo.x + (annotation.connectorOffset?.to?.dx ?? 0),
94
+ y: baseTo.y + (annotation.connectorOffset?.to?.dy ?? 0),
95
+ };
96
+
97
+ // Pull the "to" endpoint back along the connector direction so the
98
+ // line doesn't touch the data point directly (leaves a small gap).
99
+ const GAP = 4;
100
+ const cdx = adjustedToRaw.x - adjustedFrom.x;
101
+ const cdy = adjustedToRaw.y - adjustedFrom.y;
102
+ const dist = Math.sqrt(cdx * cdx + cdy * cdy);
103
+ const adjustedTo =
104
+ dist > GAP * 2
105
+ ? { x: adjustedToRaw.x - (cdx / dist) * GAP, y: adjustedToRaw.y - (cdy / dist) * GAP }
106
+ : adjustedToRaw;
107
+
108
+ const label: ResolvedLabel = {
109
+ text: annotation.text,
110
+ x: labelX,
111
+ y: labelY,
112
+ style: labelStyle,
113
+ visible: true,
114
+ connector: showConnector
115
+ ? {
116
+ from: adjustedFrom,
117
+ to: adjustedTo,
118
+ stroke: annotation.stroke ?? '#999999',
119
+ style: connectorStyle,
120
+ }
121
+ : undefined,
122
+ background: annotation.background,
123
+ };
124
+
125
+ return {
126
+ type: 'text',
127
+ id: annotation.id,
128
+ label,
129
+ stroke: annotation.stroke,
130
+ fill: annotation.fill,
131
+ opacity: annotation.opacity,
132
+ zIndex: annotation.zIndex,
133
+ };
134
+ }