@opendata-ai/openchart-core 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.
Files changed (58) hide show
  1. package/dist/index.d.ts +1154 -119
  2. package/dist/index.js +533 -42
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +2 -1
  5. package/dist/styles.css.map +1 -0
  6. package/package.json +9 -3
  7. package/schema/chart.schema.json +8957 -0
  8. package/schema/table.schema.json +861 -0
  9. package/schema/vizspec.schema.json +17694 -0
  10. package/src/accessibility/alt-text.ts +39 -1
  11. package/src/accessibility/aria.ts +4 -2
  12. package/src/colors/__tests__/colorblind.test.ts +12 -0
  13. package/src/colors/colorblind.ts +11 -2
  14. package/src/colors/contrast.ts +11 -0
  15. package/src/colors/index.ts +9 -1
  16. package/src/colors/palettes.ts +47 -0
  17. package/src/helpers/__tests__/deep-merge.test.ts +54 -0
  18. package/src/helpers/__tests__/spec-builders.test.ts +3 -3
  19. package/src/helpers/deep-merge.ts +29 -0
  20. package/src/helpers/spec-builders.ts +7 -7
  21. package/src/index.ts +29 -1
  22. package/src/labels/collision.ts +1 -0
  23. package/src/layout/chrome.ts +24 -0
  24. package/src/layout/index.ts +1 -1
  25. package/src/locale/__tests__/format.test.ts +347 -11
  26. package/src/locale/format.ts +243 -18
  27. package/src/locale/index.ts +10 -1
  28. package/src/responsive/index.ts +2 -0
  29. package/src/responsive/metrics.ts +77 -10
  30. package/src/schema/__tests__/generated-artifacts.test.ts +42 -0
  31. package/src/schema/__tests__/schema.test.ts +179 -0
  32. package/src/styles/__tests__/reduced-motion-selectors.test.ts +28 -0
  33. package/src/styles/animation.css +197 -168
  34. package/src/styles/base.css +89 -62
  35. package/src/styles/chrome.css +173 -179
  36. package/src/styles/dark.css +38 -35
  37. package/src/styles/graph.css +116 -111
  38. package/src/styles/index.css +5 -0
  39. package/src/styles/keyframes.css +115 -100
  40. package/src/styles/legend.css +14 -12
  41. package/src/styles/reduced-motion.css +61 -40
  42. package/src/styles/series-search.css +153 -0
  43. package/src/styles/sparkline.css +14 -12
  44. package/src/styles/table-animation.css +82 -80
  45. package/src/styles/table.css +314 -302
  46. package/src/styles/token-definitions.ts +199 -0
  47. package/src/styles/tokens.css +132 -128
  48. package/src/styles/tooltip.css +109 -115
  49. package/src/styles/you-draw-it.css +126 -0
  50. package/src/types/__tests__/encoding.test.ts +86 -17
  51. package/src/types/__tests__/spec.test.ts +16 -12
  52. package/src/types/encoding.ts +69 -25
  53. package/src/types/events.ts +12 -0
  54. package/src/types/index.ts +39 -3
  55. package/src/types/layout.ts +414 -8
  56. package/src/types/schema-roots.ts +32 -0
  57. package/src/types/spec.ts +643 -84
  58. package/src/types/table.ts +3 -2
@@ -40,8 +40,46 @@ export function generateAltText(spec: ChartSpec, data: DataRow[]): string {
40
40
  parts.push(`showing ${titleText}`);
41
41
  }
42
42
 
