@opendata-ai/openchart-engine 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +366 -0
- package/dist/index.js +4227 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -0
- package/src/__test-fixtures__/specs.ts +124 -0
- package/src/__tests__/axes.test.ts +114 -0
- package/src/__tests__/compile-chart.test.ts +337 -0
- package/src/__tests__/dimensions.test.ts +151 -0
- package/src/__tests__/legend.test.ts +113 -0
- package/src/__tests__/scales.test.ts +109 -0
- package/src/annotations/__tests__/compute.test.ts +454 -0
- package/src/annotations/compute.ts +603 -0
- package/src/charts/__tests__/registry.test.ts +110 -0
- package/src/charts/bar/__tests__/compute.test.ts +294 -0
- package/src/charts/bar/__tests__/labels.test.ts +75 -0
- package/src/charts/bar/compute.ts +205 -0
- package/src/charts/bar/index.ts +33 -0
- package/src/charts/bar/labels.ts +132 -0
- package/src/charts/column/__tests__/compute.test.ts +277 -0
- package/src/charts/column/compute.ts +282 -0
- package/src/charts/column/index.ts +33 -0
- package/src/charts/column/labels.ts +108 -0
- package/src/charts/dot/__tests__/compute.test.ts +344 -0
- package/src/charts/dot/compute.ts +257 -0
- package/src/charts/dot/index.ts +46 -0
- package/src/charts/dot/labels.ts +97 -0
- package/src/charts/line/__tests__/compute.test.ts +437 -0
- package/src/charts/line/__tests__/labels.test.ts +93 -0
- package/src/charts/line/area.ts +288 -0
- package/src/charts/line/compute.ts +177 -0
- package/src/charts/line/index.ts +68 -0
- package/src/charts/line/labels.ts +144 -0
- package/src/charts/pie/__tests__/compute.test.ts +276 -0
- package/src/charts/pie/compute.ts +234 -0
- package/src/charts/pie/index.ts +49 -0
- package/src/charts/pie/labels.ts +142 -0
- package/src/charts/registry.ts +64 -0
- package/src/charts/scatter/__tests__/compute.test.ts +304 -0
- package/src/charts/scatter/__tests__/trendline.test.ts +191 -0
- package/src/charts/scatter/compute.ts +124 -0
- package/src/charts/scatter/index.ts +41 -0
- package/src/charts/scatter/trendline.ts +100 -0
- package/src/charts/utils.ts +120 -0
- package/src/compile.ts +368 -0
- package/src/compiler/__tests__/compile.test.ts +87 -0
- package/src/compiler/__tests__/normalize.test.ts +210 -0
- package/src/compiler/__tests__/validate.test.ts +440 -0
- package/src/compiler/index.ts +47 -0
- package/src/compiler/normalize.ts +269 -0
- package/src/compiler/types.ts +148 -0
- package/src/compiler/validate.ts +581 -0
- package/src/graphs/__tests__/community.test.ts +228 -0
- package/src/graphs/__tests__/compile-graph.test.ts +315 -0
- package/src/graphs/__tests__/encoding.test.ts +314 -0
- package/src/graphs/community.ts +92 -0
- package/src/graphs/compile-graph.ts +291 -0
- package/src/graphs/encoding.ts +302 -0
- package/src/graphs/types.ts +98 -0
- package/src/index.ts +74 -0
- package/src/layout/axes.ts +194 -0
- package/src/layout/dimensions.ts +199 -0
- package/src/layout/gridlines.ts +84 -0
- package/src/layout/scales.ts +426 -0
- package/src/legend/compute.ts +186 -0
- package/src/tables/__tests__/bar-column.test.ts +147 -0
- package/src/tables/__tests__/category-colors.test.ts +153 -0
- package/src/tables/__tests__/compile-table.test.ts +208 -0
- package/src/tables/__tests__/format-cells.test.ts +126 -0
- package/src/tables/__tests__/heatmap.test.ts +124 -0
- package/src/tables/__tests__/pagination.test.ts +78 -0
- package/src/tables/__tests__/search.test.ts +94 -0
- package/src/tables/__tests__/sort.test.ts +107 -0
- package/src/tables/__tests__/sparkline.test.ts +122 -0
- package/src/tables/bar-column.ts +94 -0
- package/src/tables/category-colors.ts +67 -0
- package/src/tables/compile-table.ts +420 -0
- package/src/tables/format-cells.ts +110 -0
- package/src/tables/heatmap.ts +121 -0
- package/src/tables/pagination.ts +46 -0
- package/src/tables/search.ts +66 -0
- package/src/tables/sort.ts +69 -0
- package/src/tables/sparkline.ts +113 -0
- package/src/tables/utils.ts +16 -0
- package/src/tooltips/__tests__/compute.test.ts +328 -0
- package/src/tooltips/compute.ts +231 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Annotation computation: converts spec-level annotations to pixel-positioned
|
|
3
|
+
* ResolvedAnnotation objects using the resolved scales.
|
|
4
|
+
*
|
|
5
|
+
* Handles three annotation types:
|
|
6
|
+
* - text: positioned at a data coordinate with an optional callout
|
|
7
|
+
* - range: a highlighted rectangle between two data values
|
|
8
|
+
* - refline: a horizontal or vertical reference line at a data value
|
|
9
|
+
*
|
|
10
|
+
* Supports fine-grained positioning via offset, anchor, connector, and zIndex.
|
|
11
|
+
* At compact breakpoints, annotations are simplified or hidden.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
AnnotationAnchor,
|
|
16
|
+
AnnotationOffset,
|
|
17
|
+
LayoutStrategy,
|
|
18
|
+
Point,
|
|
19
|
+
RangeAnnotation,
|
|
20
|
+
Rect,
|
|
21
|
+
RefLineAnnotation,
|
|
22
|
+
ResolvedAnnotation,
|
|
23
|
+
ResolvedLabel,
|
|
24
|
+
TextAnnotation,
|
|
25
|
+
TextStyle,
|
|
26
|
+
} from '@opendata-ai/openchart-core';
|
|
27
|
+
import { estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
28
|
+
import type { ScaleBand, ScaleLinear, ScaleTime } from 'd3-scale';
|
|
29
|
+
import type { NormalizedChartSpec } from '../compiler/types';
|
|
30
|
+
import type { ResolvedScales } from '../layout/scales';
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Helpers
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
const DEFAULT_ANNOTATION_FONT_SIZE = 12;
|
|
37
|
+
const DEFAULT_ANNOTATION_FONT_WEIGHT = 400;
|
|
38
|
+
const DEFAULT_RANGE_FILL = '#f0c040';
|
|
39
|
+
const DEFAULT_RANGE_OPACITY = 0.15;
|
|
40
|
+
const DEFAULT_REFLINE_DASH = '4 3';
|
|
41
|
+
|
|
42
|
+
// Theme-aware defaults for text and stroke colors
|
|
43
|
+
const LIGHT_TEXT_FILL = '#333333';
|
|
44
|
+
const DARK_TEXT_FILL = '#d1d5db';
|
|
45
|
+
const LIGHT_REFLINE_STROKE = '#888888';
|
|
46
|
+
const DARK_REFLINE_STROKE = '#9ca3af';
|
|
47
|
+
|
|
48
|
+
/** Default label offset when using anchor directions. */
|
|
49
|
+
const ANCHOR_OFFSET = 8;
|
|
50
|
+
|
|
51
|
+
/** Resolve a data value to a pixel position on a given axis. */
|
|
52
|
+
function resolvePosition(
|
|
53
|
+
value: string | number,
|
|
54
|
+
scale: ResolvedScales['x'] | ResolvedScales['y'],
|
|
55
|
+
): number | null {
|
|
56
|
+
if (!scale) return null;
|
|
57
|
+
|
|
58
|
+
const s = scale.scale;
|
|
59
|
+
const type = scale.type;
|
|
60
|
+
|
|
61
|
+
if (type === 'time') {
|
|
62
|
+
const date = new Date(String(value));
|
|
63
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
64
|
+
return (s as ScaleTime<number, number>)(date);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (type === 'linear' || type === 'log') {
|
|
68
|
+
const num = typeof value === 'number' ? value : Number(value);
|
|
69
|
+
if (!Number.isFinite(num)) return null;
|
|
70
|
+
return (s as ScaleLinear<number, number>)(num);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (type === 'band') {
|
|
74
|
+
const bandScale = s as ScaleBand<string>;
|
|
75
|
+
const pos = bandScale(String(value));
|
|
76
|
+
if (pos === undefined) return null;
|
|
77
|
+
return pos + (bandScale.bandwidth?.() ?? 0) / 2;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// point or ordinal: try calling it directly
|
|
81
|
+
try {
|
|
82
|
+
return (s as (v: string) => number)(String(value));
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function makeAnnotationLabelStyle(
|
|
89
|
+
fontSize?: number,
|
|
90
|
+
fontWeight?: number,
|
|
91
|
+
fill?: string,
|
|
92
|
+
isDark?: boolean,
|
|
93
|
+
): TextStyle {
|
|
94
|
+
const defaultFill = isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL;
|
|
95
|
+
return {
|
|
96
|
+
fontFamily: 'Inter, system-ui, sans-serif',
|
|
97
|
+
fontSize: fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE,
|
|
98
|
+
fontWeight: fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT,
|
|
99
|
+
fill: fill ?? defaultFill,
|
|
100
|
+
lineHeight: 1.3,
|
|
101
|
+
textAnchor: 'start',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Apply anchor direction to compute label offset from data point.
|
|
107
|
+
* Returns { dx, dy } pixel offsets.
|
|
108
|
+
*/
|
|
109
|
+
function computeAnchorOffset(
|
|
110
|
+
anchor: AnnotationAnchor | undefined,
|
|
111
|
+
_px: number,
|
|
112
|
+
py: number,
|
|
113
|
+
chartArea: Rect,
|
|
114
|
+
): { dx: number; dy: number } {
|
|
115
|
+
if (!anchor || anchor === 'auto') {
|
|
116
|
+
// Auto: place above if in the lower half, below if upper half
|
|
117
|
+
const isUpperHalf = py < chartArea.y + chartArea.height / 2;
|
|
118
|
+
return isUpperHalf
|
|
119
|
+
? { dx: ANCHOR_OFFSET, dy: ANCHOR_OFFSET } // below-right
|
|
120
|
+
: { dx: ANCHOR_OFFSET, dy: -ANCHOR_OFFSET }; // above-right
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
switch (anchor) {
|
|
124
|
+
case 'top':
|
|
125
|
+
return { dx: 0, dy: -ANCHOR_OFFSET };
|
|
126
|
+
case 'bottom':
|
|
127
|
+
return { dx: 0, dy: ANCHOR_OFFSET };
|
|
128
|
+
case 'left':
|
|
129
|
+
return { dx: -ANCHOR_OFFSET, dy: 0 };
|
|
130
|
+
case 'right':
|
|
131
|
+
return { dx: ANCHOR_OFFSET, dy: 0 };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Apply user offset on top of computed anchor offset. */
|
|
136
|
+
function applyOffset(
|
|
137
|
+
base: { dx: number; dy: number },
|
|
138
|
+
offset: AnnotationOffset | undefined,
|
|
139
|
+
): { dx: number; dy: number } {
|
|
140
|
+
if (!offset) return base;
|
|
141
|
+
return {
|
|
142
|
+
dx: base.dx + (offset.dx ?? 0),
|
|
143
|
+
dy: base.dy + (offset.dy ?? 0),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Text annotation
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
function resolveTextAnnotation(
|
|
152
|
+
annotation: TextAnnotation,
|
|
153
|
+
scales: ResolvedScales,
|
|
154
|
+
chartArea: Rect,
|
|
155
|
+
isDark: boolean,
|
|
156
|
+
): ResolvedAnnotation | null {
|
|
157
|
+
const px = resolvePosition(annotation.x, scales.x);
|
|
158
|
+
const py = resolvePosition(annotation.y, scales.y);
|
|
159
|
+
|
|
160
|
+
if (px === null || py === null) return null;
|
|
161
|
+
|
|
162
|
+
const defaultTextFill = isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL;
|
|
163
|
+
const labelStyle = makeAnnotationLabelStyle(
|
|
164
|
+
annotation.fontSize,
|
|
165
|
+
annotation.fontWeight,
|
|
166
|
+
annotation.fill ?? defaultTextFill,
|
|
167
|
+
isDark,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// Compute position from anchor direction + user offset
|
|
171
|
+
const anchorDelta = computeAnchorOffset(annotation.anchor, px, py, chartArea);
|
|
172
|
+
const finalDelta = applyOffset(anchorDelta, annotation.offset);
|
|
173
|
+
|
|
174
|
+
const labelX = px + finalDelta.dx;
|
|
175
|
+
const labelY = py + finalDelta.dy;
|
|
176
|
+
|
|
177
|
+
// Connector: draw unless explicitly disabled
|
|
178
|
+
const showConnector = annotation.connector !== false;
|
|
179
|
+
const connectorStyle = annotation.connector === 'curve' ? 'curve' : 'straight';
|
|
180
|
+
|
|
181
|
+
// Compute connector origin based on style and text layout
|
|
182
|
+
const fontSize = annotation.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
|
|
183
|
+
const fontWeight = annotation.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
|
|
184
|
+
const lines = annotation.text.split('\n');
|
|
185
|
+
const lineHeight = 1.3;
|
|
186
|
+
let connectorFromX: number;
|
|
187
|
+
if (connectorStyle === 'curve') {
|
|
188
|
+
// Curved connectors start from the right edge of the text
|
|
189
|
+
connectorFromX = labelX + estimateTextWidth(annotation.text, fontSize, fontWeight);
|
|
190
|
+
} else if (lines.length > 1) {
|
|
191
|
+
// Multi-line text uses text-anchor: middle, so labelX is already the center
|
|
192
|
+
connectorFromX = labelX;
|
|
193
|
+
} else {
|
|
194
|
+
// Straight connectors start from the horizontal center of the text
|
|
195
|
+
connectorFromX = labelX + estimateTextWidth(annotation.text, fontSize, fontWeight) / 2;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Connector from.y sits at the bottom of the text block
|
|
199
|
+
const connectorFromY = labelY + (lines.length - 1) * fontSize * lineHeight + fontSize * 0.3;
|
|
200
|
+
|
|
201
|
+
// Apply user-provided connector endpoint offsets
|
|
202
|
+
const baseFrom = { x: connectorFromX, y: connectorFromY };
|
|
203
|
+
const baseTo = { x: px, y: py };
|
|
204
|
+
const adjustedFrom = {
|
|
205
|
+
x: baseFrom.x + (annotation.connectorOffset?.from?.dx ?? 0),
|
|
206
|
+
y: baseFrom.y + (annotation.connectorOffset?.from?.dy ?? 0),
|
|
207
|
+
};
|
|
208
|
+
const adjustedToRaw = {
|
|
209
|
+
x: baseTo.x + (annotation.connectorOffset?.to?.dx ?? 0),
|
|
210
|
+
y: baseTo.y + (annotation.connectorOffset?.to?.dy ?? 0),
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// Pull the "to" endpoint back along the connector direction so the
|
|
214
|
+
// line doesn't touch the data point directly (leaves a small gap).
|
|
215
|
+
const GAP = 4;
|
|
216
|
+
const cdx = adjustedToRaw.x - adjustedFrom.x;
|
|
217
|
+
const cdy = adjustedToRaw.y - adjustedFrom.y;
|
|
218
|
+
const dist = Math.sqrt(cdx * cdx + cdy * cdy);
|
|
219
|
+
const adjustedTo =
|
|
220
|
+
dist > GAP * 2
|
|
221
|
+
? { x: adjustedToRaw.x - (cdx / dist) * GAP, y: adjustedToRaw.y - (cdy / dist) * GAP }
|
|
222
|
+
: adjustedToRaw;
|
|
223
|
+
|
|
224
|
+
const label: ResolvedLabel = {
|
|
225
|
+
text: annotation.text,
|
|
226
|
+
x: labelX,
|
|
227
|
+
y: labelY,
|
|
228
|
+
style: labelStyle,
|
|
229
|
+
visible: true,
|
|
230
|
+
connector: showConnector
|
|
231
|
+
? {
|
|
232
|
+
from: adjustedFrom,
|
|
233
|
+
to: adjustedTo,
|
|
234
|
+
stroke: annotation.stroke ?? '#999999',
|
|
235
|
+
style: connectorStyle,
|
|
236
|
+
}
|
|
237
|
+
: undefined,
|
|
238
|
+
background: annotation.background,
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
type: 'text',
|
|
243
|
+
label,
|
|
244
|
+
stroke: annotation.stroke,
|
|
245
|
+
fill: annotation.fill,
|
|
246
|
+
opacity: annotation.opacity,
|
|
247
|
+
zIndex: annotation.zIndex,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Range annotation
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
function resolveRangeAnnotation(
|
|
256
|
+
annotation: RangeAnnotation,
|
|
257
|
+
scales: ResolvedScales,
|
|
258
|
+
chartArea: Rect,
|
|
259
|
+
isDark: boolean,
|
|
260
|
+
): ResolvedAnnotation | null {
|
|
261
|
+
let x = chartArea.x;
|
|
262
|
+
let y = chartArea.y;
|
|
263
|
+
let width = chartArea.width;
|
|
264
|
+
let height = chartArea.height;
|
|
265
|
+
|
|
266
|
+
// X-range (vertical band)
|
|
267
|
+
if (annotation.x1 !== undefined && annotation.x2 !== undefined) {
|
|
268
|
+
const x1px = resolvePosition(annotation.x1, scales.x);
|
|
269
|
+
const x2px = resolvePosition(annotation.x2, scales.x);
|
|
270
|
+
if (x1px === null || x2px === null) return null;
|
|
271
|
+
|
|
272
|
+
x = Math.min(x1px, x2px);
|
|
273
|
+
width = Math.abs(x2px - x1px);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Y-range (horizontal band)
|
|
277
|
+
if (annotation.y1 !== undefined && annotation.y2 !== undefined) {
|
|
278
|
+
const y1px = resolvePosition(annotation.y1, scales.y);
|
|
279
|
+
const y2px = resolvePosition(annotation.y2, scales.y);
|
|
280
|
+
if (y1px === null || y2px === null) return null;
|
|
281
|
+
|
|
282
|
+
y = Math.min(y1px, y2px);
|
|
283
|
+
height = Math.abs(y2px - y1px);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const rect: Rect = { x, y, width, height };
|
|
287
|
+
|
|
288
|
+
// Label at the top-left of the range, with optional offset
|
|
289
|
+
let label: ResolvedLabel | undefined;
|
|
290
|
+
if (annotation.label) {
|
|
291
|
+
const baseDx = 4;
|
|
292
|
+
const baseDy = 14;
|
|
293
|
+
const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset);
|
|
294
|
+
|
|
295
|
+
label = {
|
|
296
|
+
text: annotation.label,
|
|
297
|
+
x: x + labelDelta.dx,
|
|
298
|
+
y: y + labelDelta.dy,
|
|
299
|
+
style: makeAnnotationLabelStyle(11, 500, undefined, isDark),
|
|
300
|
+
visible: true,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// In dark mode, boost range opacity slightly for better visibility
|
|
305
|
+
const defaultOpacity = isDark ? 0.2 : DEFAULT_RANGE_OPACITY;
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
type: 'range',
|
|
309
|
+
rect,
|
|
310
|
+
label,
|
|
311
|
+
fill: annotation.fill ?? DEFAULT_RANGE_FILL,
|
|
312
|
+
opacity: annotation.opacity ?? defaultOpacity,
|
|
313
|
+
stroke: annotation.stroke,
|
|
314
|
+
zIndex: annotation.zIndex,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// Reference line annotation
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
function resolveRefLineAnnotation(
|
|
323
|
+
annotation: RefLineAnnotation,
|
|
324
|
+
scales: ResolvedScales,
|
|
325
|
+
chartArea: Rect,
|
|
326
|
+
isDark: boolean,
|
|
327
|
+
): ResolvedAnnotation | null {
|
|
328
|
+
let start: Point;
|
|
329
|
+
let end: Point;
|
|
330
|
+
|
|
331
|
+
if (annotation.y !== undefined) {
|
|
332
|
+
// Horizontal reference line
|
|
333
|
+
const yPx = resolvePosition(annotation.y, scales.y);
|
|
334
|
+
if (yPx === null) return null;
|
|
335
|
+
|
|
336
|
+
start = { x: chartArea.x, y: yPx };
|
|
337
|
+
end = { x: chartArea.x + chartArea.width, y: yPx };
|
|
338
|
+
} else if (annotation.x !== undefined) {
|
|
339
|
+
// Vertical reference line
|
|
340
|
+
const xPx = resolvePosition(annotation.x, scales.x);
|
|
341
|
+
if (xPx === null) return null;
|
|
342
|
+
|
|
343
|
+
start = { x: xPx, y: chartArea.y };
|
|
344
|
+
end = { x: xPx, y: chartArea.y + chartArea.height };
|
|
345
|
+
} else {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Determine dash pattern from style
|
|
350
|
+
let strokeDasharray: string | undefined;
|
|
351
|
+
if (annotation.style === 'dashed' || annotation.style === undefined) {
|
|
352
|
+
strokeDasharray = DEFAULT_REFLINE_DASH;
|
|
353
|
+
} else if (annotation.style === 'dotted') {
|
|
354
|
+
strokeDasharray = '2 2';
|
|
355
|
+
}
|
|
356
|
+
// 'solid' gets no dasharray
|
|
357
|
+
|
|
358
|
+
// Label at the right end for horizontal, top end for vertical, with optional offset.
|
|
359
|
+
// Horizontal refline labels use text-anchor 'end' so text stays inside the chart.
|
|
360
|
+
let label: ResolvedLabel | undefined;
|
|
361
|
+
if (annotation.label) {
|
|
362
|
+
const isHorizontal = annotation.y !== undefined;
|
|
363
|
+
const baseDx = isHorizontal ? -4 : 4;
|
|
364
|
+
const baseDy = -4;
|
|
365
|
+
const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset);
|
|
366
|
+
|
|
367
|
+
const defaultStroke = isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE;
|
|
368
|
+
const style = makeAnnotationLabelStyle(11, 400, annotation.stroke ?? defaultStroke, isDark);
|
|
369
|
+
if (isHorizontal) {
|
|
370
|
+
style.textAnchor = 'end';
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
label = {
|
|
374
|
+
text: annotation.label,
|
|
375
|
+
x: (isHorizontal ? end.x : start.x) + labelDelta.dx,
|
|
376
|
+
y: (isHorizontal ? end.y : start.y) + labelDelta.dy,
|
|
377
|
+
style,
|
|
378
|
+
visible: true,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const defaultStroke = isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE;
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
type: 'refline',
|
|
386
|
+
line: { start, end },
|
|
387
|
+
label,
|
|
388
|
+
stroke: annotation.stroke ?? defaultStroke,
|
|
389
|
+
strokeDasharray,
|
|
390
|
+
strokeWidth: annotation.strokeWidth ?? 1,
|
|
391
|
+
zIndex: annotation.zIndex,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// Public API
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
// Collision avoidance
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
/** Estimate the bounding box of an annotation label. */
|
|
404
|
+
function estimateLabelBounds(label: ResolvedLabel): Rect {
|
|
405
|
+
const lines = label.text.split('\n');
|
|
406
|
+
const fontSize = label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
|
|
407
|
+
const fontWeight = label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
|
|
408
|
+
const lineHeight = label.style.lineHeight ?? 1.3;
|
|
409
|
+
|
|
410
|
+
const maxWidth = Math.max(...lines.map((line) => estimateTextWidth(line, fontSize, fontWeight)));
|
|
411
|
+
const totalHeight = lines.length * fontSize * lineHeight;
|
|
412
|
+
|
|
413
|
+
// Multi-line text is rendered with text-anchor: middle by the SVG renderer,
|
|
414
|
+
// so the text is centered at label.x. Single-line uses the style's textAnchor.
|
|
415
|
+
const isMultiLine = lines.length > 1;
|
|
416
|
+
const anchorX = isMultiLine ? label.x - maxWidth / 2 : label.x;
|
|
417
|
+
|
|
418
|
+
return {
|
|
419
|
+
x: anchorX,
|
|
420
|
+
y: label.y - fontSize,
|
|
421
|
+
width: maxWidth,
|
|
422
|
+
height: totalHeight,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Check if two rects overlap. */
|
|
427
|
+
function rectsOverlap(a: Rect, b: Rect): boolean {
|
|
428
|
+
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;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Padding between annotation and obstacle when nudging. */
|
|
432
|
+
const NUDGE_PADDING = 6;
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Try to reposition a text annotation to avoid overlapping with obstacle rects
|
|
436
|
+
* (legend bounds, etc.). First tries standard anchor alternatives, then
|
|
437
|
+
* calculates specific offsets needed to clear obstacles. Returns true if moved.
|
|
438
|
+
*/
|
|
439
|
+
function nudgeAnnotationFromObstacles(
|
|
440
|
+
annotation: ResolvedAnnotation,
|
|
441
|
+
originalAnnotation: TextAnnotation,
|
|
442
|
+
scales: ResolvedScales,
|
|
443
|
+
chartArea: Rect,
|
|
444
|
+
obstacles: Rect[],
|
|
445
|
+
): boolean {
|
|
446
|
+
if (annotation.type !== 'text' || !annotation.label) return false;
|
|
447
|
+
|
|
448
|
+
const labelBounds = estimateLabelBounds(annotation.label);
|
|
449
|
+
const collidingObs = obstacles.filter(
|
|
450
|
+
(obs) => obs.width > 0 && obs.height > 0 && rectsOverlap(labelBounds, obs),
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
if (collidingObs.length === 0) return false;
|
|
454
|
+
|
|
455
|
+
// Resolve the data point pixel position for offset calculations
|
|
456
|
+
const px = resolvePosition(originalAnnotation.x, scales.x);
|
|
457
|
+
const py = resolvePosition(originalAnnotation.y, scales.y);
|
|
458
|
+
if (px === null || py === null) return false;
|
|
459
|
+
|
|
460
|
+
// Generate candidate positions: calculated offsets to clear each obstacle
|
|
461
|
+
const candidates: { dx: number; dy: number; distance: number }[] = [];
|
|
462
|
+
const fontSize = labelBounds.height / Math.max(1, annotation.label.text.split('\n').length);
|
|
463
|
+
|
|
464
|
+
for (const obs of collidingObs) {
|
|
465
|
+
// Below obstacle: shift label so its top edge clears the obstacle bottom
|
|
466
|
+
const currentLabelTop = labelBounds.y;
|
|
467
|
+
const targetLabelTop = obs.y + obs.height + NUDGE_PADDING;
|
|
468
|
+
const belowDy = targetLabelTop - currentLabelTop;
|
|
469
|
+
candidates.push({ dx: 0, dy: belowDy, distance: Math.abs(belowDy) });
|
|
470
|
+
|
|
471
|
+
// Above obstacle: shift label so its bottom edge clears the obstacle top
|
|
472
|
+
const currentLabelBottom = labelBounds.y + labelBounds.height;
|
|
473
|
+
const targetLabelBottom = obs.y - NUDGE_PADDING;
|
|
474
|
+
const aboveDy = targetLabelBottom - currentLabelBottom;
|
|
475
|
+
candidates.push({ dx: 0, dy: aboveDy, distance: Math.abs(aboveDy) });
|
|
476
|
+
|
|
477
|
+
// Left of obstacle: shift label so its right edge clears the obstacle left
|
|
478
|
+
const currentLabelRight = labelBounds.x + labelBounds.width;
|
|
479
|
+
const targetLabelRight = obs.x - NUDGE_PADDING;
|
|
480
|
+
const leftDx = targetLabelRight - currentLabelRight;
|
|
481
|
+
candidates.push({ dx: leftDx, dy: 0, distance: Math.abs(leftDx) });
|
|
482
|
+
|
|
483
|
+
// Right of obstacle: shift label so its left edge clears the obstacle right
|
|
484
|
+
const currentLabelLeft = labelBounds.x;
|
|
485
|
+
const targetLabelLeft = obs.x + obs.width + NUDGE_PADDING;
|
|
486
|
+
const rightDx = targetLabelLeft - currentLabelLeft;
|
|
487
|
+
candidates.push({ dx: rightDx, dy: 0, distance: Math.abs(rightDx) });
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Sort candidates by distance (prefer smallest movement)
|
|
491
|
+
candidates.sort((a, b) => a.distance - b.distance);
|
|
492
|
+
|
|
493
|
+
for (const { dx, dy } of candidates) {
|
|
494
|
+
const candidateLabel: ResolvedLabel = {
|
|
495
|
+
...annotation.label,
|
|
496
|
+
x: annotation.label.x + dx,
|
|
497
|
+
y: annotation.label.y + dy,
|
|
498
|
+
connector: annotation.label.connector
|
|
499
|
+
? {
|
|
500
|
+
...annotation.label.connector,
|
|
501
|
+
from: {
|
|
502
|
+
x: annotation.label.connector.from.x + dx,
|
|
503
|
+
y: annotation.label.connector.from.y + dy,
|
|
504
|
+
},
|
|
505
|
+
}
|
|
506
|
+
: undefined,
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const candidateBounds = estimateLabelBounds(candidateLabel);
|
|
510
|
+
|
|
511
|
+
// Check no collisions with any obstacle
|
|
512
|
+
const stillCollides = obstacles.some(
|
|
513
|
+
(obs) => obs.width > 0 && obs.height > 0 && rectsOverlap(candidateBounds, obs),
|
|
514
|
+
);
|
|
515
|
+
if (stillCollides) continue;
|
|
516
|
+
|
|
517
|
+
// Annotations render outside the clip path, so they can extend into margins.
|
|
518
|
+
// Only check that the label center is reasonably within the chart and that
|
|
519
|
+
// the text doesn't go completely off-screen.
|
|
520
|
+
const labelCenterX = candidateBounds.x + candidateBounds.width / 2;
|
|
521
|
+
const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
|
|
522
|
+
const inBounds =
|
|
523
|
+
labelCenterX >= chartArea.x &&
|
|
524
|
+
labelCenterX <= chartArea.x + chartArea.width + 100 &&
|
|
525
|
+
labelCenterY >= chartArea.y - fontSize &&
|
|
526
|
+
labelCenterY <= chartArea.y + chartArea.height + fontSize;
|
|
527
|
+
|
|
528
|
+
if (inBounds) {
|
|
529
|
+
// When nudged vertically (directly above/below the data), use a caret
|
|
530
|
+
// instead of a connector line for a cleaner editorial look.
|
|
531
|
+
if (candidateLabel.connector && dx === 0 && dy !== 0) {
|
|
532
|
+
candidateLabel.connector = {
|
|
533
|
+
...candidateLabel.connector,
|
|
534
|
+
style: 'caret',
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
annotation.label = candidateLabel;
|
|
538
|
+
return true;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ---------------------------------------------------------------------------
|
|
546
|
+
// Public API
|
|
547
|
+
// ---------------------------------------------------------------------------
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Compute resolved annotations from spec annotations using the resolved scales.
|
|
551
|
+
*
|
|
552
|
+
* Converts data-coordinate annotations to pixel-positioned ResolvedAnnotation
|
|
553
|
+
* objects. Supports offset, anchor, connector, and zIndex. At compact
|
|
554
|
+
* breakpoints, annotations are hidden (strategy says "tooltip-only").
|
|
555
|
+
*
|
|
556
|
+
* When obstacle rects are provided (e.g. legend bounds), text annotations
|
|
557
|
+
* that overlap with them are automatically repositioned using alternate
|
|
558
|
+
* anchor directions.
|
|
559
|
+
*/
|
|
560
|
+
export function computeAnnotations(
|
|
561
|
+
spec: NormalizedChartSpec,
|
|
562
|
+
scales: ResolvedScales,
|
|
563
|
+
chartArea: Rect,
|
|
564
|
+
strategy: LayoutStrategy,
|
|
565
|
+
isDark = false,
|
|
566
|
+
obstacles: Rect[] = [],
|
|
567
|
+
): ResolvedAnnotation[] {
|
|
568
|
+
// At compact breakpoints, skip all annotations
|
|
569
|
+
if (strategy.annotationPosition === 'tooltip-only') {
|
|
570
|
+
return [];
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const annotations: ResolvedAnnotation[] = [];
|
|
574
|
+
|
|
575
|
+
for (const annotation of spec.annotations) {
|
|
576
|
+
let resolved: ResolvedAnnotation | null = null;
|
|
577
|
+
|
|
578
|
+
switch (annotation.type) {
|
|
579
|
+
case 'text':
|
|
580
|
+
resolved = resolveTextAnnotation(annotation, scales, chartArea, isDark);
|
|
581
|
+
break;
|
|
582
|
+
case 'range':
|
|
583
|
+
resolved = resolveRangeAnnotation(annotation, scales, chartArea, isDark);
|
|
584
|
+
break;
|
|
585
|
+
case 'refline':
|
|
586
|
+
resolved = resolveRefLineAnnotation(annotation, scales, chartArea, isDark);
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (resolved) {
|
|
591
|
+
// For text annotations, check for collisions with obstacles and nudge if needed
|
|
592
|
+
if (annotation.type === 'text' && obstacles.length > 0) {
|
|
593
|
+
nudgeAnnotationFromObstacles(resolved, annotation, scales, chartArea, obstacles);
|
|
594
|
+
}
|
|
595
|
+
annotations.push(resolved);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Sort by zIndex (lower first, undefined treated as 0)
|
|
600
|
+
annotations.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0));
|
|
601
|
+
|
|
602
|
+
return annotations;
|
|
603
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { LayoutStrategy, Mark, Rect } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
4
|
+
import type { ResolvedScales } from '../../layout/scales';
|
|
5
|
+
import { clearRenderers, getChartRenderer, registerChartRenderer } from '../registry';
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Fixtures
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A minimal renderer that returns a single rect mark.
|
|
13
|
+
* Used to verify the registry lifecycle without needing real chart logic.
|
|
14
|
+
*/
|
|
15
|
+
function stubRenderer(
|
|
16
|
+
_spec: NormalizedChartSpec,
|
|
17
|
+
_scales: ResolvedScales,
|
|
18
|
+
_chartArea: Rect,
|
|
19
|
+
_strategy: LayoutStrategy,
|
|
20
|
+
): Mark[] {
|
|
21
|
+
return [
|
|
22
|
+
{
|
|
23
|
+
type: 'rect',
|
|
24
|
+
x: 10,
|
|
25
|
+
y: 20,
|
|
26
|
+
width: 100,
|
|
27
|
+
height: 50,
|
|
28
|
+
fill: '#ff0000',
|
|
29
|
+
data: {},
|
|
30
|
+
aria: { label: 'stub mark' },
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Tests
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
describe('chart renderer registry', () => {
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
clearRenderers();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns undefined for an unregistered chart type', () => {
|
|
45
|
+
expect(getChartRenderer('nonexistent')).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('registers and retrieves a renderer by type', () => {
|
|
49
|
+
registerChartRenderer('test-type', stubRenderer);
|
|
50
|
+
|
|
51
|
+
const retrieved = getChartRenderer('test-type');
|
|
52
|
+
expect(retrieved).toBe(stubRenderer);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('registered renderer produces marks when called', () => {
|
|
56
|
+
registerChartRenderer('test-type', stubRenderer);
|
|
57
|
+
|
|
58
|
+
const renderer = getChartRenderer('test-type')!;
|
|
59
|
+
const marks = renderer(
|
|
60
|
+
{} as NormalizedChartSpec,
|
|
61
|
+
{} as ResolvedScales,
|
|
62
|
+
{ x: 0, y: 0, width: 600, height: 400 },
|
|
63
|
+
{} as LayoutStrategy,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(marks).toHaveLength(1);
|
|
67
|
+
expect(marks[0].type).toBe('rect');
|
|
68
|
+
if (marks[0].type === 'rect') {
|
|
69
|
+
expect(marks[0].width).toBe(100);
|
|
70
|
+
expect(marks[0].fill).toBe('#ff0000');
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('overwrites a previously registered renderer for the same type', () => {
|
|
75
|
+
const secondRenderer = () => [] as Mark[];
|
|
76
|
+
|
|
77
|
+
registerChartRenderer('test-type', stubRenderer);
|
|
78
|
+
registerChartRenderer('test-type', secondRenderer);
|
|
79
|
+
|
|
80
|
+
expect(getChartRenderer('test-type')).toBe(secondRenderer);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('clearRenderers removes all registered renderers', () => {
|
|
84
|
+
registerChartRenderer('type-a', stubRenderer);
|
|
85
|
+
registerChartRenderer('type-b', stubRenderer);
|
|
86
|
+
|
|
87
|
+
// Both should be registered
|
|
88
|
+
expect(getChartRenderer('type-a')).toBe(stubRenderer);
|
|
89
|
+
expect(getChartRenderer('type-b')).toBe(stubRenderer);
|
|
90
|
+
|
|
91
|
+
clearRenderers();
|
|
92
|
+
|
|
93
|
+
// Both should be gone
|
|
94
|
+
expect(getChartRenderer('type-a')).toBeUndefined();
|
|
95
|
+
expect(getChartRenderer('type-b')).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('multiple types can be registered independently', () => {
|
|
99
|
+
const rendererA = () => [] as Mark[];
|
|
100
|
+
const rendererB = () => [] as Mark[];
|
|
101
|
+
|
|
102
|
+
registerChartRenderer('type-a', rendererA);
|
|
103
|
+
registerChartRenderer('type-b', rendererB);
|
|
104
|
+
|
|
105
|
+
expect(getChartRenderer('type-a')).toBe(rendererA);
|
|
106
|
+
expect(getChartRenderer('type-b')).toBe(rendererB);
|
|
107
|
+
// Unregistered type still returns undefined
|
|
108
|
+
expect(getChartRenderer('type-c')).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
});
|