@opendata-ai/openchart-core 7.2.4 → 7.4.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.
@@ -1,20 +1,268 @@
1
1
  /**
2
- * Heuristic text measurement for environments without a DOM.
2
+ * Text measurement for environments without a DOM.
3
3
  *
4
- * These are intentionally approximate. Adapters can provide a real
5
- * measureText function via CompileOptions for higher accuracy.
6
- * The engine uses the real function when available, falls back to
7
- * these heuristics when not.
4
+ * Uses per-character advance-width tables measured from Inter (the default
5
+ * chart font) for ~2-3% accuracy on Latin text at weights 400/700.
6
+ * Other fonts set via theme.fonts.family still get a better-than-flat-ratio
7
+ * approximation but no accuracy guarantee.
8
+ *
9
+ * Tables generated by scripts/generate-char-widths.mjs — regenerate, don't
10
+ * hand-edit.
8
11
  */
9
12
 
10
- /**
11
- * Average character width as a fraction of font size for Inter.
12
- * Inter is slightly wider than Helvetica, narrower than Courier.
13
- * This is a reasonable middle ground.
14
- */
15
- const AVG_CHAR_WIDTH_RATIO = 0.57;
13
+ import type { MeasureTextFn } from '../types/layout';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Per-character advance widths for Inter, normalized to fontSize = 1.
17
+ // Generated by scripts/generate-char-widths.mjs from Chromium canvas metrics.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const CHAR_WIDTHS_400: Record<string, number> = {
21
+ ' ': 0.278,
22
+ '!': 0.278,
23
+ '"': 0.355,
24
+ '#': 0.556,
25
+ $: 0.556,
26
+ '%': 0.889,
27
+ '&': 0.667,
28
+ "'": 0.191,
29
+ '(': 0.333,
30
+ ')': 0.333,
31
+ '*': 0.389,
32
+ '+': 0.584,
33
+ ',': 0.278,
34
+ '-': 0.333,
35
+ '.': 0.278,
36
+ '/': 0.278,
37
+ '0': 0.556,
38
+ '1': 0.556,
39
+ '2': 0.556,
40
+ '3': 0.556,
41
+ '4': 0.556,
42
+ '5': 0.556,
43
+ '6': 0.556,
44
+ '7': 0.556,
45
+ '8': 0.556,
46
+ '9': 0.556,
47
+ ':': 0.278,
48
+ ';': 0.278,
49
+ '<': 0.584,
50
+ '=': 0.584,
51
+ '>': 0.584,
52
+ '?': 0.556,
53
+ '@': 1.015,
54
+ A: 0.667,
55
+ B: 0.667,
56
+ C: 0.722,
57
+ D: 0.722,
58
+ E: 0.667,
59
+ F: 0.611,
60
+ G: 0.778,
61
+ H: 0.722,
62
+ I: 0.278,
63
+ J: 0.5,
64
+ K: 0.667,
65
+ L: 0.556,
66
+ M: 0.833,
67
+ N: 0.722,
68
+ O: 0.778,
69
+ P: 0.667,
70
+ Q: 0.778,
71
+ R: 0.722,
72
+ S: 0.667,
73
+ T: 0.611,
74
+ U: 0.722,
75
+ V: 0.667,
76
+ W: 0.944,
77
+ X: 0.667,
78
+ Y: 0.667,
79
+ Z: 0.611,
80
+ '[': 0.278,
81
+ '\\': 0.278,
82
+ ']': 0.278,
83
+ '^': 0.469,
84
+ _: 0.556,
85
+ '`': 0.333,
86
+ a: 0.556,
87
+ b: 0.556,
88
+ c: 0.5,
89
+ d: 0.556,
90
+ e: 0.556,
91
+ f: 0.278,
92
+ g: 0.556,
93
+ h: 0.556,
94
+ i: 0.222,
95
+ j: 0.222,
96
+ k: 0.5,
97
+ l: 0.222,
98
+ m: 0.833,
99
+ n: 0.556,
100
+ o: 0.556,
101
+ p: 0.556,
102
+ q: 0.556,
103
+ r: 0.333,
104
+ s: 0.5,
105
+ t: 0.278,
106
+ u: 0.556,
107
+ v: 0.5,
108
+ w: 0.722,
109
+ x: 0.5,
110
+ y: 0.5,
111
+ z: 0.5,
112
+ '{': 0.334,
113
+ '|': 0.26,
114
+ '}': 0.334,
115
+ '~': 0.584,
116
+ '₹': 0.52,
117
+ '₩': 1.022,
118
+ '¥': 0.556,
119
+ '¥': 1,
120
+ '€': 0.744,
121
+ '£': 0.556,
122
+ '¢': 0.556,
123
+ '§': 0.556,
124
+ '©': 0.737,
125
+ '®': 0.737,
126
+ '™': 1,
127
+ '°': 0.4,
128
+ '–': 0.556,
129
+ '—': 1,
130
+ '‘': 0.222,
131
+ '’': 0.222,
132
+ '“': 0.333,
133
+ '”': 0.333,
134
+ '…': 1,
135
+ '±': 0.549,
136
+ '×': 0.584,
137
+ '÷': 0.549,
138
+ };
139
+
140
+ const AVG_CHAR_WIDTH_400 = 0.53;
16
141
 