43
+ // Waffle: name the unit framing. Per-category "x of N units" phrasing
44
+ // lives on the mark aria labels; the chart-level text carries the total.
45
+ if (markType === 'waffle') {
46
+ const units = markDef.units ?? 100;
47
+ parts.push(`dividing ${units} units`);
48
+ }
49
+
50
+ // Parliament: name the total seat count and the majority threshold, the two
51
+ // numbers a reader most wants. Per-party seat counts live on the seat marks'
52
+ // aria labels; this carries the overall framing.
53
+ if (markType === 'parliament') {
54
+ const valueField = spec.encoding.theta?.field ?? spec.encoding.y?.field;
55
+ if (valueField) {
56
+ const totalSeats = data.reduce((sum, d) => {
57
+ const v = Number(d[valueField]);
58
+ return sum + (Number.isFinite(v) && v > 0 ? v : 0);
59
+ }, 0);
60
+ if (totalSeats > 0) {
61
+ const majority = Math.floor(totalSeats / 2) + 1;
62
+ parts.push(`with ${totalSeats} seats, ${majority} needed for a majority`);
63
+ }
64
+ }
65
+ }
66
+
67
+ // Beeswarm: name the distribution fields (value axis + optional lane).
68
+ // Skips the generic x-axis description below, which would either say
69
+ // nothing (quantitative value axis) or repeat the lane count.
70
+ if (markType === 'beeswarm') {
71
+ const xIsValue = spec.encoding.x?.type === 'quantitative';
72
+ const valueField = xIsValue ? spec.encoding.x?.field : spec.encoding.y?.field;
73
+ const laneField = xIsValue ? spec.encoding.y?.field : spec.encoding.x?.field;
74
+ if (valueField) {
75
+ parts.push(
76
+ `plotting the distribution of ${valueField}${laneField ? ` by ${laneField}` : ''}`,
77
+ );
78
+ }
79
+ }
80
+
43
81
  // Describe the data range
