@opendata-ai/openchart-core 7.2.4 → 7.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-core",
3
- "version": "7.2.4",
3
+ "version": "7.3.0",
4
4
  "description": "Types, theme, colors, accessibility, and utilities for openchart",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
package/src/index.ts CHANGED
@@ -60,6 +60,8 @@ export {
60
60
  COMPACT_WIDTH,
61
61
  computeChrome,
62
62
  estimateTextWidth,
63
+ heuristicMeasure,
64
+ resolveMeasurer,
63
65
  wrapText,
64
66
  } from './layout/index';
65
67
 
@@ -76,6 +78,7 @@ export type {
76
78
  LabelMode,
77
79
  LayoutStrategy,
78
80
  LegendPosition,
81
+ XAxisExtentInput,
79
82
  } from './responsive/index';
80
83
  export {
81
84
  AXIS_TITLE_GAP,
@@ -85,6 +88,7 @@ export {
85
88
  axisTitleOffset,
86
89
  BREAKPOINT_COMPACT_MAX,
87
90
  BREAKPOINT_MEDIUM_MAX,
91
+ computeXAxisExtentFromLabels,
88
92
  getAxisTitleOffset,
89
93
  getBreakpoint,
90
94
  getHeightClass,
@@ -104,6 +108,9 @@ export {
104
108
  TICK_LABEL_OFFSET,
105
109
  TOP_PAD_EXTRA_NARROW,
106
110
  TOP_PAD_NARROW_MAX,
111
+ X_AXIS_BAND_HEIGHT,
112
+ X_AXIS_TITLE_BAND,
113
+ X_AXIS_TITLE_BAND_ROTATED,
107
114
  } from './responsive/index';
108
115
 
109
116
  // ---------------------------------------------------------------------------
@@ -120,6 +127,7 @@ export {
120
127
  detectCollision,
121
128
  EXTENDED_OFFSET_STRATEGIES,
122
129
  OFFSET_STRATEGIES,
130
+ overlapArea,
123
131
  resolveCollisions,
124
132
  } from './labels/index';
125
133
 
@@ -3,7 +3,6 @@
3
3
  *
4
4
  * Greedy algorithm: sort by priority, place in order, try offset
5
5
  * positions for conflicts, demote to tooltip-only if no position works.
6
- * Targeting ~60% of Infrographic quality for Phase 0.
7
6
  */
8
7
 
9
8
  import { estimateTextWidth } from '../layout/text-measure';
@@ -55,6 +54,13 @@ export function detectCollision(a: Rect, b: Rect): boolean {
55
54
  return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
56
55
  }
57
56
 
57
+ /** Compute the area of intersection between two axis-aligned rectangles. Returns 0 if disjoint. */
58
+ export function overlapArea(a: Rect, b: Rect): number {
59
+ const w = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x);
60
+ const h = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y);
61
+ return w > 0 && h > 0 ? w * h : 0;
62
+ }
63
+
58
64
  // ---------------------------------------------------------------------------
59
65
  // Offset strategies
60
66
  // ---------------------------------------------------------------------------
@@ -8,5 +8,6 @@ export {
8
8
  detectCollision,
9
9
  EXTENDED_OFFSET_STRATEGIES,
10
10
  OFFSET_STRATEGIES,
11
+ overlapArea,
11
12
  resolveCollisions,
12
13
  } from './collision';
@@ -7,8 +7,8 @@ describe('estimateTextWidth', () => {
7
7
  });
8
8
 
9
9
  it('scales linearly with text length', () => {
10
- const w5 = estimateTextWidth('hello', 14);
11
- const w10 = estimateTextWidth('helloworld', 14);
10
+ const w5 = estimateTextWidth('abcde', 14);
11
+ const w10 = estimateTextWidth('abcdeabcde', 14);
12
12
  expect(w10).toBeCloseTo(w5 * 2, 1);
13
13
  });
14
14
 
@@ -31,13 +31,12 @@ describe('estimateTextWidth', () => {
31
31
  expect(width).toBeLessThan(250);
32
32
  });
33
33
 
34
- // Characterization test (refactor/v7-cohesion step 1):
35
- // Pins the AVG_CHAR_WIDTH_RATIO = 0.57 constant introduced in commit e7b98f9.
36
- // Any future tuning of the ratio changes layout-wide text wrapping decisions,
37
- // so we freeze the exact numeric output of the canonical call.
34
+ // Pins the per-character width table output. Any change to the tables
35
+ // shifts layout-wide text wrapping decisions, so we freeze the exact value.
38
36
  it('estimateTextWidth("sample", 14, 400) returns the locked numeric value', () => {
39
- // 6 chars * (14 * 0.57 * 1.0) = 6 * 7.98 = 47.88
40
- expect(estimateTextWidth('sample', 14, 400)).toBeCloseTo(47.88, 5);
37
+ // Per-char lookup: s(0.5) + a(0.556) + m(0.833) + p(0.556) + l(0.222) + e(0.556) = 3.223
38
+ // 3.223 * 14 * 1.0 = 45.122
39
+ expect(estimateTextWidth('sample', 14, 400)).toBeCloseTo(45.122, 2);
41
40
  });
42
41
  });
43
42
 
@@ -11,5 +11,7 @@ export {
11
11
  estimateCharWidth,
12
12
  estimateTextHeight,
13
13
  estimateTextWidth,
14
+ heuristicMeasure,
15
+ resolveMeasurer,
14
16
  } from './text-measure';
15
17
  export { wrapText } from './text-wrap';
@@ -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
+ };
16
139
 
17
- /** Narrower characters (i, l, t, etc.) bring the average down. */
140
+ const AVG_CHAR_WIDTH_400 = 0.53;
141
+
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,20 @@ 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
+ // Measurer resolver — bridges heuristic ↔ injected canvas measurer
379
+ // ---------------------------------------------------------------------------
380
+
381
+ export const heuristicMeasure: MeasureTextFn = (text, fontSize, fontWeight) => ({
382
+ width: estimateTextWidth(text, fontSize, fontWeight),
383
+ height: estimateTextHeight(fontSize),
384
+ });
385
+
386
+ export function resolveMeasurer(measureText?: MeasureTextFn): MeasureTextFn {
387
+ return measureText ?? heuristicMeasure;
388
+ }
@@ -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,39 @@ 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
+ const rotatedHeight = Math.min(maxLabelWidth * Math.sin(angleRad) + 6, 120);
188
+ return input.hasTitle ? rotatedHeight + X_AXIS_TITLE_BAND_ROTATED : rotatedHeight;
189
+ }
190
+
191
+ return input.hasTitle ? baseHeight + X_AXIS_TITLE_BAND : baseHeight;
192
+ }
@@ -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,7 +59,7 @@ 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
64
  // renderer anchors x-tick labels at their top edge (hanging baseline), so
64
65
  // this is a literal top gap that holds regardless of font size.
@@ -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,