@opendata-ai/openchart-engine 7.11.0 → 8.0.0-rc.2
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 +123 -14
- package/dist/index.js +16166 -10027
- package/dist/index.js.map +1 -1
- package/package.json +11 -4
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +170 -56
- package/src/__tests__/annotation-behavior.test.ts +721 -0
- package/src/__tests__/axes.test.ts +50 -3
- package/src/__tests__/compile-chart.test.ts +30 -0
- package/src/__tests__/compile-footnotes.test.ts +277 -0
- package/src/__tests__/compile-layer.test.ts +120 -1
- package/src/__tests__/helpers/invariants.ts +54 -20
- package/src/__tests__/known-bugs.test.ts +455 -2
- package/src/__tests__/legend.test.ts +69 -4
- package/src/__tests__/ranking-recipes.test.ts +260 -0
- package/src/__tests__/scales.test.ts +9 -3
- package/src/__tests__/series-search.test.ts +92 -0
- package/src/__tests__/spec-sugar.test.ts +801 -0
- package/src/__tests__/you-draw-it.test.ts +160 -0
- package/src/annotations/__tests__/compute.test.ts +207 -92
- package/src/annotations/__tests__/geometry.test.ts +66 -0
- package/src/annotations/__tests__/resolve-text.test.ts +527 -27
- package/src/annotations/__tests__/rich-text.test.ts +91 -0
- package/src/annotations/__tests__/thinning.test.ts +148 -0
- package/src/annotations/collisions.ts +130 -84
- package/src/annotations/compute.ts +83 -85
- package/src/annotations/constants.ts +116 -6
- package/src/annotations/geometry.ts +222 -77
- package/src/annotations/move.ts +135 -0
- package/src/annotations/placement.ts +62 -14
- package/src/annotations/resolve-range.ts +2 -0
- package/src/annotations/resolve-refline.ts +2 -0
- package/src/annotations/resolve-text.ts +312 -111
- package/src/annotations/rich-text.ts +65 -0
- package/src/annotations/thinning.ts +84 -10
- package/src/barlist/compile-barlist.ts +16 -6
- package/src/charts/_shared/format-label-value.ts +9 -8
- package/src/charts/bar/__tests__/compute.test.ts +57 -10
- package/src/charts/bar/__tests__/labels.test.ts +5 -4
- package/src/charts/bar/compute.ts +2 -6
- package/src/charts/bar/index.ts +8 -1
- package/src/charts/bar/labels.ts +3 -3
- package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
- package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
- package/src/charts/beeswarm/compute.ts +203 -0
- package/src/charts/beeswarm/dodge.ts +155 -0
- package/src/charts/beeswarm/index.ts +32 -0
- package/src/charts/builtin.ts +12 -1
- package/src/charts/calendar/__tests__/compute.test.ts +417 -0
- package/src/charts/calendar/compute.ts +319 -0
- package/src/charts/calendar/index.ts +32 -0
- package/src/charts/column/__tests__/compute.test.ts +21 -7
- package/src/charts/column/__tests__/labels.test.ts +76 -1
- package/src/charts/column/compute.ts +2 -6
- package/src/charts/column/index.ts +10 -2
- package/src/charts/column/labels.ts +95 -21
- package/src/charts/dot/index.ts +8 -1
- package/src/charts/dot/labels.ts +3 -3
- package/src/charts/line/__tests__/compute.test.ts +15 -19
- package/src/charts/line/area.ts +8 -23
- package/src/charts/line/compute.ts +32 -2
- package/src/charts/parliament/__tests__/compute.test.ts +411 -0
- package/src/charts/parliament/compute.ts +313 -0
- package/src/charts/parliament/index.ts +32 -0
- package/src/charts/pie/__tests__/compute.test.ts +103 -0
- package/src/charts/pie/compute.ts +63 -8
- package/src/charts/pie/labels.ts +1 -0
- package/src/charts/range/__tests__/compute.test.ts +486 -0
- package/src/charts/range/compute.ts +273 -0
- package/src/charts/range/index.ts +82 -0
- package/src/charts/range/labels.ts +169 -0
- package/src/charts/rect/__tests__/compute.test.ts +120 -0
- package/src/charts/rect/index.ts +127 -0
- package/src/charts/scatter/compute.ts +7 -21
- package/src/charts/text/__tests__/compute.test.ts +307 -0
- package/src/charts/text/index.ts +44 -7
- package/src/charts/tick/__tests__/compute.test.ts +75 -0
- package/src/charts/tick/index.ts +7 -4
- package/src/charts/utils.ts +5 -3
- package/src/charts/waffle/__tests__/compute.test.ts +305 -0
- package/src/charts/waffle/compute.ts +197 -0
- package/src/charts/waffle/index.ts +31 -0
- package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
- package/src/compile/__tests__/data-clip.test.ts +68 -0
- package/src/compile/__tests__/fill-patterns.test.ts +154 -0
- package/src/compile/__tests__/size-scale.test.ts +108 -0
- package/src/compile/color-scale-range.ts +54 -33
- package/src/compile/data-clip.ts +21 -6
- package/src/compile/fill-patterns.ts +74 -0
- package/src/compile/layer.ts +117 -21
- package/src/compile/size-scale.ts +139 -0
- package/src/compile/spec-sugar.ts +758 -0
- package/src/compile/you-draw-it.ts +123 -0
- package/src/compile.ts +310 -103
- package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
- package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
- package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
- package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
- package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
- package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
- package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
- package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
- package/src/compiler/__tests__/keys.test.ts +3 -9
- package/src/compiler/__tests__/normalize.test.ts +65 -1
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
- package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
- package/src/compiler/__tests__/validate.test.ts +156 -0
- package/src/compiler/__tests__/vl-parity.test.ts +68 -0
- package/src/compiler/a11y-warnings.ts +110 -0
- package/src/compiler/keys.ts +5 -4
- package/src/compiler/normalize.ts +150 -7
- package/src/compiler/types.ts +11 -0
- package/src/compiler/validate.ts +664 -18
- package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
- package/src/endpoint-labels/compute.ts +40 -12
- package/src/endpoint-labels/format.ts +45 -15
- package/src/endpoint-labels/predict.ts +50 -36
- package/src/format/__tests__/field-format.test.ts +164 -0
- package/src/format/field-format.ts +27 -0
- package/src/geo/__tests__/compile-map.test.ts +621 -0
- package/src/geo/compile-map.ts +856 -0
- package/src/geo/join.ts +115 -0
- package/src/geo/projections.ts +45 -0
- package/src/geo/types.ts +24 -0
- package/src/graphs/__tests__/compile-graph.test.ts +25 -0
- package/src/graphs/compile-graph.ts +27 -18
- package/src/index.ts +20 -2
- package/src/layout/axes/ticks.ts +59 -10
- package/src/layout/axes.ts +35 -6
- package/src/layout/dimensions.ts +139 -83
- package/src/layout/facet.ts +14 -3
- package/src/layout/plan.ts +83 -33
- package/src/layout/scales.ts +132 -15
- package/src/legend/__tests__/continuous.test.ts +318 -0
- package/src/legend/__tests__/size.test.ts +348 -0
- package/src/legend/__tests__/suppression.test.ts +17 -1
- package/src/legend/compute.ts +119 -13
- package/src/legend/continuous.ts +327 -0
- package/src/legend/size.ts +407 -0
- package/src/legend/suppression.ts +8 -7
- package/src/sankey/compile-sankey.ts +31 -19
- package/src/tables/format-cells.ts +16 -7
- package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
- package/src/tilemap/compile-tilemap.ts +24 -7
- package/src/tooltips/__tests__/compute.test.ts +2 -2
- package/src/tooltips/compute.ts +422 -41
- package/src/transforms/__tests__/conditional.test.ts +18 -0
- package/src/transforms/aggregate.ts +2 -1
|
@@ -21,6 +21,7 @@ export function resolveRefLineAnnotation(
|
|
|
21
21
|
scales: ResolvedScales,
|
|
22
22
|
chartArea: Rect,
|
|
23
23
|
isDark: boolean,
|
|
24
|
+
fontFamily?: string,
|
|
24
25
|
): ResolvedAnnotation | null {
|
|
25
26
|
let start: Point;
|
|
26
27
|
let end: Point;
|
|
@@ -128,6 +129,7 @@ export function resolveRefLineAnnotation(
|
|
|
128
129
|
annotation.fontWeight ?? 400,
|
|
129
130
|
annotation.stroke ?? defaultStroke,
|
|
130
131
|
isDark,
|
|
132
|
+
fontFamily,
|
|
131
133
|
);
|
|
132
134
|
style.textAnchor = textAnchor;
|
|
133
135
|
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type {
|
|
7
|
+
ConnectorConfig,
|
|
8
|
+
ConnectorType,
|
|
7
9
|
Rect,
|
|
8
10
|
ResolvedAnnotation,
|
|
9
11
|
ResolvedLabel,
|
|
@@ -12,7 +14,10 @@ import type {
|
|
|
12
14
|
} from '@opendata-ai/openchart-core';
|
|
13
15
|
import type { ResolvedScales } from '../layout/scales';
|
|
14
16
|
import {
|
|
17
|
+
connectorIsDrawable,
|
|
18
|
+
DARK_CONNECTOR_STROKE,
|
|
15
19
|
DARK_DOT_FILL,
|
|
20
|
+
DARK_LABEL_BACKGROUND,
|
|
16
21
|
DARK_MUTED_TEXT_FILL,
|
|
17
22
|
DARK_TEXT_FILL,
|
|
18
23
|
DEFAULT_ANNOTATION_FONT_SIZE,
|
|
@@ -20,39 +25,131 @@ import {
|
|
|
20
25
|
DEFAULT_DOT_RADIUS,
|
|
21
26
|
DEFAULT_DOT_STROKE_WIDTH,
|
|
22
27
|
DEFAULT_LINE_HEIGHT,
|
|
28
|
+
DROP_LINE_TOP_GAP,
|
|
29
|
+
FALLBACK_FONT_FAMILY,
|
|
30
|
+
LEDE_FONT_WEIGHT,
|
|
31
|
+
LIGHT_CONNECTOR_STROKE,
|
|
23
32
|
LIGHT_DOT_FILL,
|
|
33
|
+
LIGHT_LABEL_BACKGROUND,
|
|
24
34
|
LIGHT_MUTED_TEXT_FILL,
|
|
25
35
|
LIGHT_TEXT_FILL,
|
|
26
|
-
|
|
36
|
+
SUBTITLE_FONT_WEIGHT,
|
|
27
37
|
SUBTITLE_GAP,
|
|
38
|
+
subtitleBaselineY,
|
|
39
|
+
subtitleFontSize,
|
|
28
40
|
} from './constants';
|
|
29
41
|
import {
|
|
30
42
|
type AnnotationMeasureTextFn,
|
|
31
43
|
applyOffset,
|
|
32
44
|
computeAnchorOffset,
|
|
33
|
-
computeConnectorOrigin,
|
|
34
45
|
computeTextBlockBounds,
|
|
46
|
+
connectorExit,
|
|
47
|
+
connectorPullbackGap,
|
|
35
48
|
heuristicMeasure,
|
|
49
|
+
measureRichLine,
|
|
36
50
|
unionRects,
|
|
37
51
|
} from './geometry';
|
|
38
52
|
import { resolvePosition } from './position';
|
|
39
53
|
|
|
54
|
+
/** The resolved endpoint marker on a text annotation. */
|
|
55
|
+
type ResolvedDot = NonNullable<ResolvedAnnotation['dot']>;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Two connector voices. An arrowed callout is the emphasis gesture, so it takes
|
|
59
|
+
* the label's text ink and reads as one stroke with the words. Everything else
|
|
60
|
+
* (plain leaders, drop-lines) is quiet infrastructure: a gray hairline.
|
|
61
|
+
*
|
|
62
|
+
* The marker inherits the same color, so leader and dot read as one system.
|
|
63
|
+
*/
|
|
64
|
+
function defaultConnectorStroke(hasArrow: boolean, isDark: boolean, textInk: string): string {
|
|
65
|
+
if (hasArrow) return textInk;
|
|
66
|
+
return isDark ? DARK_CONNECTOR_STROKE : LIGHT_CONNECTOR_STROKE;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolve the endpoint marker at the data point.
|
|
71
|
+
*
|
|
72
|
+
* A default marker (equivalent to `dot: true`) appears whenever a connector is
|
|
73
|
+
* enabled with no arrowhead: the leader stops short and the marker terminates
|
|
74
|
+
* it. An arrowhead is already a terminator, so it gets no marker unless the
|
|
75
|
+
* author asks for one. `dot: false` always means bare.
|
|
76
|
+
*/
|
|
77
|
+
function resolveDot(
|
|
78
|
+
dotSpec: TextAnnotation['dot'],
|
|
79
|
+
hasConnector: boolean,
|
|
80
|
+
hasArrow: boolean,
|
|
81
|
+
x: number,
|
|
82
|
+
y: number,
|
|
83
|
+
isDark: boolean,
|
|
84
|
+
connectorStroke: string,
|
|
85
|
+
): ResolvedDot | undefined {
|
|
86
|
+
const useDefault = dotSpec === undefined && hasConnector && !hasArrow;
|
|
87
|
+
if (!dotSpec && !useDefault) return undefined;
|
|
88
|
+
|
|
89
|
+
const dotConfig = typeof dotSpec === 'object' ? dotSpec : {};
|
|
90
|
+
return {
|
|
91
|
+
x,
|
|
92
|
+
y,
|
|
93
|
+
radius: dotConfig.radius ?? DEFAULT_DOT_RADIUS,
|
|
94
|
+
fill: dotConfig.fill ?? (isDark ? DARK_DOT_FILL : LIGHT_DOT_FILL),
|
|
95
|
+
stroke: dotConfig.stroke ?? connectorStroke,
|
|
96
|
+
strokeWidth: dotConfig.strokeWidth ?? DEFAULT_DOT_STROKE_WIDTH,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Radius the connector must clear at the data point, marker stroke included. */
|
|
101
|
+
export function markerClearance(dot: ResolvedDot | undefined): number {
|
|
102
|
+
return dot ? dot.radius + dot.strokeWidth / 2 : 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve the connector spec shorthand into a normalized { type, arrow } object.
|
|
107
|
+
* Returns null when the connector is disabled.
|
|
108
|
+
*/
|
|
109
|
+
function resolveConnectorConfig(
|
|
110
|
+
connector: TextAnnotation['connector'],
|
|
111
|
+
): { type: ConnectorType; arrow: boolean } | null {
|
|
112
|
+
if (connector === false) return null;
|
|
113
|
+
|
|
114
|
+
if (typeof connector === 'object' && connector !== null && 'type' in connector) {
|
|
115
|
+
const cfg = connector as ConnectorConfig;
|
|
116
|
+
const arrow = cfg.type === 'drop-line' ? false : (cfg.arrow ?? cfg.type === 'curve');
|
|
117
|
+
return { type: cfg.type, arrow };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (connector === 'curve') return { type: 'curve', arrow: true };
|
|
121
|
+
if (connector === 'drop-line') return { type: 'drop-line', arrow: false };
|
|
122
|
+
|
|
123
|
+
// true, 'straight', or undefined all map to straight with no arrow
|
|
124
|
+
return { type: 'straight', arrow: false };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolve `background` to a concrete color. `true` picks the theme surface so
|
|
129
|
+
* the plate follows light/dark; a string passes through as an explicit override.
|
|
130
|
+
*/
|
|
131
|
+
function resolveLabelBackground(
|
|
132
|
+
background: string | true | undefined,
|
|
133
|
+
isDark: boolean,
|
|
134
|
+
): string | undefined {
|
|
135
|
+
if (background === undefined) return undefined;
|
|
136
|
+
if (background === true) return isDark ? DARK_LABEL_BACKGROUND : LIGHT_LABEL_BACKGROUND;
|
|
137
|
+
return background;
|
|
138
|
+
}
|
|
139
|
+
|
|
40
140
|
/** Horizontal gap between the drop-line and the label text. */
|
|
41
141
|
const DROP_LINE_LABEL_GAP = 8;
|
|
42
|
-
/** Vertical gap between the top of the drop-line and the top of the label box. */
|
|
43
|
-
const DROP_LINE_TOP_GAP = 4;
|
|
44
|
-
/** Vertical gap between the bottom of the drop-line and the data point. */
|
|
45
|
-
const DROP_LINE_BOTTOM_GAP = 4;
|
|
46
142
|
|
|
47
143
|
export function makeAnnotationLabelStyle(
|
|
48
144
|
fontSize?: number,
|
|
49
145
|
fontWeight?: number,
|
|
50
146
|
fill?: string,
|
|
51
147
|
isDark?: boolean,
|
|
148
|
+
fontFamily?: string,
|
|
52
149
|
): TextStyle {
|
|
53
150
|
const defaultFill = isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL;
|
|
54
151
|
return {
|
|
55
|
-
fontFamily:
|
|
152
|
+
fontFamily: fontFamily ?? FALLBACK_FONT_FAMILY,
|
|
56
153
|
fontSize: fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE,
|
|
57
154
|
fontWeight: fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT,
|
|
58
155
|
fill: fill ?? defaultFill,
|
|
@@ -61,12 +158,22 @@ export function makeAnnotationLabelStyle(
|
|
|
61
158
|
};
|
|
62
159
|
}
|
|
63
160
|
|
|
161
|
+
/**
|
|
162
|
+
* The primary text's resolved weight. A subtitle turns the primary line into a
|
|
163
|
+
* lede, so it goes bold unless the author asked for a specific weight.
|
|
164
|
+
*/
|
|
165
|
+
export function resolveLedeFontWeight(annotation: TextAnnotation): number | undefined {
|
|
166
|
+
if (annotation.fontWeight !== undefined) return annotation.fontWeight;
|
|
167
|
+
return annotation.subtitle ? LEDE_FONT_WEIGHT : undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
64
170
|
export function resolveTextAnnotation(
|
|
65
171
|
annotation: TextAnnotation,
|
|
66
172
|
scales: ResolvedScales,
|
|
67
173
|
chartArea: Rect,
|
|
68
174
|
isDark: boolean,
|
|
69
175
|
measure: AnnotationMeasureTextFn = heuristicMeasure,
|
|
176
|
+
fontFamily?: string,
|
|
70
177
|
): ResolvedAnnotation | null {
|
|
71
178
|
const px = resolvePosition(annotation.x, scales.x);
|
|
72
179
|
const py = resolvePosition(annotation.y, scales.y);
|
|
@@ -74,141 +181,106 @@ export function resolveTextAnnotation(
|
|
|
74
181
|
if (px === null || py === null) return null;
|
|
75
182
|
|
|
76
183
|
const defaultTextFill = isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL;
|
|
184
|
+
const labelBackground = resolveLabelBackground(annotation.background, isDark);
|
|
77
185
|
|
|
78
186
|
const labelStyle = makeAnnotationLabelStyle(
|
|
79
187
|
annotation.fontSize,
|
|
80
|
-
annotation
|
|
188
|
+
resolveLedeFontWeight(annotation),
|
|
81
189
|
annotation.fill ?? defaultTextFill,
|
|
82
190
|
isDark,
|
|
191
|
+
fontFamily,
|
|
83
192
|
);
|
|
84
193
|
|
|
194
|
+
// Parse connector spec into { type, arrow } or null (disabled).
|
|
195
|
+
const connectorConfig = resolveConnectorConfig(annotation.connector);
|
|
196
|
+
|
|
85
197
|
// Drop-line connector: vertical line through the data point's x with the
|
|
86
198
|
// label sitting flush beside it. Auto-flips to the opposite side if the
|
|
87
199
|
// chosen side would overflow the chart area.
|
|
88
|
-
if (
|
|
200
|
+
if (connectorConfig?.type === 'drop-line') {
|
|
89
201
|
return resolveDropLineAnnotation(
|
|
90
202
|
annotation,
|
|
91
203
|
px,
|
|
92
204
|
py,
|
|
93
205
|
chartArea,
|
|
94
206
|
labelStyle,
|
|
95
|
-
|
|
207
|
+
labelBackground,
|
|
208
|
+
isDark,
|
|
96
209
|
measure,
|
|
97
210
|
);
|
|
98
211
|
}
|
|
99
212
|
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
labelStyle.textAnchor = 'middle';
|
|
106
|
-
}
|
|
213
|
+
// Text blocks are never center-aligned: the reference annotations align on the
|
|
214
|
+
// edge that faces the data point, so the block reads as a flag on the leader.
|
|
215
|
+
// A left anchor puts the block to the left of the point, so its right edge
|
|
216
|
+
// faces it (`end`); everything else faces left (`start`).
|
|
217
|
+
labelStyle.textAnchor = annotation.anchor === 'left' ? 'end' : 'start';
|
|
107
218
|
|
|
108
219
|
// Compute position from anchor direction + user offset
|
|
109
220
|
const anchorDelta = computeAnchorOffset(annotation.anchor, px, py, chartArea);
|
|
110
221
|
const finalDelta = applyOffset(anchorDelta, annotation.offset);
|
|
111
222
|
|
|
112
|
-
const labelX = px + finalDelta.dx;
|
|
113
|
-
const labelY = py + finalDelta.dy;
|
|
114
|
-
|
|
115
|
-
// Connector: draw unless explicitly disabled
|
|
116
|
-
const showConnector = annotation.connector !== false;
|
|
117
|
-
const connectorStyle: 'straight' | 'curve' =
|
|
118
|
-
annotation.connector === 'curve' ? 'curve' : 'straight';
|
|
119
|
-
|
|
120
|
-
// Compute connector origin: pick the edge midpoint closest to the data point
|
|
121
223
|
const fontSize = annotation.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
|
|
122
|
-
const fontWeight = annotation.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
|
|
123
|
-
const { x: connectorFromX, y: connectorFromY } = computeConnectorOrigin(
|
|
124
|
-
labelX,
|
|
125
|
-
labelY,
|
|
126
|
-
annotation.text,
|
|
127
|
-
fontSize,
|
|
128
|
-
fontWeight,
|
|
129
|
-
px,
|
|
130
|
-
py,
|
|
131
|
-
connectorStyle,
|
|
132
|
-
);
|
|
133
|
-
|
|
134
|
-
// Apply user-provided connector endpoint offsets
|
|
135
|
-
const baseFrom = { x: connectorFromX, y: connectorFromY };
|
|
136
|
-
const baseTo = { x: px, y: py };
|
|
137
|
-
const adjustedFrom = {
|
|
138
|
-
x: baseFrom.x + (annotation.connectorOffset?.from?.dx ?? 0),
|
|
139
|
-
y: baseFrom.y + (annotation.connectorOffset?.from?.dy ?? 0),
|
|
140
|
-
};
|
|
141
|
-
const adjustedToRaw = {
|
|
142
|
-
x: baseTo.x + (annotation.connectorOffset?.to?.dx ?? 0),
|
|
143
|
-
y: baseTo.y + (annotation.connectorOffset?.to?.dy ?? 0),
|
|
144
|
-
};
|
|
145
224
|
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
225
|
+
// A top/bottom anchor means the block sits ABOVE/BELOW the point, so it has to
|
|
226
|
+
// straddle it horizontally. The text renders with textAnchor 'start', which
|
|
227
|
+
// would otherwise hang the whole block off to the right and make `anchor: 'top'`
|
|
228
|
+
// read as "up and to the right" — forcing every author to hand-compute a
|
|
229
|
+
// negative dx of roughly half the block width. Centering the block is separate
|
|
230
|
+
// from aligning the text: the lines inside stay left-aligned and ragged right.
|
|
231
|
+
//
|
|
232
|
+
// Measured against the widest line of the block (primary vs subtitle), since a
|
|
233
|
+
// subtitle is often wider and centering on the primary alone reads as off-center.
|
|
234
|
+
let centeringShift = 0;
|
|
235
|
+
if (annotation.anchor === 'top' || annotation.anchor === 'bottom') {
|
|
236
|
+
const primaryWidth = computeTextBlockBounds(
|
|
237
|
+
0,
|
|
238
|
+
fontSize,
|
|
239
|
+
annotation.text,
|
|
240
|
+
{ ...labelStyle, textAnchor: 'start' },
|
|
241
|
+
measure,
|
|
242
|
+
).width;
|
|
243
|
+
const subtitleWidth = annotation.subtitle
|
|
244
|
+
? computeTextBlockBounds(
|
|
245
|
+
0,
|
|
246
|
+
fontSize,
|
|
247
|
+
annotation.subtitle,
|
|
248
|
+
{
|
|
249
|
+
...labelStyle,
|
|
250
|
+
fontSize: subtitleFontSize(fontSize),
|
|
251
|
+
fontWeight: SUBTITLE_FONT_WEIGHT,
|
|
252
|
+
textAnchor: 'start',
|
|
253
|
+
},
|
|
254
|
+
measure,
|
|
255
|
+
).width
|
|
256
|
+
: 0;
|
|
257
|
+
centeringShift = -Math.max(primaryWidth, subtitleWidth) / 2;
|
|
258
|
+
}
|
|
156
259
|
|
|
157
|
-
const
|
|
260
|
+
const labelX = px + finalDelta.dx + centeringShift;
|
|
261
|
+
const labelY = py + finalDelta.dy;
|
|
158
262
|
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
style: labelStyle,
|
|
164
|
-
visible: true,
|
|
165
|
-
connector: showConnector
|
|
166
|
-
? {
|
|
167
|
-
from: adjustedFrom,
|
|
168
|
-
to: adjustedTo,
|
|
169
|
-
endpoint: { x: px, y: py },
|
|
170
|
-
stroke: annotation.stroke ?? '#999999',
|
|
171
|
-
style: connectorStyle,
|
|
172
|
-
}
|
|
173
|
-
: undefined,
|
|
174
|
-
background: annotation.background,
|
|
175
|
-
halo: annotation.halo,
|
|
176
|
-
bounds: labelBounds,
|
|
177
|
-
};
|
|
263
|
+
const showConnector = connectorConfig !== null;
|
|
264
|
+
const connectorStyle: 'straight' | 'curve' =
|
|
265
|
+
connectorConfig?.type === 'curve' ? 'curve' : 'straight';
|
|
266
|
+
const connectorArrow = connectorConfig?.arrow ?? false;
|
|
178
267
|
|
|
179
|
-
|
|
180
|
-
// (post user-supplied connectorOffset.to) so it sits exactly where the
|
|
181
|
-
// connector terminates at the data point.
|
|
182
|
-
let dot: ResolvedAnnotation['dot'] | undefined;
|
|
183
|
-
if (annotation.dot) {
|
|
184
|
-
const dotConfig = typeof annotation.dot === 'object' ? annotation.dot : {};
|
|
185
|
-
const defaultDotFill = isDark ? DARK_DOT_FILL : LIGHT_DOT_FILL;
|
|
186
|
-
const defaultDotStroke = isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL;
|
|
187
|
-
dot = {
|
|
188
|
-
x: adjustedTo.x,
|
|
189
|
-
y: adjustedTo.y,
|
|
190
|
-
radius: dotConfig.radius ?? DEFAULT_DOT_RADIUS,
|
|
191
|
-
fill: dotConfig.fill ?? defaultDotFill,
|
|
192
|
-
stroke: dotConfig.stroke ?? defaultDotStroke,
|
|
193
|
-
strokeWidth: dotConfig.strokeWidth ?? DEFAULT_DOT_STROKE_WIDTH,
|
|
194
|
-
};
|
|
195
|
-
}
|
|
268
|
+
const labelBounds = computeTextBlockBounds(labelX, labelY, annotation.text, labelStyle, measure);
|
|
196
269
|
|
|
197
|
-
// Resolve subtitle
|
|
198
|
-
//
|
|
270
|
+
// Resolve subtitle first: its bounds are part of the block the connector must
|
|
271
|
+
// clear, and the subtitle is often the wider of the two lines.
|
|
199
272
|
let subtitle: ResolvedAnnotation['subtitle'] | undefined;
|
|
200
273
|
let annotationBounds: Rect = labelBounds;
|
|
201
274
|
if (annotation.subtitle) {
|
|
202
275
|
const primaryLineCount = annotation.text.split('\n').length;
|
|
203
|
-
const subtitleFontSize = Math.round(fontSize * SUBTITLE_FONT_SIZE_RATIO);
|
|
204
276
|
const mutedFill = isDark ? DARK_MUTED_TEXT_FILL : LIGHT_MUTED_TEXT_FILL;
|
|
205
277
|
const subtitleStyle: TextStyle = {
|
|
206
278
|
...labelStyle,
|
|
207
|
-
fontSize: subtitleFontSize,
|
|
208
|
-
fontWeight:
|
|
279
|
+
fontSize: subtitleFontSize(fontSize),
|
|
280
|
+
fontWeight: SUBTITLE_FONT_WEIGHT,
|
|
209
281
|
fill: mutedFill,
|
|
210
282
|
};
|
|
211
|
-
const subtitleY = labelY
|
|
283
|
+
const subtitleY = subtitleBaselineY(labelY, fontSize * DEFAULT_LINE_HEIGHT, primaryLineCount);
|
|
212
284
|
subtitle = {
|
|
213
285
|
text: annotation.subtitle,
|
|
214
286
|
x: labelX,
|
|
@@ -225,6 +297,81 @@ export function resolveTextAnnotation(
|
|
|
225
297
|
annotationBounds = unionRects(labelBounds, subtitleBounds);
|
|
226
298
|
}
|
|
227
299
|
|
|
300
|
+
// The endpoint marker sits on the data point itself (plus any user offset on
|
|
301
|
+
// the connector's data-side end), never on the pulled-back connector tip.
|
|
302
|
+
const endpoint = {
|
|
303
|
+
x: px + (annotation.connectorOffset?.to?.dx ?? 0),
|
|
304
|
+
y: py + (annotation.connectorOffset?.to?.dy ?? 0),
|
|
305
|
+
};
|
|
306
|
+
// Emphasis (arrowed) connectors take the label ink; quiet leaders take gray.
|
|
307
|
+
// The marker shares the color so it reads as part of the same gesture.
|
|
308
|
+
const connectorStroke =
|
|
309
|
+
annotation.stroke ??
|
|
310
|
+
defaultConnectorStroke(connectorArrow, isDark, labelStyle.fill ?? defaultTextFill);
|
|
311
|
+
const dot = resolveDot(
|
|
312
|
+
annotation.dot,
|
|
313
|
+
showConnector,
|
|
314
|
+
connectorArrow,
|
|
315
|
+
endpoint.x,
|
|
316
|
+
endpoint.y,
|
|
317
|
+
isDark,
|
|
318
|
+
connectorStroke,
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
// Connector origin: ray from the block center (label ∪ subtitle) to the data
|
|
322
|
+
// point, exiting the box with a standoff gap.
|
|
323
|
+
let connector: ResolvedLabel['connector'];
|
|
324
|
+
if (showConnector) {
|
|
325
|
+
const exit = connectorExit(annotationBounds, endpoint.x, endpoint.y);
|
|
326
|
+
if (exit) {
|
|
327
|
+
const from = {
|
|
328
|
+
x: exit.x + (annotation.connectorOffset?.from?.dx ?? 0),
|
|
329
|
+
y: exit.y + (annotation.connectorOffset?.from?.dy ?? 0),
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// Pull the data-point end back so the line stops short of the marker.
|
|
333
|
+
const gap = connectorPullbackGap(markerClearance(dot), connectorArrow);
|
|
334
|
+
const cdx = endpoint.x - from.x;
|
|
335
|
+
const cdy = endpoint.y - from.y;
|
|
336
|
+
const dist = Math.sqrt(cdx * cdx + cdy * cdy);
|
|
337
|
+
const to =
|
|
338
|
+
dist > 0
|
|
339
|
+
? { x: endpoint.x - (cdx / dist) * gap, y: endpoint.y - (cdy / dist) * gap }
|
|
340
|
+
: { ...endpoint };
|
|
341
|
+
|
|
342
|
+
// Min-length check runs after the pullback: it can flip a near-degenerate
|
|
343
|
+
// line's direction, and a stub shorter than MIN_CONNECTOR_LENGTH reads as
|
|
344
|
+
// noise — the marker alone says it better. An arrowed connector spends
|
|
345
|
+
// ARROWHEAD_LENGTH of its length on the head, so the gate measures what's
|
|
346
|
+
// actually left to stroke.
|
|
347
|
+
const lx = to.x - from.x;
|
|
348
|
+
const ly = to.y - from.y;
|
|
349
|
+
if (connectorIsDrawable(Math.sqrt(lx * lx + ly * ly), connectorArrow)) {
|
|
350
|
+
connector = {
|
|
351
|
+
from,
|
|
352
|
+
to,
|
|
353
|
+
endpoint,
|
|
354
|
+
stroke: connectorStroke,
|
|
355
|
+
style: connectorStyle,
|
|
356
|
+
arrow: connectorArrow,
|
|
357
|
+
exit: exit.exit,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const label: ResolvedLabel = {
|
|
364
|
+
text: annotation.text,
|
|
365
|
+
x: labelX,
|
|
366
|
+
y: labelY,
|
|
367
|
+
style: labelStyle,
|
|
368
|
+
visible: true,
|
|
369
|
+
connector,
|
|
370
|
+
background: labelBackground,
|
|
371
|
+
halo: annotation.halo,
|
|
372
|
+
bounds: labelBounds,
|
|
373
|
+
};
|
|
374
|
+
|
|
228
375
|
return {
|
|
229
376
|
type: 'text',
|
|
230
377
|
id: annotation.id,
|
|
@@ -250,15 +397,23 @@ function resolveDropLineAnnotation(
|
|
|
250
397
|
py: number,
|
|
251
398
|
chartArea: Rect,
|
|
252
399
|
labelStyle: TextStyle,
|
|
253
|
-
|
|
400
|
+
labelBackground: string | undefined,
|
|
401
|
+
isDark: boolean,
|
|
254
402
|
measure: AnnotationMeasureTextFn = heuristicMeasure,
|
|
255
403
|
): ResolvedAnnotation {
|
|
256
404
|
const fontSize = annotation.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
|
|
257
|
-
const fontWeight =
|
|
405
|
+
const fontWeight = Number(labelStyle.fontWeight) || DEFAULT_ANNOTATION_FONT_WEIGHT;
|
|
258
406
|
const lines = annotation.text.split('\n');
|
|
407
|
+
const subtitleLines = annotation.subtitle ? annotation.subtitle.split('\n') : [];
|
|
408
|
+
const subFontSize = subtitleFontSize(fontSize);
|
|
259
409
|
const estimatedWidth = Math.max(
|
|
260
410
|
0,
|
|
261
|
-
...lines.map((line) =>
|
|
411
|
+
...lines.map((line) => measureRichLine(line, { fontSize, fontWeight }, measure)),
|
|
412
|
+
// The subtitle is often the wider line, and it's what decides whether the
|
|
413
|
+
// block fits on a side.
|
|
414
|
+
...subtitleLines.map((line) =>
|
|
415
|
+
measureRichLine(line, { fontSize: subFontSize, fontWeight: SUBTITLE_FONT_WEIGHT }, measure),
|
|
416
|
+
),
|
|
262
417
|
);
|
|
263
418
|
|
|
264
419
|
// Pick initial side from anchor; default to 'left' (label sits to the left
|
|
@@ -285,8 +440,13 @@ function resolveDropLineAnnotation(
|
|
|
285
440
|
// Drop the label box top a bit above the data point so the label and line
|
|
286
441
|
// share a baseline that reads as "callout above the point".
|
|
287
442
|
const lineHeight = fontSize * DEFAULT_LINE_HEIGHT;
|
|
288
|
-
|
|
289
|
-
//
|
|
443
|
+
// The subtitle hangs below the primary lines, so it's part of the block the
|
|
444
|
+
// 12px setback has to clear -- otherwise it lands on top of the data point.
|
|
445
|
+
const subtitleHeight = annotation.subtitle
|
|
446
|
+
? SUBTITLE_GAP + subFontSize * DEFAULT_LINE_HEIGHT * subtitleLines.length
|
|
447
|
+
: 0;
|
|
448
|
+
const totalHeight = lineHeight * lines.length + subtitleHeight;
|
|
449
|
+
// Position the first line so the bottom of the block sits ~12px above py.
|
|
290
450
|
// Clamp to the chart-area top so multi-line labels near peaks don't escape
|
|
291
451
|
// upward into chrome / metric-bar territory.
|
|
292
452
|
const desiredLabelTopY = py - totalHeight - 12;
|
|
@@ -296,8 +456,16 @@ function resolveDropLineAnnotation(
|
|
|
296
456
|
|
|
297
457
|
const resolvedStyle: TextStyle = { ...labelStyle, textAnchor };
|
|
298
458
|
|
|
459
|
+
// The drop-line is a connector with no arrow, so it gets the default endpoint
|
|
460
|
+
// marker too — and the line stops short of it by the same rule as the other
|
|
461
|
+
// connector styles. Quiet voice: gray hairline, marker in the same gray.
|
|
462
|
+
const connectorStroke =
|
|
463
|
+
annotation.stroke ?? (isDark ? DARK_CONNECTOR_STROKE : LIGHT_CONNECTOR_STROKE);
|
|
464
|
+
const dot = resolveDot(annotation.dot, true, false, px, py, isDark, connectorStroke);
|
|
465
|
+
const bottomGap = connectorPullbackGap(markerClearance(dot), false);
|
|
466
|
+
|
|
299
467
|
const from = { x: px, y: labelTopY - DROP_LINE_TOP_GAP };
|
|
300
|
-
const to = { x: px, y: py -
|
|
468
|
+
const to = { x: px, y: py - bottomGap };
|
|
301
469
|
|
|
302
470
|
const labelBounds = computeTextBlockBounds(
|
|
303
471
|
labelX,
|
|
@@ -307,6 +475,33 @@ function resolveDropLineAnnotation(
|
|
|
307
475
|
measure,
|
|
308
476
|
);
|
|
309
477
|
|
|
478
|
+
// A subtitle works on a drop-line the same as on any other connector style:
|
|
479
|
+
// the lede rule already bolded the primary line on the way in, so dropping the
|
|
480
|
+
// subtitle here would leave a mystery-bold label with no context under it.
|
|
481
|
+
let subtitle: ResolvedAnnotation['subtitle'] | undefined;
|
|
482
|
+
let annotationBounds = labelBounds;
|
|
483
|
+
if (annotation.subtitle) {
|
|
484
|
+
const subtitleStyle: TextStyle = {
|
|
485
|
+
...resolvedStyle,
|
|
486
|
+
fontSize: subFontSize,
|
|
487
|
+
fontWeight: SUBTITLE_FONT_WEIGHT,
|
|
488
|
+
fill: isDark ? DARK_MUTED_TEXT_FILL : LIGHT_MUTED_TEXT_FILL,
|
|
489
|
+
};
|
|
490
|
+
// `lineHeight` here is already the pixel line-height, which is what the shared
|
|
491
|
+
// formula takes.
|
|
492
|
+
const subtitleY = subtitleBaselineY(labelBaselineY, lineHeight, lines.length);
|
|
493
|
+
subtitle = {
|
|
494
|
+
text: annotation.subtitle,
|
|
495
|
+
x: labelX,
|
|
496
|
+
y: subtitleY,
|
|
497
|
+
style: subtitleStyle,
|
|
498
|
+
};
|
|
499
|
+
annotationBounds = unionRects(
|
|
500
|
+
labelBounds,
|
|
501
|
+
computeTextBlockBounds(labelX, subtitleY, annotation.subtitle, subtitleStyle, measure),
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
310
505
|
const label: ResolvedLabel = {
|
|
311
506
|
text: annotation.text,
|
|
312
507
|
x: labelX,
|
|
@@ -317,10 +512,12 @@ function resolveDropLineAnnotation(
|
|
|
317
512
|
from,
|
|
318
513
|
to,
|
|
319
514
|
endpoint: { x: px, y: py },
|
|
320
|
-
stroke:
|
|
515
|
+
stroke: connectorStroke,
|
|
321
516
|
style: 'drop-line',
|
|
517
|
+
arrow: false,
|
|
518
|
+
exit: 'vertical',
|
|
322
519
|
},
|
|
323
|
-
background:
|
|
520
|
+
background: labelBackground,
|
|
324
521
|
halo: annotation.halo,
|
|
325
522
|
bounds: labelBounds,
|
|
326
523
|
};
|
|
@@ -329,10 +526,14 @@ function resolveDropLineAnnotation(
|
|
|
329
526
|
type: 'text',
|
|
330
527
|
id: annotation.id,
|
|
331
528
|
label,
|
|
529
|
+
subtitle,
|
|
332
530
|
stroke: annotation.stroke,
|
|
333
531
|
fill: annotation.fill,
|
|
334
532
|
opacity: annotation.opacity,
|
|
335
533
|
zIndex: annotation.zIndex,
|
|
336
|
-
|
|
534
|
+
dot,
|
|
535
|
+
// Union of label + subtitle. `label.bounds` stays label-only -- the renderer
|
|
536
|
+
// sizes the `background` plate from that.
|
|
537
|
+
bounds: annotationBounds,
|
|
337
538
|
};
|
|
338
539
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline rich text for annotation labels: `**bold**` spans inside an otherwise
|
|
3
|
+
* regular-weight sentence. This is the NYT/Datawrapper voice — emphasis lands on
|
|
4
|
+
* the key phrase, not on the whole block.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** One run of annotation text at a single weight. */
|
|
8
|
+
export interface RichSpan {
|
|
9
|
+
text: string;
|
|
10
|
+
bold: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Weight bold spans are measured and rendered at. */
|
|
14
|
+
export const BOLD_SPAN_FONT_WEIGHT = 700;
|
|
15
|
+
|
|
16
|
+
const BOLD_DELIMITER = '**';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse `**bold**` markers in a single line of annotation text.
|
|
20
|
+
*
|
|
21
|
+
* Only matched pairs turn bold; an unmatched `**` renders literally, so specs
|
|
22
|
+
* that legitimately contain asterisks are never mangled. Empty `****` also
|
|
23
|
+
* renders literally (there's nothing to emphasize).
|
|
24
|
+
*
|
|
25
|
+
* The line is expected to be a single line — callers split on `\n` first.
|
|
26
|
+
*/
|
|
27
|
+
export function parseAnnotationSpans(line: string): RichSpan[] {
|
|
28
|
+
if (!line.includes(BOLD_DELIMITER)) {
|
|
29
|
+
return [{ text: line, bold: false }];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const spans: RichSpan[] = [];
|
|
33
|
+
let plain = '';
|
|
34
|
+
let i = 0;
|
|
35
|
+
|
|
36
|
+
const flushPlain = (): void => {
|
|
37
|
+
if (plain) {
|
|
38
|
+
spans.push({ text: plain, bold: false });
|
|
39
|
+
plain = '';
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
while (i < line.length) {
|
|
44
|
+
if (line.startsWith(BOLD_DELIMITER, i)) {
|
|
45
|
+
const close = line.indexOf(BOLD_DELIMITER, i + BOLD_DELIMITER.length);
|
|
46
|
+
const inner = close === -1 ? '' : line.slice(i + BOLD_DELIMITER.length, close);
|
|
47
|
+
if (close === -1 || inner.length === 0) {
|
|
48
|
+
// Unmatched (or empty) delimiter: render it literally.
|
|
49
|
+
plain += BOLD_DELIMITER;
|
|
50
|
+
i += BOLD_DELIMITER.length;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
flushPlain();
|
|
54
|
+
spans.push({ text: inner, bold: true });
|
|
55
|
+
i = close + BOLD_DELIMITER.length;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
plain += line[i];
|
|
59
|
+
i++;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
flushPlain();
|
|
63
|
+
|
|
64
|
+
return spans.length > 0 ? spans : [{ text: '', bold: false }];
|
|
65
|
+
}
|