17
- /** Narrower characters (i, l, t, etc.) bring the average down. */
142
+ const CHAR_WIDTHS_700: Record<string, number> = {
143
+ ' ': 0.278,
144
+ '!': 0.333,
145
+ '"': 0.474,
146
+ '#': 0.556,
147
+ $: 0.556,
148
+ '%': 0.889,
149
+ '&': 0.722,
150
+ "'": 0.238,
151
+ '(': 0.333,
152
+ ')': 0.333,
153
+ '*': 0.389,
154
+ '+': 0.584,
155
+ ',': 0.278,
156
+ '-': 0.333,
157
+ '.': 0.278,
158
+ '/': 0.278,
159
+ '0': 0.556,
160
+ '1': 0.556,
161
+ '2': 0.556,
162
+ '3': 0.556,
163
+ '4': 0.556,
164
+ '5': 0.556,
165
+ '6': 0.556,
166
+ '7': 0.556,
167
+ '8': 0.556,
168
+ '9': 0.556,
169
+ ':': 0.333,
170
+ ';': 0.333,
171
+ '<': 0.584,
172
+ '=': 0.584,
173
+ '>': 0.584,
174
+ '?': 0.611,
175
+ '@': 0.975,
176
+ A: 0.722,
177
+ B: 0.722,
178
+ C: 0.722,
179
+ D: 0.722,
180
+ E: 0.667,
181
+ F: 0.611,
182
+ G: 0.778,
183
+ H: 0.722,
184
+ I: 0.278,
185
+ J: 0.556,
186
+ K: 0.722,
187
+ L: 0.611,
188
+ M: 0.833,
189
+ N: 0.722,
190
+ O: 0.778,
191
+ P: 0.667,
192
+ Q: 0.778,
193
+ R: 0.722,
194
+ S: 0.667,
195
+ T: 0.611,
196
+ U: 0.722,
197
+ V: 0.667,
198
+ W: 0.944,
199
+ X: 0.667,
200
+ Y: 0.667,
201
+ Z: 0.611,
202
+ '[': 0.333,
203
+ '\\': 0.278,
204
+ ']': 0.333,
205
+ '^': 0.584,
206
+ _: 0.556,
207
+ '`': 0.333,
208
+ a: 0.556,
209
+ b: 0.611,
210
+ c: 0.556,
211
+ d: 0.611,
212
+ e: 0.556,
213
+ f: 0.333,
214
+ g: 0.611,
215
+ h: 0.611,
216
+ i: 0.278,
217
+ j: 0.278,
218
+ k: 0.556,
219
+ l: 0.278,
220
+ m: 0.889,
221
+ n: 0.611,
222
+ o: 0.611,
223
+ p: 0.611,
224
+ q: 0.611,
225
+ r: 0.389,
226
+ s: 0.556,
227
+ t: 0.333,
228
+ u: 0.611,
229
+ v: 0.556,
230
+ w: 0.778,
231
+ x: 0.556,
232
+ y: 0.556,
233
+ z: 0.5,
234
+ '{': 0.389,
235
+ '|': 0.28,
236
+ '}': 0.389,
237
+ '~': 0.584,
238
+ '₹': 0.553,
239
+ '₩': 1.024,
240
+ '¥': 0.556,
241
+ '¥': 1,
242
+ '€': 0.744,
243
+ '£': 0.556,
244
+ '¢': 0.556,
245
+ '§': 0.556,
246
+ '©': 0.737,
247
+ '®': 0.737,
248
+ '™': 1,
249
+ '°': 0.4,
250
+ '–': 0.556,
251
+ '—': 1,
252
+ '‘': 0.278,
253
+ '’': 0.278,
254
+ '“': 0.5,
255
+ '”': 0.5,
256
+ '…': 1,
257
+ '±': 0.549,
258
+ '×': 0.584,
259
+ '÷': 0.549,
260
+ };
261
+
262
+ const AVG_CHAR_WIDTH_700 = 0.554;
263
+
264
+ // Weights < 650 scale the CHAR_WIDTHS_400 table; weights >= 650 scale
265
+ // CHAR_WIDTHS_700. The factor is 1.0 at each table's native weight.
18
266
  const WEIGHT_ADJUSTMENT: Record<number, number> = {
19
267
  100: 0.9,
20
268
  200: 0.92,
@@ -22,48 +270,88 @@ const WEIGHT_ADJUSTMENT: Record<number, number> = {
22
270
  400: 1.0,
23
271
  500: 1.02,
24
272
  600: 1.05,
25
- 700: 1.08,
273
+ 700: 1.0, // uses CHAR_WIDTHS_700 directly
26
274
  800: 1.1,
27
275
  900: 1.12,
28
276
  };
29
277
 
278
+ function resolveTable(fontWeight: number): {
279
+ table: Record<string, number>;
280
+ avg: number;
281
+ scale: number;
282
+ } {
283
+ if (fontWeight >= 650) {
284
+ return {
285
+ table: CHAR_WIDTHS_700,
286
+ avg: AVG_CHAR_WIDTH_700,
287
+ scale: WEIGHT_ADJUSTMENT[fontWeight] ?? 1.0,
288
+ };
289
+ }
290
+ return {
291
+ table: CHAR_WIDTHS_400,
292
+ avg: AVG_CHAR_WIDTH_400,
293
+ scale: WEIGHT_ADJUSTMENT[fontWeight] ?? 1.0,
294
+ };
295
+ }
296
+
297
+ function charWidthRatio(ch: string, table: Record<string, number>, avg: number): number {
298
+ const direct = table[ch];
299
+ if (direct !== undefined) return direct;
300
+ const code = ch.codePointAt(0) ?? 0;
301
+ // Combining marks (diacritics) have zero advance width
302
+ if ((code >= 0x0300 && code <= 0x036f) || (code >= 0x1ab0 && code <= 0x1aff)) return 0;
303
+ // CJK ideographs, kana, hangul, and fullwidth forms are full-em
304
+ if (
305
+ (code >= 0x1100 && code <= 0x11ff) ||
306
+ (code >= 0x2e80 && code <= 0x9fff) ||
307
+ (code >= 0xac00 && code <= 0xd7af) ||
308
+ (code >= 0xf900 && code <= 0xfaff) ||
309
+ (code >= 0xff00 && code <= 0xff60)
310
+ )
311
+ return 1.0;
312
+ return avg;
313
+ }
314
+
30
315
  /**
31
316
  * Estimate the average character width for a given font size and weight.
32
317
  * Used by both text width estimation and word-wrap line counting.
33
318
  */
34
319
  export function estimateCharWidth(fontSize: number, fontWeight = 400): number {
35
- const weightFactor = WEIGHT_ADJUSTMENT[fontWeight] ?? 1.0;
36
- return fontSize * AVG_CHAR_WIDTH_RATIO * weightFactor;
320
+ const { avg, scale } = resolveTable(fontWeight);
321
+ return fontSize * avg * scale;
37
322
  }
38
323
 
39
324
  /**
40
325
  * Estimate the rendered width of a text string.
41
326
  *
42
- * Uses a per-character average width based on font size, adjusted
43
- * for font weight. Accurate to within ~20% for Latin text in Inter.
44
- *
45
- * @param text - The text string to measure.
46
- * @param fontSize - Font size in pixels.
47
- * @param fontWeight - Font weight (100-900). Defaults to 400.
327
+ * Uses per-character advance widths measured from Inter (the default chart
328
+ * font). Accurate to ~2-3% for Latin text at weights 400/700; other weights
329
+ * are interpolated from the nearest table.
48
330
  */
49
331
  export function estimateTextWidth(text: string, fontSize: number, fontWeight = 400): number {
50
- const charWidth = estimateCharWidth(fontSize, fontWeight);
51
- // For multiline text, measure the longest line
332
+ const { table, avg, scale } = resolveTable(fontWeight);
333
+
334
+ const measureLine = (line: string): number => {
335
+ let sum = 0;
336
+ for (const ch of line) {
337
+ sum += charWidthRatio(ch, table, avg);
338
+ }
339
+ return sum * fontSize * scale;
340
+ };
341
+
52
342
  if (text.includes('\n')) {
53
- let maxLen = 0;
343
+ let maxWidth = 0;
54
344
  for (const line of text.split('\n')) {
55
- if (line.length > maxLen) maxLen = line.length;
345
+ const w = measureLine(line);
346
+ if (w > maxWidth) maxWidth = w;
56
347
  }
57
- return maxLen * charWidth;
348
+ return maxWidth;
58
349
  }
59
- return text.length * charWidth;
350
+ return measureLine(text);
60
351
  }
61
352
 
62
353
  /**
63
354
  * Width reserved for the "tryOpenData.ai" brand watermark in the bottom-right corner.
64
- * Accounts for ~14 chars at font size 12 with mixed weights, plus a gap
65
- * so adjacent text doesn't crowd it. Used by chrome and legend layout to avoid
66
- * overlapping the brand.
67
355
  */
68
356
  export const BRAND_RESERVE_WIDTH = 130;
69
357
 
@@ -73,10 +361,6 @@ export const BRAND_FONT_SIZE = 12;
73
361
  /** Minimum chart width to render the brand watermark (px). */
74
362
  export const BRAND_MIN_WIDTH = 120;
75
363
 
76
- // ---------------------------------------------------------------------------
77
- // Responsive breakpoints
78
- // ---------------------------------------------------------------------------
79
-
80
364
  /**
81
365
  * Width threshold below which layout tightens spacing (legend gaps, chrome
82
366
  * padding, label gaps) to maximize data area on small screens.
@@ -85,11 +369,36 @@ export const COMPACT_WIDTH = 420;
85
369
 
86
370
  /**
87
371
  * Estimate the rendered height of a text block.
88
- *
89
- * @param fontSize - Font size in pixels.
90
- * @param lineCount - Number of lines. Defaults to 1.
91
- * @param lineHeight - Line height multiplier. Defaults to 1.3.
92
372
  */
93
373
  export function estimateTextHeight(fontSize: number, lineCount = 1, lineHeight = 1.3): number {
94
374
  return fontSize * lineHeight * lineCount;
95
375
  }
376
+
377
+ /**
378
+ * Distance from the top of a text box to its alphabetic baseline.
379
+ *
380
+ * Layout stores text positions as top-edge coordinates; renderers convert to
381
+ * baseline coordinates with this ratio instead of `dominant-baseline: hanging`.
382
+ * WebKit computes the hanging baseline from different font metrics than Blink
383
+ * and never inherits dominant-baseline into tspans (WebKit bugs 139258 /
384
+ * 297455), which shifted whole titles above the container on iOS Safari.
385
+ * The 0.8 ratio matches Blink's rendered hanging position for typical UI
386
+ * fonts (hanging sits at ~0.8 x ascent, Inter ascent ~= 0.97em) and the
387
+ * Highcharts fontMetrics convention.
388
+ */
389
+ export function textAscent(fontSize: number): number {
390
+ return fontSize * 0.8;
391
+ }
392
+
393
+ // ---------------------------------------------------------------------------
394
+ // Measurer resolver — bridges heuristic ↔ injected canvas measurer
395
+ // ---------------------------------------------------------------------------
396
+
397
+ export const heuristicMeasure: MeasureTextFn = (text, fontSize, fontWeight) => ({
398
+ width: estimateTextWidth(text, fontSize, fontWeight),
399
+ height: estimateTextHeight(fontSize),
400
+ });
401
+
402
+ export function resolveMeasurer(measureText?: MeasureTextFn): MeasureTextFn {
403
+ return measureText ?? heuristicMeasure;
404
+ }
@@ -21,12 +21,14 @@ export {
21
21
  HEIGHT_CRAMPED_MAX,
22
22
  HEIGHT_SHORT_MAX,
23
23
  } from './breakpoints';
24
+ export type { XAxisExtentInput } from './metrics';
24
25
  export {
25
26
  AXIS_TITLE_GAP,
26
27
  AXIS_TITLE_OFFSET_COMPACT,
27
28
  AXIS_TITLE_OFFSET_DEFAULT,
28
29
  AXIS_TITLE_TRAILING_PAD,
29
30
  axisTitleOffset,
31
+ computeXAxisExtentFromLabels,
30
32
  getAxisTitleOffset,
31
33
  HPAD_COMPACT_FRACTION,
32
34
  HPAD_COMPACT_MIN,
@@ -41,4 +43,7 @@ export {
41
43
  TICK_LABEL_OFFSET,
42
44
  TOP_PAD_EXTRA_NARROW,
43
45
  TOP_PAD_NARROW_MAX,
46
+ X_AXIS_BAND_HEIGHT,
47
+ X_AXIS_TITLE_BAND,
48
+ X_AXIS_TITLE_BAND_ROTATED,
44
49
  } from './metrics';
@@ -8,6 +8,7 @@
8
8
  * LayoutStrategy is semantic (what to show), this module is metric (how much space).
9
9
  */
10
10
 
11
+ import { estimateTextWidth } from '../layout/text-measure';
11
12
  import { BREAKPOINT_COMPACT_MAX } from './breakpoints';
12
13
 
13
14
  // ---------------------------------------------------------------------------
@@ -153,3 +154,53 @@ export const MAX_LEFT_LABEL_FRACTION_MEDIUM = 0.55;
153
154
 
154
155
  /** Max fraction of container width reservable for left category labels (standard). */
155
156
  export const MAX_LEFT_LABEL_FRACTION_DEFAULT = 1;
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // X-axis extent (height below chart area)
160
+ // ---------------------------------------------------------------------------
161
+
162
+ export const X_AXIS_BAND_HEIGHT = 26;
163
+ export const X_AXIS_TITLE_BAND = 22;
164
+ export const X_AXIS_TITLE_BAND_ROTATED = 20;
165
+
166
+ export interface XAxisExtentInput {
167
+ labels: string[];
168
+ tickAngle?: number;
169
+ hasTitle: boolean;
170
+ tickFontSize: number;
171
+ tickFontWeight: number;
172
+ xAxisHeight?: number;
173
+ measure?: (text: string, fontSize: number, fontWeight: number) => number;
174
+ }
175
+
176
+ export function computeXAxisExtentFromLabels(input: XAxisExtentInput): number {
177
+ const measure = input.measure ?? estimateTextWidth;
178
+ const baseHeight = input.xAxisHeight ?? X_AXIS_BAND_HEIGHT;
179
+
180
+ if (input.tickAngle && Math.abs(input.tickAngle) > 10) {
181
+ const angleRad = Math.abs(input.tickAngle) * (Math.PI / 180);
182
+ let maxLabelWidth = 40;
183
+ for (const label of input.labels) {
184
+ const w = measure(label, input.tickFontSize, input.tickFontWeight);
185
+ if (w > maxLabelWidth) maxLabelWidth = w;
186
+ }
187
+ // Vertical extent of a rotated label = the projection of its bounding box
188
+ // onto the vertical axis: textWidth*|sin θ| (the tilted length) plus
189
+ // lineHeight*|cos θ| (the label's own height, which still contributes
190
+ // unless the text is fully vertical). Measuring only the sin term
191
+ // under-reserved space, letting rotated ticks spill into the source line.
192
+ const lineHeight = input.tickFontSize * 1.2;
193
+ // Small gap between the axis line and the top of the rotated label band.
194
+ // Mirrors the tick-mark-to-label offset used for flat labels; keeps the
195
+ // reservation matched to the drawn footprint across Blink and WebKit,
196
+ // whose rotated-glyph metrics differ by a couple of pixels.
197
+ const AXIS_TO_LABEL_GAP = 3;
198
+ const rotatedHeight = Math.min(
199
+ maxLabelWidth * Math.sin(angleRad) + lineHeight * Math.cos(angleRad) + AXIS_TO_LABEL_GAP,
200
+ 120,
201
+ );
202
+ return input.hasTitle ? rotatedHeight + X_AXIS_TITLE_BAND_ROTATED : rotatedHeight;
203
+ }
204
+
205
+ return input.hasTitle ? baseHeight + X_AXIS_TITLE_BAND : baseHeight;
206
+ }
@@ -14,6 +14,10 @@
14
14
  -webkit-font-smoothing: antialiased;
15
15
  -moz-osx-font-smoothing: grayscale;
16
16
 
17
+ /* Opt out of iOS text auto-inflation so measured widths match rendered text. */
18
+ -webkit-text-size-adjust: 100%;
19
+ text-size-adjust: 100%;
20
+
17
21
  --oc-font-family:
18
22
  "Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
19
23
  --oc-font-mono: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace;
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import { CATEGORICAL_PALETTE, DIVERGING_PALETTES, SEQUENTIAL_PALETTES } from '../colors/palettes';
13
+ import { X_AXIS_BAND_HEIGHT } from '../responsive/metrics';
13
14
  import type { Theme } from '../types/theme';
14
15
 
15
16
  /**
@@ -58,10 +59,12 @@ export const DEFAULT_THEME: Theme = {
58
59
  chromeToChart: 8,
59
60
  chartToFooter: 8,
60
61
  axisMargin: 6,
61
- xAxisHeight: 26,
62
+ xAxisHeight: X_AXIS_BAND_HEIGHT,
62
63
  // Gap (px) between the x-axis line and the TOP of the tick labels. The
63
- // renderer anchors x-tick labels at their top edge (hanging baseline), so
64
- // this is a literal top gap that holds regardless of font size.
64
+ // renderer treats this as a literal top gap and then shifts the label down
65
+ // by textAscent() to land it on the alphabetic baseline (rather than
66
+ // dominant-baseline:hanging, which WebKit positions from different metrics),
67
+ // so the gap holds regardless of font size.
65
68
  xAxisLabelPadding: 4,
66
69
  },
67
70
  borderRadius: 2,
@@ -54,6 +54,7 @@ export type {
54
54
  HeatmapTableCell,
55
55
  ImageTableCell,
56
56
  LegendEntry,
57
+ LegendEntryPosition,
57
58
  LegendLayout,
58
59
  LineMark,
59
60
  Margins,
@@ -109,6 +109,8 @@ export interface ResolvedChrome {
109
109
  byline?: ResolvedChromeElement;
110
110
  footer?: ResolvedChromeElement;
111
111
  brand?: ResolvedChromeElement;
112
+ /** Absolute y at which bottom-chrome relative offsets are anchored: area.y + area.height + x-axis extent. */
113
+ bottomAnchorY?: number;
112
114
  }
113
115
 
114
116
  // ---------------------------------------------------------------------------
@@ -176,6 +178,10 @@ export interface AxisLayout {
176
178
  * classic outside-the-area placement.
177
179
  */
178
180
  tickPosition?: 'inline' | 'gutter';
181
+ /** Extent in px beyond the chart-area edge occupied by tick labels + title. Engine-resolved from the final (post-thinning) tick set. */
182
+ extent?: number;
183
+ /** Absolute anchor for the axis title. Replaces renderer-side offset math. */
184
+ titlePosition?: { x: number; y: number; angle?: number };
179
185
  }
180
186
 
181
187
  // ---------------------------------------------------------------------------
@@ -546,6 +552,10 @@ export interface ResolvedLabel {
546
552
  background?: string;
547
553
  /** Whether to show the paint-order stroke halo. Default true. */
548
554
  halo?: boolean;
555
+ /** Engine-measured text-block bounds. Renderers should use this for background rects instead of re-estimating. */
556
+ bounds?: Rect;
557
+ /** Source index carried through from the LabelCandidate, if the caller set one. */
558
+ index?: number;
549
559
  }
550
560
 
551
561
  // ---------------------------------------------------------------------------
@@ -598,6 +608,8 @@ export interface ResolvedAnnotation {
598
608
  y: number;
599
609
  style: TextStyle;
600
610
  };
611
+ /** Full placed extent: label bounds unioned with subtitle bounds. Collision and placement source of truth. */
612
+ bounds?: Rect;
601
613
  }
