@opendata-ai/openchart-engine 6.12.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.
- package/dist/index.js +878 -606
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/axes.test.ts +12 -30
- package/src/__tests__/compile-chart.test.ts +4 -4
- package/src/__tests__/dimensions.test.ts +2 -2
- package/src/__tests__/encoding-sugar.test.ts +389 -0
- package/src/annotations/collisions.ts +268 -0
- package/src/annotations/compute.ts +9 -912
- package/src/annotations/constants.ts +32 -0
- package/src/annotations/geometry.ts +167 -0
- package/src/annotations/position.ts +95 -0
- package/src/annotations/resolve-range.ts +98 -0
- package/src/annotations/resolve-refline.ts +148 -0
- package/src/annotations/resolve-text.ts +134 -0
- package/src/charts/__tests__/post-process.test.ts +258 -0
- package/src/charts/bar/__tests__/labels.test.ts +31 -0
- package/src/charts/bar/compute.ts +27 -6
- package/src/charts/bar/labels.ts +7 -1
- package/src/charts/column/__tests__/compute.test.ts +99 -0
- package/src/charts/column/compute.ts +27 -6
- package/src/charts/line/area.ts +19 -2
- package/src/charts/post-process.ts +215 -0
- package/src/compile.ts +90 -158
- package/src/compiler/normalize.ts +2 -2
- package/src/layout/axes.ts +10 -13
- package/src/layout/dimensions.ts +3 -3
- package/src/layout/scales.ts +106 -29
- package/src/tooltips/__tests__/compute.test.ts +188 -0
- package/src/tooltips/compute.ts +25 -11
- package/src/transforms/__tests__/aggregate.test.ts +159 -0
- package/src/transforms/__tests__/fold.test.ts +79 -0
- package/src/transforms/aggregate.ts +130 -0
- package/src/transforms/fold.ts +49 -0
- 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
|
+
}
|