@opendata-ai/openchart-engine 8.0.0-rc.9 → 8.1.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 +175 -12
- package/dist/index.js +917 -168
- package/dist/index.js.map +1 -1
- package/package.json +3 -4
- package/src/__test-fixtures__/specs.ts +0 -182
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +0 -2401
- package/src/__tests__/annotation-behavior.test.ts +0 -852
- package/src/__tests__/axes.test.ts +0 -1579
- package/src/__tests__/compile-animation.test.ts +0 -161
- package/src/__tests__/compile-chart.test.ts +0 -1303
- package/src/__tests__/compile-footnotes.test.ts +0 -277
- package/src/__tests__/compile-layer.test.ts +0 -969
- package/src/__tests__/compile-snapshot.test.ts +0 -156
- package/src/__tests__/compound-labels.test.ts +0 -147
- package/src/__tests__/dimensions.test.ts +0 -918
- package/src/__tests__/encoding-sugar.test.ts +0 -390
- package/src/__tests__/facet.test.ts +0 -215
- package/src/__tests__/helpers/invariants.ts +0 -299
- package/src/__tests__/known-bugs.test.ts +0 -566
- package/src/__tests__/layout-fuzz.test.ts +0 -358
- package/src/__tests__/layout-invariants.test.ts +0 -535
- package/src/__tests__/legend.test.ts +0 -721
- package/src/__tests__/mark-keys.test.ts +0 -294
- package/src/__tests__/ranking-recipes.test.ts +0 -260
- package/src/__tests__/rotation.test.ts +0 -83
- package/src/__tests__/scales.test.ts +0 -323
- package/src/__tests__/series-search.test.ts +0 -92
- package/src/__tests__/spec-sugar.test.ts +0 -801
- package/src/__tests__/you-draw-it.test.ts +0 -160
- package/src/annotations/__tests__/compute.test.ts +0 -1672
- package/src/annotations/__tests__/geometry.test.ts +0 -66
- package/src/annotations/__tests__/placement-fuzz.test.ts +0 -256
- package/src/annotations/__tests__/placement-invariants.test.ts +0 -345
- package/src/annotations/__tests__/placement.test.ts +0 -316
- package/src/annotations/__tests__/resolve-text.test.ts +0 -788
- package/src/annotations/__tests__/rich-text.test.ts +0 -91
- package/src/annotations/__tests__/thinning.test.ts +0 -285
- package/src/annotations/collisions.ts +0 -326
- package/src/annotations/compute.ts +0 -333
- package/src/annotations/constants.ts +0 -162
- package/src/annotations/geometry.ts +0 -376
- package/src/annotations/move.ts +0 -135
- package/src/annotations/placement.ts +0 -636
- package/src/annotations/position.ts +0 -131
- package/src/annotations/resolve-range.ts +0 -117
- package/src/annotations/resolve-refline.ts +0 -157
- package/src/annotations/resolve-text.ts +0 -545
- package/src/annotations/rich-text.ts +0 -65
- package/src/annotations/thinning.ts +0 -190
- package/src/barlist/__tests__/compile-barlist.test.ts +0 -200
- package/src/barlist/compile-barlist.ts +0 -391
- package/src/barlist/types.ts +0 -28
- package/src/charts/__tests__/post-process.test.ts +0 -237
- package/src/charts/__tests__/registry.test.ts +0 -116
- package/src/charts/__tests__/utils.test.ts +0 -195
- package/src/charts/_shared/__tests__/density-filter.test.ts +0 -32
- package/src/charts/_shared/density-filter.ts +0 -26
- package/src/charts/_shared/format-label-value.ts +0 -16
- package/src/charts/bar/__tests__/compute.test.ts +0 -934
- package/src/charts/bar/__tests__/gradient-orient.test.ts +0 -127
- package/src/charts/bar/__tests__/labels.test.ts +0 -292
- package/src/charts/bar/compute.ts +0 -539
- package/src/charts/bar/index.ts +0 -60
- package/src/charts/bar/labels.ts +0 -311
- package/src/charts/beeswarm/__tests__/compute.test.ts +0 -280
- package/src/charts/beeswarm/__tests__/dodge.test.ts +0 -148
- package/src/charts/beeswarm/compute.ts +0 -203
- package/src/charts/beeswarm/dodge.ts +0 -155
- package/src/charts/beeswarm/index.ts +0 -32
- package/src/charts/builtin.ts +0 -75
- package/src/charts/calendar/__tests__/compute.test.ts +0 -417
- package/src/charts/calendar/compute.ts +0 -319
- package/src/charts/calendar/index.ts +0 -32
- package/src/charts/column/__tests__/compute.test.ts +0 -540
- package/src/charts/column/__tests__/labels.test.ts +0 -179
- package/src/charts/column/compute.ts +0 -493
- package/src/charts/column/index.ts +0 -53
- package/src/charts/column/labels.ts +0 -229
- package/src/charts/dot/__tests__/compute.test.ts +0 -380
- package/src/charts/dot/__tests__/labels.test.ts +0 -98
- package/src/charts/dot/compute.ts +0 -285
- package/src/charts/dot/index.ts +0 -62
- package/src/charts/dot/labels.ts +0 -121
- package/src/charts/line/__tests__/compute.test.ts +0 -1194
- package/src/charts/line/__tests__/curves.test.ts +0 -51
- package/src/charts/line/__tests__/labels.test.ts +0 -94
- package/src/charts/line/__tests__/mark-options.test.ts +0 -175
- package/src/charts/line/area.ts +0 -484
- package/src/charts/line/compute.ts +0 -356
- package/src/charts/line/curves.ts +0 -85
- package/src/charts/line/index.ts +0 -176
- package/src/charts/line/labels.ts +0 -177
- package/src/charts/parliament/__tests__/compute.test.ts +0 -411
- package/src/charts/parliament/compute.ts +0 -313
- package/src/charts/parliament/index.ts +0 -32
- package/src/charts/pie/__tests__/compute.test.ts +0 -384
- package/src/charts/pie/__tests__/labels.test.ts +0 -132
- package/src/charts/pie/compute.ts +0 -324
- package/src/charts/pie/index.ts +0 -49
- package/src/charts/pie/labels.ts +0 -143
- package/src/charts/post-process.ts +0 -229
- package/src/charts/range/__tests__/compute.test.ts +0 -486
- package/src/charts/range/compute.ts +0 -273
- package/src/charts/range/index.ts +0 -82
- package/src/charts/range/labels.ts +0 -169
- package/src/charts/rect/__tests__/compute.test.ts +0 -120
- package/src/charts/rect/index.ts +0 -127
- package/src/charts/registry.ts +0 -72
- package/src/charts/rule/index.ts +0 -139
- package/src/charts/scatter/__tests__/compute.test.ts +0 -488
- package/src/charts/scatter/__tests__/trendline.test.ts +0 -191
- package/src/charts/scatter/compute.ts +0 -192
- package/src/charts/scatter/index.ts +0 -46
- package/src/charts/scatter/trendline.ts +0 -100
- package/src/charts/text/__tests__/compute.test.ts +0 -307
- package/src/charts/text/index.ts +0 -141
- package/src/charts/tick/__tests__/compute.test.ts +0 -75
- package/src/charts/tick/index.ts +0 -99
- package/src/charts/utils.ts +0 -170
- package/src/charts/waffle/__tests__/compute.test.ts +0 -305
- package/src/charts/waffle/compute.ts +0 -197
- package/src/charts/waffle/index.ts +0 -31
- package/src/compile/__tests__/color-scale-range.test.ts +0 -158
- package/src/compile/__tests__/color-scale-reverse.test.ts +0 -89
- package/src/compile/__tests__/data-clip.test.ts +0 -127
- package/src/compile/__tests__/fill-patterns.test.ts +0 -154
- package/src/compile/__tests__/size-scale.test.ts +0 -108
- package/src/compile/__tests__/watermark-obstacle.test.ts +0 -93
- package/src/compile/color-scale-range.ts +0 -101
- package/src/compile/data-clip.ts +0 -48
- package/src/compile/fill-patterns.ts +0 -74
- package/src/compile/layer.ts +0 -630
- package/src/compile/size-scale.ts +0 -141
- package/src/compile/spec-sugar.ts +0 -758
- package/src/compile/watermark-obstacle.ts +0 -54
- package/src/compile/you-draw-it.ts +0 -123
- package/src/compile.ts +0 -1503
- package/src/compiler/__tests__/a11y-warnings.test.ts +0 -148
- package/src/compiler/__tests__/animation.test.ts +0 -177
- package/src/compiler/__tests__/compile.test.ts +0 -87
- package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +0 -20
- package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +0 -22
- package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +0 -14
- package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +0 -34
- package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +0 -22
- package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +0 -41
- package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +0 -38
- package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +0 -28
- package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +0 -28
- package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +0 -30
- package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +0 -29
- package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +0 -32
- package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +0 -25
- package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +0 -21
- package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +0 -23
- package/src/compiler/__tests__/keys.test.ts +0 -83
- package/src/compiler/__tests__/normalize-highlight.test.ts +0 -103
- package/src/compiler/__tests__/normalize.test.ts +0 -399
- package/src/compiler/__tests__/sparkline-defaults.test.ts +0 -153
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +0 -170
- package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
- package/src/compiler/__tests__/validate-near-miss.test.ts +0 -251
- package/src/compiler/__tests__/validate.test.ts +0 -629
- package/src/compiler/__tests__/vl-parity.test.ts +0 -68
- package/src/compiler/a11y-warnings.ts +0 -110
- package/src/compiler/animation.ts +0 -142
- package/src/compiler/index.ts +0 -47
- package/src/compiler/keys.ts +0 -55
- package/src/compiler/normalize.ts +0 -734
- package/src/compiler/sparkline-defaults.ts +0 -138
- package/src/compiler/types.ts +0 -241
- package/src/compiler/validate.ts +0 -2041
- package/src/endpoint-labels/__tests__/compute.test.ts +0 -587
- package/src/endpoint-labels/compute.ts +0 -621
- package/src/endpoint-labels/constants.ts +0 -54
- package/src/endpoint-labels/format.ts +0 -60
- package/src/endpoint-labels/predict.ts +0 -139
- package/src/format/__tests__/field-format.test.ts +0 -164
- package/src/format/field-format.ts +0 -27
- package/src/geo/__tests__/compile-map.test.ts +0 -1128
- package/src/geo/compile-map.ts +0 -1376
- package/src/geo/join.ts +0 -115
- package/src/geo/projections.ts +0 -65
- package/src/geo/types.ts +0 -26
- package/src/graphs/__tests__/community.test.ts +0 -228
- package/src/graphs/__tests__/compile-graph.test.ts +0 -396
- package/src/graphs/__tests__/encoding.test.ts +0 -415
- package/src/graphs/community.ts +0 -92
- package/src/graphs/compile-graph.ts +0 -329
- package/src/graphs/encoding.ts +0 -356
- package/src/graphs/types.ts +0 -106
- package/src/index.ts +0 -177
- package/src/layout/__tests__/metrics.test.ts +0 -97
- package/src/layout/__tests__/scales-highlight.test.ts +0 -192
- package/src/layout/axes/rotation.ts +0 -152
- package/src/layout/axes/thinning.ts +0 -98
- package/src/layout/axes/ticks.ts +0 -409
- package/src/layout/axes.ts +0 -757
- package/src/layout/dimensions.ts +0 -912
- package/src/layout/facet.ts +0 -91
- package/src/layout/gridlines.ts +0 -84
- package/src/layout/metrics.ts +0 -132
- package/src/layout/plan.ts +0 -631
- package/src/layout/scales.ts +0 -1053
- package/src/layout/shared.ts +0 -44
- package/src/legend/__tests__/continuous.test.ts +0 -318
- package/src/legend/__tests__/redundancy.test.ts +0 -108
- package/src/legend/__tests__/size.test.ts +0 -348
- package/src/legend/__tests__/suppression.test.ts +0 -382
- package/src/legend/compute.ts +0 -748
- package/src/legend/continuous.ts +0 -327
- package/src/legend/size.ts +0 -407
- package/src/legend/suppression.ts +0 -226
- package/src/legend/wrap.ts +0 -117
- package/src/sankey/__tests__/compile-sankey.test.ts +0 -458
- package/src/sankey/__tests__/layout.test.ts +0 -165
- package/src/sankey/__tests__/node-label-wrap.test.ts +0 -114
- package/src/sankey/__tests__/node-sort.test.ts +0 -45
- package/src/sankey/compile-sankey.ts +0 -706
- package/src/sankey/layout.ts +0 -184
- package/src/sankey/types.ts +0 -41
- package/src/tables/__tests__/bar-column.test.ts +0 -147
- package/src/tables/__tests__/category-colors.test.ts +0 -230
- package/src/tables/__tests__/compile-table.test.ts +0 -236
- package/src/tables/__tests__/format-cells.test.ts +0 -143
- package/src/tables/__tests__/heatmap.test.ts +0 -148
- package/src/tables/__tests__/pagination.test.ts +0 -78
- package/src/tables/__tests__/search.test.ts +0 -94
- package/src/tables/__tests__/sort.test.ts +0 -107
- package/src/tables/__tests__/sparkline.test.ts +0 -122
- package/src/tables/bar-column.ts +0 -94
- package/src/tables/category-colors.ts +0 -78
- package/src/tables/compile-table.ts +0 -427
- package/src/tables/format-cells.ts +0 -115
- package/src/tables/heatmap.ts +0 -141
- package/src/tables/pagination.ts +0 -46
- package/src/tables/search.ts +0 -66
- package/src/tables/sort.ts +0 -69
- package/src/tables/sparkline.ts +0 -113
- package/src/tables/utils.ts +0 -16
- package/src/tilemap/__tests__/compile-tilemap.test.ts +0 -576
- package/src/tilemap/compile-tilemap.ts +0 -755
- package/src/tilemap/layout.ts +0 -172
- package/src/tilemap/types.ts +0 -33
- package/src/tooltips/__tests__/compute.test.ts +0 -582
- package/src/tooltips/compute.ts +0 -700
- package/src/transforms/__tests__/aggregate.test.ts +0 -159
- package/src/transforms/__tests__/bin.test.ts +0 -88
- package/src/transforms/__tests__/calculate.test.ts +0 -146
- package/src/transforms/__tests__/conditional.test.ts +0 -127
- package/src/transforms/__tests__/filter-relative.test.ts +0 -202
- package/src/transforms/__tests__/filter.test.ts +0 -59
- package/src/transforms/__tests__/fold.test.ts +0 -79
- package/src/transforms/__tests__/index.test.ts +0 -93
- package/src/transforms/__tests__/predicates.test.ts +0 -193
- package/src/transforms/__tests__/timeunit.test.ts +0 -129
- package/src/transforms/__tests__/window.test.ts +0 -286
- package/src/transforms/aggregate.ts +0 -131
- package/src/transforms/bin.ts +0 -87
- package/src/transforms/calculate.ts +0 -60
- package/src/transforms/conditional.ts +0 -46
- package/src/transforms/filter.ts +0 -122
- package/src/transforms/fold.ts +0 -49
- package/src/transforms/index.ts +0 -60
- package/src/transforms/predicates.ts +0 -123
- package/src/transforms/timeunit.ts +0 -88
- package/src/transforms/window.ts +0 -185
|
@@ -1,545 +0,0 @@
|
|
|
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
|
-
ConnectorConfig,
|
|
8
|
-
ConnectorType,
|
|
9
|
-
Rect,
|
|
10
|
-
ResolvedAnnotation,
|
|
11
|
-
ResolvedLabel,
|
|
12
|
-
TextAnnotation,
|
|
13
|
-
TextStyle,
|
|
14
|
-
} from '@opendata-ai/openchart-core';
|
|
15
|
-
import type { ResolvedScales } from '../layout/scales';
|
|
16
|
-
import {
|
|
17
|
-
connectorIsDrawable,
|
|
18
|
-
DARK_CONNECTOR_STROKE,
|
|
19
|
-
DARK_DOT_FILL,
|
|
20
|
-
DARK_LABEL_BACKGROUND,
|
|
21
|
-
DARK_MUTED_TEXT_FILL,
|
|
22
|
-
DARK_TEXT_FILL,
|
|
23
|
-
DEFAULT_ANNOTATION_FONT_SIZE,
|
|
24
|
-
DEFAULT_ANNOTATION_FONT_WEIGHT,
|
|
25
|
-
DEFAULT_DOT_RADIUS,
|
|
26
|
-
DEFAULT_DOT_STROKE_WIDTH,
|
|
27
|
-
DEFAULT_LINE_HEIGHT,
|
|
28
|
-
DROP_LINE_TOP_GAP,
|
|
29
|
-
FALLBACK_FONT_FAMILY,
|
|
30
|
-
LEDE_FONT_WEIGHT,
|
|
31
|
-
LIGHT_CONNECTOR_STROKE,
|
|
32
|
-
LIGHT_DOT_FILL,
|
|
33
|
-
LIGHT_LABEL_BACKGROUND,
|
|
34
|
-
LIGHT_MUTED_TEXT_FILL,
|
|
35
|
-
LIGHT_TEXT_FILL,
|
|
36
|
-
SUBTITLE_FONT_WEIGHT,
|
|
37
|
-
SUBTITLE_GAP,
|
|
38
|
-
subtitleBaselineY,
|
|
39
|
-
subtitleFontSize,
|
|
40
|
-
} from './constants';
|
|
41
|
-
import {
|
|
42
|
-
type AnnotationMeasureTextFn,
|
|
43
|
-
applyOffset,
|
|
44
|
-
computeAnchorOffset,
|
|
45
|
-
computeTextBlockBounds,
|
|
46
|
-
connectorExit,
|
|
47
|
-
connectorPullbackGap,
|
|
48
|
-
heuristicMeasure,
|
|
49
|
-
measureRichLine,
|
|
50
|
-
unionRects,
|
|
51
|
-
} from './geometry';
|
|
52
|
-
import { resolvePosition } from './position';
|
|
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
|
-
|
|
140
|
-
/** Horizontal gap between the drop-line and the label text. */
|
|
141
|
-
const DROP_LINE_LABEL_GAP = 8;
|
|
142
|
-
|
|
143
|
-
export function makeAnnotationLabelStyle(
|
|
144
|
-
fontSize?: number,
|
|
145
|
-
fontWeight?: number,
|
|
146
|
-
fill?: string,
|
|
147
|
-
isDark?: boolean,
|
|
148
|
-
fontFamily?: string,
|
|
149
|
-
): TextStyle {
|
|
150
|
-
const defaultFill = isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL;
|
|
151
|
-
return {
|
|
152
|
-
fontFamily: fontFamily ?? FALLBACK_FONT_FAMILY,
|
|
153
|
-
fontSize: fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE,
|
|
154
|
-
fontWeight: fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT,
|
|
155
|
-
fill: fill ?? defaultFill,
|
|
156
|
-
lineHeight: DEFAULT_LINE_HEIGHT,
|
|
157
|
-
textAnchor: 'start',
|
|
158
|
-
};
|
|
159
|
-
}
|
|
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
|
-
|
|
170
|
-
export function resolveTextAnnotation(
|
|
171
|
-
annotation: TextAnnotation,
|
|
172
|
-
scales: ResolvedScales,
|
|
173
|
-
chartArea: Rect,
|
|
174
|
-
isDark: boolean,
|
|
175
|
-
measure: AnnotationMeasureTextFn = heuristicMeasure,
|
|
176
|
-
fontFamily?: string,
|
|
177
|
-
): ResolvedAnnotation | null {
|
|
178
|
-
const px = resolvePosition(annotation.x, scales.x);
|
|
179
|
-
const py = resolvePosition(annotation.y, scales.y);
|
|
180
|
-
|
|
181
|
-
if (px === null || py === null) return null;
|
|
182
|
-
|
|
183
|
-
const defaultTextFill = isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL;
|
|
184
|
-
const labelBackground = resolveLabelBackground(annotation.background, isDark);
|
|
185
|
-
|
|
186
|
-
const labelStyle = makeAnnotationLabelStyle(
|
|
187
|
-
annotation.fontSize,
|
|
188
|
-
resolveLedeFontWeight(annotation),
|
|
189
|
-
annotation.fill ?? defaultTextFill,
|
|
190
|
-
isDark,
|
|
191
|
-
fontFamily,
|
|
192
|
-
);
|
|
193
|
-
|
|
194
|
-
// Parse connector spec into { type, arrow } or null (disabled).
|
|
195
|
-
const connectorConfig = resolveConnectorConfig(annotation.connector);
|
|
196
|
-
|
|
197
|
-
// Drop-line connector: vertical line through the data point's x with the
|
|
198
|
-
// label sitting flush beside it. Auto-flips to the opposite side if the
|
|
199
|
-
// chosen side would overflow the chart area.
|
|
200
|
-
if (connectorConfig?.type === 'drop-line') {
|
|
201
|
-
return resolveDropLineAnnotation(
|
|
202
|
-
annotation,
|
|
203
|
-
px,
|
|
204
|
-
py,
|
|
205
|
-
chartArea,
|
|
206
|
-
labelStyle,
|
|
207
|
-
labelBackground,
|
|
208
|
-
isDark,
|
|
209
|
-
measure,
|
|
210
|
-
);
|
|
211
|
-
}
|
|
212
|
-
|
|
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';
|
|
218
|
-
|
|
219
|
-
// Compute position from anchor direction + user offset
|
|
220
|
-
const anchorDelta = computeAnchorOffset(annotation.anchor, px, py, chartArea);
|
|
221
|
-
const finalDelta = applyOffset(anchorDelta, annotation.offset);
|
|
222
|
-
|
|
223
|
-
const fontSize = annotation.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
|
|
224
|
-
|
|
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
|
-
}
|
|
259
|
-
|
|
260
|
-
const labelX = px + finalDelta.dx + centeringShift;
|
|
261
|
-
const labelY = py + finalDelta.dy;
|
|
262
|
-
|
|
263
|
-
const showConnector = connectorConfig !== null;
|
|
264
|
-
const connectorStyle: 'straight' | 'curve' =
|
|
265
|
-
connectorConfig?.type === 'curve' ? 'curve' : 'straight';
|
|
266
|
-
const connectorArrow = connectorConfig?.arrow ?? false;
|
|
267
|
-
|
|
268
|
-
const labelBounds = computeTextBlockBounds(labelX, labelY, annotation.text, labelStyle, measure);
|
|
269
|
-
|
|
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.
|
|
272
|
-
let subtitle: ResolvedAnnotation['subtitle'] | undefined;
|
|
273
|
-
let annotationBounds: Rect = labelBounds;
|
|
274
|
-
if (annotation.subtitle) {
|
|
275
|
-
const primaryLineCount = annotation.text.split('\n').length;
|
|
276
|
-
const mutedFill = isDark ? DARK_MUTED_TEXT_FILL : LIGHT_MUTED_TEXT_FILL;
|
|
277
|
-
const subtitleStyle: TextStyle = {
|
|
278
|
-
...labelStyle,
|
|
279
|
-
fontSize: subtitleFontSize(fontSize),
|
|
280
|
-
fontWeight: SUBTITLE_FONT_WEIGHT,
|
|
281
|
-
fill: mutedFill,
|
|
282
|
-
};
|
|
283
|
-
const subtitleY = subtitleBaselineY(labelY, fontSize * DEFAULT_LINE_HEIGHT, primaryLineCount);
|
|
284
|
-
subtitle = {
|
|
285
|
-
text: annotation.subtitle,
|
|
286
|
-
x: labelX,
|
|
287
|
-
y: subtitleY,
|
|
288
|
-
style: subtitleStyle,
|
|
289
|
-
};
|
|
290
|
-
const subtitleBounds = computeTextBlockBounds(
|
|
291
|
-
labelX,
|
|
292
|
-
subtitleY,
|
|
293
|
-
annotation.subtitle,
|
|
294
|
-
subtitleStyle,
|
|
295
|
-
measure,
|
|
296
|
-
);
|
|
297
|
-
annotationBounds = unionRects(labelBounds, subtitleBounds);
|
|
298
|
-
}
|
|
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
|
-
fromOffset: annotation.connectorOffset?.from
|
|
355
|
-
? {
|
|
356
|
-
dx: annotation.connectorOffset.from.dx ?? 0,
|
|
357
|
-
dy: annotation.connectorOffset.from.dy ?? 0,
|
|
358
|
-
}
|
|
359
|
-
: undefined,
|
|
360
|
-
stroke: connectorStroke,
|
|
361
|
-
style: connectorStyle,
|
|
362
|
-
arrow: connectorArrow,
|
|
363
|
-
exit: exit.exit,
|
|
364
|
-
};
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
const label: ResolvedLabel = {
|
|
370
|
-
text: annotation.text,
|
|
371
|
-
x: labelX,
|
|
372
|
-
y: labelY,
|
|
373
|
-
style: labelStyle,
|
|
374
|
-
visible: true,
|
|
375
|
-
connector,
|
|
376
|
-
background: labelBackground,
|
|
377
|
-
halo: annotation.halo,
|
|
378
|
-
bounds: labelBounds,
|
|
379
|
-
};
|
|
380
|
-
|
|
381
|
-
return {
|
|
382
|
-
type: 'text',
|
|
383
|
-
id: annotation.id,
|
|
384
|
-
label,
|
|
385
|
-
stroke: annotation.stroke,
|
|
386
|
-
fill: annotation.fill,
|
|
387
|
-
opacity: annotation.opacity,
|
|
388
|
-
zIndex: annotation.zIndex,
|
|
389
|
-
dot,
|
|
390
|
-
subtitle,
|
|
391
|
-
bounds: annotationBounds,
|
|
392
|
-
};
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/**
|
|
396
|
-
* Resolve a drop-line text annotation. The connector is a vertical line through
|
|
397
|
-
* the data point's x. The label sits beside the line, anchored toward the chosen
|
|
398
|
-
* side (left or right), and auto-flips if the chosen side would overflow.
|
|
399
|
-
*/
|
|
400
|
-
function resolveDropLineAnnotation(
|
|
401
|
-
annotation: TextAnnotation,
|
|
402
|
-
px: number,
|
|
403
|
-
py: number,
|
|
404
|
-
chartArea: Rect,
|
|
405
|
-
labelStyle: TextStyle,
|
|
406
|
-
labelBackground: string | undefined,
|
|
407
|
-
isDark: boolean,
|
|
408
|
-
measure: AnnotationMeasureTextFn = heuristicMeasure,
|
|
409
|
-
): ResolvedAnnotation {
|
|
410
|
-
const fontSize = annotation.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
|
|
411
|
-
const fontWeight = Number(labelStyle.fontWeight) || DEFAULT_ANNOTATION_FONT_WEIGHT;
|
|
412
|
-
const lines = annotation.text.split('\n');
|
|
413
|
-
const subtitleLines = annotation.subtitle ? annotation.subtitle.split('\n') : [];
|
|
414
|
-
const subFontSize = subtitleFontSize(fontSize);
|
|
415
|
-
const estimatedWidth = Math.max(
|
|
416
|
-
0,
|
|
417
|
-
...lines.map((line) => measureRichLine(line, { fontSize, fontWeight }, measure)),
|
|
418
|
-
// The subtitle is often the wider line, and it's what decides whether the
|
|
419
|
-
// block fits on a side.
|
|
420
|
-
...subtitleLines.map((line) =>
|
|
421
|
-
measureRichLine(line, { fontSize: subFontSize, fontWeight: SUBTITLE_FONT_WEIGHT }, measure),
|
|
422
|
-
),
|
|
423
|
-
);
|
|
424
|
-
|
|
425
|
-
// Pick initial side from anchor; default to 'left' (label sits to the left
|
|
426
|
-
// of the drop-line) when not specified.
|
|
427
|
-
let side: 'left' | 'right' = annotation.anchor === 'right' ? 'right' : 'left';
|
|
428
|
-
|
|
429
|
-
// Auto-flip: if the chosen side would push the label past the chart-area edge,
|
|
430
|
-
// flip to the other side. Compare the estimated label width against the
|
|
431
|
-
// available space on each side. When neither side fits cleanly, fall back to
|
|
432
|
-
// whichever side has more room — graceful degradation beats silent overflow.
|
|
433
|
-
const spaceLeft = px - chartArea.x - DROP_LINE_LABEL_GAP;
|
|
434
|
-
const spaceRight = chartArea.x + chartArea.width - px - DROP_LINE_LABEL_GAP;
|
|
435
|
-
const fitsLeft = estimatedWidth <= spaceLeft;
|
|
436
|
-
const fitsRight = estimatedWidth <= spaceRight;
|
|
437
|
-
if (side === 'left' && !fitsLeft) {
|
|
438
|
-
side = fitsRight || spaceRight > spaceLeft ? 'right' : 'left';
|
|
439
|
-
} else if (side === 'right' && !fitsRight) {
|
|
440
|
-
side = fitsLeft || spaceLeft > spaceRight ? 'left' : 'right';
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
const labelX = side === 'left' ? px - DROP_LINE_LABEL_GAP : px + DROP_LINE_LABEL_GAP;
|
|
444
|
-
const textAnchor: 'start' | 'end' = side === 'left' ? 'end' : 'start';
|
|
445
|
-
|
|
446
|
-
// Drop the label box top a bit above the data point so the label and line
|
|
447
|
-
// share a baseline that reads as "callout above the point".
|
|
448
|
-
const lineHeight = fontSize * DEFAULT_LINE_HEIGHT;
|
|
449
|
-
// The subtitle hangs below the primary lines, so it's part of the block the
|
|
450
|
-
// 12px setback has to clear -- otherwise it lands on top of the data point.
|
|
451
|
-
const subtitleHeight = annotation.subtitle
|
|
452
|
-
? SUBTITLE_GAP + subFontSize * DEFAULT_LINE_HEIGHT * subtitleLines.length
|
|
453
|
-
: 0;
|
|
454
|
-
const totalHeight = lineHeight * lines.length + subtitleHeight;
|
|
455
|
-
// Position the first line so the bottom of the block sits ~12px above py.
|
|
456
|
-
// Clamp to the chart-area top so multi-line labels near peaks don't escape
|
|
457
|
-
// upward into chrome / metric-bar territory.
|
|
458
|
-
const desiredLabelTopY = py - totalHeight - 12;
|
|
459
|
-
const minLabelTopY = chartArea.y + 4;
|
|
460
|
-
const labelTopY = Math.max(desiredLabelTopY, minLabelTopY);
|
|
461
|
-
const labelBaselineY = labelTopY + fontSize;
|
|
462
|
-
|
|
463
|
-
const resolvedStyle: TextStyle = { ...labelStyle, textAnchor };
|
|
464
|
-
|
|
465
|
-
// The drop-line is a connector with no arrow, so it gets the default endpoint
|
|
466
|
-
// marker too — and the line stops short of it by the same rule as the other
|
|
467
|
-
// connector styles. Quiet voice: gray hairline, marker in the same gray.
|
|
468
|
-
const connectorStroke =
|
|
469
|
-
annotation.stroke ?? (isDark ? DARK_CONNECTOR_STROKE : LIGHT_CONNECTOR_STROKE);
|
|
470
|
-
const dot = resolveDot(annotation.dot, true, false, px, py, isDark, connectorStroke);
|
|
471
|
-
const bottomGap = connectorPullbackGap(markerClearance(dot), false);
|
|
472
|
-
|
|
473
|
-
const from = { x: px, y: labelTopY - DROP_LINE_TOP_GAP };
|
|
474
|
-
const to = { x: px, y: py - bottomGap };
|
|
475
|
-
|
|
476
|
-
const labelBounds = computeTextBlockBounds(
|
|
477
|
-
labelX,
|
|
478
|
-
labelBaselineY,
|
|
479
|
-
annotation.text,
|
|
480
|
-
resolvedStyle,
|
|
481
|
-
measure,
|
|
482
|
-
);
|
|
483
|
-
|
|
484
|
-
// A subtitle works on a drop-line the same as on any other connector style:
|
|
485
|
-
// the lede rule already bolded the primary line on the way in, so dropping the
|
|
486
|
-
// subtitle here would leave a mystery-bold label with no context under it.
|
|
487
|
-
let subtitle: ResolvedAnnotation['subtitle'] | undefined;
|
|
488
|
-
let annotationBounds = labelBounds;
|
|
489
|
-
if (annotation.subtitle) {
|
|
490
|
-
const subtitleStyle: TextStyle = {
|
|
491
|
-
...resolvedStyle,
|
|
492
|
-
fontSize: subFontSize,
|
|
493
|
-
fontWeight: SUBTITLE_FONT_WEIGHT,
|
|
494
|
-
fill: isDark ? DARK_MUTED_TEXT_FILL : LIGHT_MUTED_TEXT_FILL,
|
|
495
|
-
};
|
|
496
|
-
// `lineHeight` here is already the pixel line-height, which is what the shared
|
|
497
|
-
// formula takes.
|
|
498
|
-
const subtitleY = subtitleBaselineY(labelBaselineY, lineHeight, lines.length);
|
|
499
|
-
subtitle = {
|
|
500
|
-
text: annotation.subtitle,
|
|
501
|
-
x: labelX,
|
|
502
|
-
y: subtitleY,
|
|
503
|
-
style: subtitleStyle,
|
|
504
|
-
};
|
|
505
|
-
annotationBounds = unionRects(
|
|
506
|
-
labelBounds,
|
|
507
|
-
computeTextBlockBounds(labelX, subtitleY, annotation.subtitle, subtitleStyle, measure),
|
|
508
|
-
);
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
const label: ResolvedLabel = {
|
|
512
|
-
text: annotation.text,
|
|
513
|
-
x: labelX,
|
|
514
|
-
y: labelBaselineY,
|
|
515
|
-
style: resolvedStyle,
|
|
516
|
-
visible: true,
|
|
517
|
-
connector: {
|
|
518
|
-
from,
|
|
519
|
-
to,
|
|
520
|
-
endpoint: { x: px, y: py },
|
|
521
|
-
stroke: connectorStroke,
|
|
522
|
-
style: 'drop-line',
|
|
523
|
-
arrow: false,
|
|
524
|
-
exit: 'vertical',
|
|
525
|
-
},
|
|
526
|
-
background: labelBackground,
|
|
527
|
-
halo: annotation.halo,
|
|
528
|
-
bounds: labelBounds,
|
|
529
|
-
};
|
|
530
|
-
|
|
531
|
-
return {
|
|
532
|
-
type: 'text',
|
|
533
|
-
id: annotation.id,
|
|
534
|
-
label,
|
|
535
|
-
subtitle,
|
|
536
|
-
stroke: annotation.stroke,
|
|
537
|
-
fill: annotation.fill,
|
|
538
|
-
opacity: annotation.opacity,
|
|
539
|
-
zIndex: annotation.zIndex,
|
|
540
|
-
dot,
|
|
541
|
-
// Union of label + subtitle. `label.bounds` stays label-only -- the renderer
|
|
542
|
-
// sizes the `background` plate from that.
|
|
543
|
-
bounds: annotationBounds,
|
|
544
|
-
};
|
|
545
|
-
}
|
|
@@ -1,65 +0,0 @@
|
|
|
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
|
-
}
|