602
614
 
603
615
  // ---------------------------------------------------------------------------
@@ -628,6 +640,16 @@ export interface BaseLegendLayout {
628
640
  labelStyle: TextStyle;
629
641
  }
630
642
 
643
+ /** Engine-resolved absolute position for a single legend entry. */
644
+ export interface LegendEntryPosition {
645
+ x: number;
646
+ y: number;
647
+ labelX: number;
648
+ labelY: number;
649
+ width: number;
650
+ row: number;
651
+ }
652
+
631
653
  /** Categorical legend with discrete swatch entries. */
632
654
  export interface CategoricalLegendLayout extends BaseLegendLayout {
633
655
  /** Discriminant for legend type. Omitted means categorical (backwards compat). */
@@ -642,6 +664,10 @@ export interface CategoricalLegendLayout extends BaseLegendLayout {
642
664
  entryGap: number;
643
665
  /** Fill color for the rounded chip background behind the colored bar. */
644
666
  swatchChipFill: string;
667
+ /** Engine-resolved absolute positions, parallel to `entries`. */
668
+ entryPositions?: LegendEntryPosition[];
669
+ /** Row advance used to build entryPositions (single source of truth). */
670
+ rowHeight?: number;
645
671
  }
646
672
 
647
673
  /** A color stop in a gradient legend. */