44
- if (spec.encoding.x && data.length > 0) {
82
+ if (spec.encoding.x && markType !== 'beeswarm' && data.length > 0) {
45
83
  const field = spec.encoding.x.field;
46
84
  const values = data.map((d) => d[field]).filter((v) => v != null);
47
85
 
@@ -5,6 +5,7 @@
5
5
  * users to explore data points individually.
6
6
  */
7
7
 
8
+ import { defaultNumberFormatter } from '../locale/format';
8
9
  import type { Mark } from '../types/layout';
9
10
 
10
11
  /**
@@ -62,12 +63,13 @@ export function generateAriaLabels(marks: Mark[]): Map<string, string> {
62
63
  return labels;
63
64
  }
64
65
 
65
- /** Format a data value for use in an ARIA label. */
66
+ const ariaNumberFormatter = defaultNumberFormatter();
67
+
66
68
  function formatValue(value: unknown): string {
67
69
  if (value == null) return 'N/A';
68
70
  if (value instanceof Date) return value.toISOString().slice(0, 10);
69
71
  if (typeof value === 'number') {
70
- return Number.isInteger(value) ? String(value) : value.toFixed(2);
72
+ return ariaNumberFormatter(value);
71
73
  }
72
74
  return String(value);
73
75
  }
@@ -42,6 +42,18 @@ describe('simulateColorBlindness', () => {
42
42
  expect(simRed).toMatch(/^#[0-9a-f]{6}$/i);
43
43
  expect(simGreen).toMatch(/^#[0-9a-f]{6}$/i);
44
44
  });
45
+
46
+ it('achromatopsia collapses every color to a grey', () => {
47
+ for (const color of ['#e15759', '#59a14f', '#1b7fa3']) {
48
+ const sim = simulateColorBlindness(color, 'achromatopsia');
49
+ const match = sim.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
50
+ expect(match).not.toBeNull();
51
+ if (match) {
52
+ const [r, g, b] = [parseInt(match[1], 16), parseInt(match[2], 16), parseInt(match[3], 16)];
53
+ expect(Math.max(r, g, b) - Math.min(r, g, b)).toBeLessThanOrEqual(1);
54
+ }
55
+ }
56
+ });
45
57
  });
46
58
 
47
59
  describe('checkPaletteDistinguishability', () => {
@@ -14,8 +14,8 @@ import { rgb } from 'd3-color';
14
14
  // Types
15
15
  // ---------------------------------------------------------------------------
16
16
 
17
- /** The three common types of color vision deficiency. */
18
- export type ColorBlindnessType = 'protanopia' | 'deuteranopia' | 'tritanopia';
17
+ /** The four common types of color vision deficiency. */
18
+ export type ColorBlindnessType = 'protanopia' | 'deuteranopia' | 'tritanopia' | 'achromatopsia';
19
19
 
20
20
  // ---------------------------------------------------------------------------
21
21
  // Simulation matrices
@@ -49,10 +49,19 @@ const TRITAN_MATRIX: Matrix3x3 = [
49
49
  [0.0, 0.475, 0.525],
50
50
  ];
51
51
 
52
+ // Total color blindness: every channel collapses to Rec. 709 relative
53
+ // luminance, computed in linear RGB like the other matrices.
54
+ const ACHROMAT_MATRIX: Matrix3x3 = [
55
+ [0.2126, 0.7152, 0.0722],
56
+ [0.2126, 0.7152, 0.0722],
57
+ [0.2126, 0.7152, 0.0722],
58
+ ];
59
+
52
60
  const MATRICES: Record<ColorBlindnessType, Matrix3x3> = {
53
61
  protanopia: PROTAN_MATRIX,
54
62
  deuteranopia: DEUTAN_MATRIX,
55
63
  tritanopia: TRITAN_MATRIX,
64
+ achromatopsia: ACHROMAT_MATRIX,
56
65
  };
57
66
 
58
67
  // ---------------------------------------------------------------------------
@@ -28,6 +28,17 @@ export function relativeLuminance(color: string): number {
28
28
  // Public API
29
29
  // ---------------------------------------------------------------------------
30
30
 
31
+ /**
32
+ * Check whether a color string parses to a fully opaque color.
33
+ * Contrast ratios are only meaningful against opaque backgrounds; the
34
+ * default chart background ('transparent') defers to the host page and
35
+ * can't be checked at compile time.
36
+ */
37
+ export function isOpaqueColor(color: string): boolean {
38
+ const c = rgb(color);
39
+ return c != null && !Number.isNaN(c.r) && c.opacity === 1;
40
+ }
41
+
31
42
  /**
32
43
  * Compute the WCAG contrast ratio between two colors.
33
44
  * Returns a value between 1 (identical) and 21 (black on white).
@@ -8,7 +8,13 @@ export {
8
8
  simulateColorBlindness,
9
9
  } from './colorblind';
10
10
 
11
- export { contrastRatio, findAccessibleColor, meetsAA, pickLabelColor } from './contrast';
11
+ export {
12
+ contrastRatio,
13
+ findAccessibleColor,
14
+ isOpaqueColor,
15
+ meetsAA,
16
+ pickLabelColor,
17
+ } from './contrast';
12
18
  export type {
13
19
  CategoricalPalette,
14
20
  DivergingPalette,
@@ -20,9 +26,11 @@ export {
20
26
  DIVERGING_BROWN_TEAL,
21
27
  DIVERGING_PALETTES,
22
28
  DIVERGING_RED_BLUE,
29
+ resolveSchemeName,
23
30
  SEQUENTIAL_BLUE,
24
31
  SEQUENTIAL_GREEN,
25
32
  SEQUENTIAL_ORANGE,
26
33
  SEQUENTIAL_PALETTES,
27
34
  SEQUENTIAL_PURPLE,
35
+ SUPPORTED_SCHEME_NAMES,
28
36
  } from './palettes';
@@ -157,3 +157,50 @@ export const DIVERGING_PALETTES: Record<string, string[]> = {
157
157
  redBlue: [...DIVERGING_RED_BLUE.stops],
158
158
  brownTeal: [...DIVERGING_BROWN_TEAL.stops],
159
159
  };
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Scheme name resolution (spec-level `scale: { scheme }` support)
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /**
166
+ * Vega-Lite scheme-name aliases mapped onto the openchart palettes.
167
+ * Keys are lowercase. Only schemes with a faithful local equivalent are
168
+ * aliased; unknown names fail validation with the supported list rather
169
+ * than silently substituting an unrelated ramp.
170
+ */
171
+ const VL_SCHEME_ALIASES: Record<string, string> = {
172
+ blues: 'blue',
173
+ greens: 'green',
174
+ oranges: 'orange',
175
+ purples: 'purple',
176
+ teals: 'teal',
177
+ redblue: 'redBlue',
178
+ brownteal: 'brownTeal',
179
+ // ColorBrewer BrBG, which brownTeal mirrors
180
+ brownbluegreen: 'brownTeal',
181
+ category10: 'categorical',
182
+ tableau10: 'categorical',
183
+ };
184
+
185
+ /**
186
+ * Resolve a scheme name to its palette stops. Accepts openchart palette names
187
+ * (blue, green, orange, purple, teal, redBlue, brownTeal, categorical) and
188
+ * common Vega-Lite aliases (blues, greens, ..., redblue, category10).
189
+ * Case-insensitive. Returns undefined for unknown names.
190
+ */
191
+ export function resolveSchemeName(name: string): string[] | undefined {
192
+ const canonical =
193
+ SEQUENTIAL_PALETTES[name] || DIVERGING_PALETTES[name]
194
+ ? name
195
+ : (VL_SCHEME_ALIASES[name.toLowerCase()] ?? name.toLowerCase());
196
+ if (canonical === 'categorical') return [...CATEGORICAL_PALETTE];
197
+ return SEQUENTIAL_PALETTES[canonical] ?? DIVERGING_PALETTES[canonical];
198
+ }
199
+
200
+ /** Scheme names accepted by `scale: { scheme }`, for validation messages. */
201
+ export const SUPPORTED_SCHEME_NAMES: readonly string[] = [
202
+ ...Object.keys(SEQUENTIAL_PALETTES),
203
+ ...Object.keys(DIVERGING_PALETTES),
204
+ 'categorical',
205
+ ...Object.keys(VL_SCHEME_ALIASES),
206
+ ];
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { deepMergeSpec } from '../deep-merge';
3
+
4
+ describe('deepMergeSpec', () => {
5
+ it('merges nested plain objects key by key', () => {
6
+ const base = { encoding: { x: { field: 'a' }, y: { field: 'b' } }, chrome: { title: 'X' } };
7
+ const patch = { encoding: { y: { field: 'c' } } };
8
+ expect(deepMergeSpec(base, patch)).toEqual({
9
+ encoding: { x: { field: 'a' }, y: { field: 'c' } },
10
+ chrome: { title: 'X' },
11
+ });
12
+ });
13
+
14
+ it('replaces arrays wholesale rather than merging by index', () => {
15
+ const base = { annotations: [{ type: 'text', text: 'old' }] };
16
+ const patch = { annotations: [{ type: 'refline', y: 5 }] };
17
+ expect(deepMergeSpec(base, patch)).toEqual({
18
+ annotations: [{ type: 'refline', y: 5 }],
19
+ });
20
+ });
21
+
22
+ it('leaves target untouched when patch value is undefined', () => {
23
+ const base = { chrome: { title: 'X' } };
24
+ expect(deepMergeSpec(base, { chrome: { title: undefined } })).toEqual({
25
+ chrome: { title: 'X' },
26
+ });
27
+ });
28
+
29
+ it('overwrites with null explicitly', () => {
30
+ const base = { encoding: { color: { field: 'a' } } };
31
+ expect(deepMergeSpec(base, { encoding: { color: null } })).toEqual({
32
+ encoding: { color: null },
33
+ });
34
+ });
35
+
36
+ it('does not mutate the source objects', () => {
37
+ const base = { encoding: { x: { field: 'a' } } };
38
+ const patch = { encoding: { x: { field: 'b' } } };
39
+ const result = deepMergeSpec(base, patch);
40
+ expect(result).not.toBe(base);
41
+ expect(base.encoding.x.field).toBe('a');
42
+ });
43
+
44
+ it('cumulative patches compose left to right', () => {
45
+ let spec: Record<string, unknown> = { mark: 'line', encoding: { y: { field: 'a' } } };
46
+ const patches = [{ chrome: { title: 'Step 1' } }, { encoding: { y: { field: 'b' } } }];
47
+ for (const p of patches) spec = deepMergeSpec(spec, p);
48
+ expect(spec).toEqual({
49
+ mark: 'line',
50
+ encoding: { y: { field: 'b' } },
51
+ chrome: { title: 'Step 1' },
52
+ });
53
+ });
54
+ });
@@ -203,12 +203,12 @@ describe('columnChart', () => {
203
203
  // ---------------------------------------------------------------------------
204
204
 
205
205
  describe('pieChart', () => {
206
- it('maps category to color channel and value to y', () => {
206
+ it('maps category to color channel and value to theta', () => {
207
207
  const spec = pieChart(categoricalData, 'name', 'count');
208
208
 
209
209
  expect(spec.mark).toBe('arc');
210
- // Arc (pie) convention: value on y, category on color, no x
211
- expect(spec.encoding.y).toEqual({ field: 'count', type: 'quantitative' });
210
+ // Arc (pie) convention: value on theta (canonical), category on color, no x
211
+ expect(spec.encoding.theta).toEqual({ field: 'count', type: 'quantitative' });
212
212
  expect(spec.encoding.color).toEqual({ field: 'name', type: 'nominal' });
213
213
  expect(spec.encoding.x).toBeUndefined();
214
214
  });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Deep merge for applying partial patches onto a spec-shaped object.
3
+ *
4
+ * Used by the scrollytelling story layer (`vanilla/story`) to apply
5
+ * cumulative deep-partial spec patches onto a base spec. Arrays REPLACE
6
+ * rather than merge element-by-element: predictable behavior for things
7
+ * like `annotations` or `data`, where index-wise merging would silently
8
+ * produce nonsense. Plain objects merge key-by-key, recursively.
9
+ */
10
+ // biome-ignore lint/suspicious/noExplicitAny: recursive object merge requires dynamic typing
11
+ export function deepMergeSpec(target: any, source: any): any {
12
+ if (source === undefined) return target;
13
+ if (source === null) return source;
14
+ if (
15
+ typeof source !== 'object' ||
16
+ Array.isArray(source) ||
17
+ typeof target !== 'object' ||
18
+ target === null ||
19
+ Array.isArray(target)
20
+ ) {
21
+ return source;
22
+ }
23
+
24
+ const result = { ...target };
25
+ for (const key of Object.keys(source)) {
26
+ result[key] = deepMergeSpec(target[key], source[key]);
27
+ }
28
+ return result;
29
+ }
@@ -250,12 +250,12 @@ export function columnChart(
250
250
  /**
251
251
  * Create a pie chart spec.
252
252
  *
253
- * Convention: category maps to the color channel, value maps to y.
253
+ * Convention: category maps to the color channel, value maps to theta.
254
254
  * Pie charts have no x-axis.
255
255
  *
256
256
  * @param data - Array of data rows.
257
257
  * @param category - Category field (color channel, nominal).
258
- * @param value - Value field (y channel, quantitative).
258
+ * @param value - Value field (theta channel, quantitative).
259
259
  * @param options - Optional chrome, annotations, theme, etc.
260
260
  * Note: color option is ignored since category is used for color.
261
261
  */
@@ -268,9 +268,9 @@ export function pieChart(
268
268
  const categoryChannel = resolveField(category, data);
269
269
  const valueChannel = resolveField(value, data);
270
270
 
271
- // Pie charts: value on y, category on color (no x-axis)
271
+ // Pie charts: value on theta (canonical in v8), category on color (no x-axis)
272
272
  const encoding: Encoding = {
273
- y: valueChannel,
273
+ theta: valueChannel,
274
274
  color: categoryChannel,
275
275
  };
276
276
  if (options?.size && data) {
@@ -303,12 +303,12 @@ export function areaChart(
303
303
  /**
304
304
  * Create a donut chart spec.
305
305
  *
306
- * Convention: category maps to the color channel, value maps to y.
306
+ * Convention: category maps to the color channel, value maps to theta.
307
307
  * Donut charts have no x-axis.
308
308
  *
309
309
  * @param data - Array of data rows.
310
310
  * @param category - Category field (color channel, nominal).
311
- * @param value - Value field (y channel, quantitative).
311
+ * @param value - Value field (theta channel, quantitative).
312
312
  * @param options - Optional chrome, annotations, theme, etc.
313
313
  * Note: color option is ignored since category is used for color.
314
314
  */
@@ -322,7 +322,7 @@ export function donutChart(
322
322
  const valueChannel = resolveField(value, data);
323
323
 
324
324
  const encoding: Encoding = {
325
- y: valueChannel,
325
+ theta: valueChannel,
326
326
  color: categoryChannel,
327
327
  };
328
328
  if (options?.size && data) {
package/src/index.ts CHANGED
@@ -33,9 +33,12 @@ export {
33
33
  contrastRatio,
34
34
  DIVERGING_PALETTES,
35
35
  findAccessibleColor,
36
+ isOpaqueColor,
36
37
  meetsAA,
37
38
  pickLabelColor,
39
+ resolveSchemeName,
38
40
  SEQUENTIAL_PALETTES,
41
+ SUPPORTED_SCHEME_NAMES,
39
42
  simulateColorBlindness,
40
43
  } from './colors/index';
41
44
 
@@ -65,6 +68,8 @@ export {
65
68
  COMPACT_WIDTH,
66
69
  computeChrome,
67
70
  estimateTextWidth,
71
+ FOOTNOTE_LINE_HEIGHT,
72
+ footnoteBandHeight,
68
73
  heuristicMeasure,
69
74
  resolveMeasurer,
70
75
  textAscent,
@@ -110,11 +115,13 @@ export {
110
115
  MAX_LEFT_LABEL_FRACTION_DEFAULT,
111
116
  MAX_LEFT_LABEL_FRACTION_MEDIUM,
112
117
  MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
118
+ maxRotatedLabelWidth,
113
119
  NARROW_VIEWPORT_MAX,
114
120
  TICK_LABEL_OFFSET,
115
121
  TICK_LINE_HEIGHT_FACTOR,
116
122
  TOP_PAD_EXTRA_NARROW,
117
123
  TOP_PAD_NARROW_MAX,
124
+ truncateRotatedLabel,
118
125
  X_AXIS_BAND_HEIGHT,
119
126
  X_AXIS_ROTATED_EXTENT_CAP,
120
127
  X_AXIS_TITLE_BAND,
@@ -143,13 +150,21 @@ export {
143
150
  // Locale: number and date formatting
144
151
  // ---------------------------------------------------------------------------
145
152
 
146
- export type { DateGranularity } from './locale/index';
153
+ export type { DateGranularity, FieldFormatContext, NumberFormatter } from './locale/index';
147
154
  export {
148
155
  abbreviateNumber,
156
+ buildCompactStepFormatter,
149
157
  buildD3Formatter,
150
158
  buildTemporalFormatter,
159
+ computeFieldFormatContext,
160
+ defaultNumberFormatter,
161
+ formatCurrency,
151
162
  formatDate,
152
163
  formatNumber,
164
+ formatOrdinal,
165
+ formatPercent,
166
+ isYearContext,
167
+ resolveNumberFormatter,
153
168
  } from './locale/index';
154
169
 
155
170
  // ---------------------------------------------------------------------------
@@ -185,3 +200,16 @@ export {
185
200
  scatterChart,
186
201
  tileMap,
187
202
  } from './helpers/spec-builders';
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Helpers: deep-merge for spec patches (used by vanilla/story)
206
+ // ---------------------------------------------------------------------------
207
+
208
+ export { deepMergeSpec } from './helpers/deep-merge';
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // CSS token definitions (single source of truth for tokens.css / dark.css)
212
+ // ---------------------------------------------------------------------------
213
+
214
+ export type { CssTokenDef } from './styles/token-definitions';
215
+ export { CSS_TOKEN_ROOT_SELECTORS, CSS_TOKENS, cssTokenDefault } from './styles/token-definitions';
@@ -183,6 +183,7 @@ export function resolveCollisions(
183
183
  to: { x: label.anchorX, y: label.anchorY },
184
184
  stroke: label.style.fill,
185
185
  style: 'straight' as const,
186
+ arrow: false,
186
187
  }
187
188
  : undefined,
188
189
  });
@@ -156,6 +156,30 @@ function estimateLineCount(
156
156
  // Public API
157
157
  // ---------------------------------------------------------------------------
158
158
 
159
+ /**
160
+ * Line-height multiplier for the auto-thinning footnote list.
161
+ *
162
+ * Exported because the chrome renderer steps each footnote line down by it
163
+ * while the band height below is computed from it -- the two have to agree, or
164
+ * the last line drifts outside the band that was reserved for it.
165
+ */
166
+ export const FOOTNOTE_LINE_HEIGHT = 1.3;
167
+ /** Gap below the footnote list, separating it from the source row. */
168
+ const FOOTNOTE_BAND_GAP = 4;
169
+
170
+ /**
171
+ * Vertical space a footnote list of `count` lines occupies below the plot.
172
+ *
173
+ * Single source of truth, deliberately: the engine reserves this band in the
174
+ * bottom margin, the chrome renderer shifts the source row down by it, and the
175
+ * brand watermark shifts by it too. When those three disagree the footnotes
176
+ * land on top of the watermark, which is exactly the bug this centralizes away.
177
+ */
178
+ export function footnoteBandHeight(count: number, theme: ResolvedTheme): number {
179
+ if (count <= 0) return 0;
180
+ return count * theme.fonts.sizes.small * FOOTNOTE_LINE_HEIGHT + FOOTNOTE_BAND_GAP;
181
+ }
182
+
159
183
  /**
160
184
  * Compute resolved chrome layout from a Chrome spec and resolved theme.
161
185
  *
@@ -2,7 +2,7 @@
2
2
  * Layout module barrel export.
3
3
  */
4
4
 
5
- export { computeChrome } from './chrome';
5
+ export { computeChrome, FOOTNOTE_LINE_HEIGHT, footnoteBandHeight } from './chrome';
6
6
  export {
7
7
  BRAND_FONT_SIZE,
8
8
  BRAND_MIN_WIDTH,