@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.
- package/dist/index.d.ts +91 -11
- package/dist/index.js +469 -138
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/index.ts +9 -0
- package/src/labels/__tests__/collision.test.ts +49 -1
- package/src/labels/collision.ts +39 -2
- package/src/labels/index.ts +1 -0
- package/src/layout/__tests__/text-measure.test.ts +7 -8
- package/src/layout/chrome.ts +5 -1
- package/src/layout/index.ts +3 -0
- package/src/layout/text-measure.ts +347 -38
- package/src/responsive/index.ts +5 -0
- package/src/responsive/metrics.ts +51 -0
- package/src/styles/tokens.css +4 -0
- package/src/theme/defaults.ts +6 -3
- package/src/types/index.ts +1 -0
- package/src/types/layout.ts +26 -0
|
@@ -1,20 +1,268 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Text measurement for environments without a DOM.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
36
|
-
return fontSize *
|
|
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
|
|
43
|
-
*
|
|
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
|
|
51
|
-
|
|
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
|
|
343
|
+
let maxWidth = 0;
|
|
54
344
|
for (const line of text.split('\n')) {
|
|
55
|
-
|
|
345
|
+
const w = measureLine(line);
|
|
346
|
+
if (w > maxWidth) maxWidth = w;
|
|
56
347
|
}
|
|
57
|
-
return
|
|
348
|
+
return maxWidth;
|
|
58
349
|
}
|
|
59
|
-
return text
|
|
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
|
+
}
|
package/src/responsive/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/styles/tokens.css
CHANGED
|
@@ -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;
|
package/src/theme/defaults.ts
CHANGED
|
@@ -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:
|
|
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
|
|
64
|
-
//
|
|
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,
|
package/src/types/index.ts
CHANGED
package/src/types/layout.ts
CHANGED
|
@@ -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. */
|