@opendata-ai/openchart-core 7.11.0 → 8.0.0-rc.10
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 +1348 -132
- package/dist/index.js +556 -48
- package/dist/index.js.map +1 -1
- package/dist/styles.css +2 -1
- package/dist/styles.css.map +1 -0
- package/package.json +9 -3
- package/schema/chart.schema.json +9086 -0
- package/schema/table.schema.json +861 -0
- package/schema/vizspec.schema.json +17922 -0
- package/src/accessibility/alt-text.ts +39 -1
- package/src/accessibility/aria.ts +4 -2
- package/src/colors/__tests__/colorblind.test.ts +12 -0
- package/src/colors/colorblind.ts +11 -2
- package/src/colors/contrast.ts +11 -0
- package/src/colors/index.ts +9 -1
- package/src/colors/palettes.ts +47 -0
- package/src/helpers/__tests__/deep-merge.test.ts +54 -0
- package/src/helpers/__tests__/spec-builders.test.ts +3 -3
- package/src/helpers/deep-merge.ts +29 -0
- package/src/helpers/spec-builders.ts +7 -7
- package/src/index.ts +29 -1
- package/src/labels/collision.ts +1 -0
- package/src/layout/chrome.ts +24 -0
- package/src/layout/index.ts +1 -1
- package/src/locale/__tests__/format.test.ts +347 -11
- package/src/locale/format.ts +243 -18
- package/src/locale/index.ts +10 -1
- package/src/responsive/breakpoints.ts +7 -2
- package/src/responsive/index.ts +2 -0
- package/src/responsive/metrics.ts +77 -10
- package/src/schema/__tests__/generated-artifacts.test.ts +42 -0
- package/src/schema/__tests__/schema.test.ts +179 -0
- package/src/styles/__tests__/reduced-motion-selectors.test.ts +28 -0
- package/src/styles/animation.css +211 -168
- package/src/styles/base.css +89 -62
- package/src/styles/chrome.css +173 -179
- package/src/styles/dark.css +38 -35
- package/src/styles/graph.css +116 -111
- package/src/styles/index.css +5 -0
- package/src/styles/keyframes.css +130 -99
- package/src/styles/legend.css +14 -12
- package/src/styles/reduced-motion.css +61 -40
- package/src/styles/series-search.css +153 -0
- package/src/styles/sparkline.css +14 -12
- package/src/styles/table-animation.css +82 -80
- package/src/styles/table.css +314 -302
- package/src/styles/token-definitions.ts +199 -0
- package/src/styles/tokens.css +132 -128
- package/src/styles/tooltip.css +109 -115
- package/src/styles/you-draw-it.css +126 -0
- package/src/types/__tests__/encoding.test.ts +86 -17
- package/src/types/__tests__/spec.test.ts +16 -12
- package/src/types/encoding.ts +69 -25
- package/src/types/events.ts +107 -13
- package/src/types/index.ts +42 -3
- package/src/types/layout.ts +484 -11
- package/src/types/schema-roots.ts +32 -0
- package/src/types/spec.ts +718 -87
- package/src/types/table.ts +3 -2
package/src/locale/format.ts
CHANGED
|
@@ -23,10 +23,12 @@ import { timeFormat, utcFormat } from 'd3-time-format';
|
|
|
23
23
|
*/
|
|
24
24
|
export function formatNumber(value: number, _locale?: string): string {
|
|
25
25
|
if (!Number.isFinite(value)) return String(value);
|
|
26
|
-
// Auto-detect: use integer format for whole numbers, 2dp for decimals
|
|
27
26
|
if (Number.isInteger(value)) {
|
|
28
27
|
return d3Format(',')(value);
|
|
29
28
|
}
|
|
29
|
+
if (Math.abs(value) < 0.005) {
|
|
30
|
+
return d3Format('.2~r')(value);
|
|
31
|
+
}
|
|
30
32
|
return d3Format(',.2f')(value);
|
|
31
33
|
}
|
|
32
34
|
|
|
@@ -34,41 +36,75 @@ export function formatNumber(value: number, _locale?: string): string {
|
|
|
34
36
|
// Number abbreviation
|
|
35
37
|
// ---------------------------------------------------------------------------
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
const ABBREVIATIONS: Array<{ threshold: number; suffix: string; divisor: number }> = [
|
|
39
|
+
const UNIT_TABLE: Array<{ threshold: number; suffix: string; divisor: number }> = [
|
|
39
40
|
{ threshold: 1_000_000_000_000, suffix: 'T', divisor: 1_000_000_000_000 },
|
|
40
41
|
{ threshold: 1_000_000_000, suffix: 'B', divisor: 1_000_000_000 },
|
|
41
42
|
{ threshold: 1_000_000, suffix: 'M', divisor: 1_000_000 },
|
|
42
|
-
{ threshold: 1_000, suffix: '
|
|
43
|
+
{ threshold: 1_000, suffix: 'k', divisor: 1_000 },
|
|
43
44
|
];
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
46
|
+
function unitFor(abs: number): { suffix: string; divisor: number } {
|
|
47
|
+
for (const entry of UNIT_TABLE) {
|
|
48
|
+
if (abs >= entry.threshold) return entry;
|
|
49
|
+
}
|
|
50
|
+
return { suffix: '', divisor: 1 };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fractionDigits(x: number): number {
|
|
54
|
+
const a = Math.abs(x);
|
|
55
|
+
for (let p = 0; p <= 3; p++) {
|
|
56
|
+
const scaled = a * 10 ** p;
|
|
57
|
+
if (Math.abs(scaled - Math.round(scaled)) < 1e-9) return p;
|
|
58
|
+
}
|
|
59
|
+
return 4;
|
|
60
|
+
}
|
|
61
|
+
|
|
54
62
|
export function abbreviateNumber(value: number): string {
|
|
55
63
|
if (!Number.isFinite(value)) return String(value);
|
|
56
64
|
|
|
57
65
|
const absValue = Math.abs(value);
|
|
58
66
|
const sign = value < 0 ? '-' : '';
|
|
59
67
|
|
|
60
|
-
for (
|
|
68
|
+
for (let i = 0; i < UNIT_TABLE.length; i++) {
|
|
69
|
+
const { threshold, suffix, divisor } = UNIT_TABLE[i];
|
|
61
70
|
if (absValue >= threshold) {
|
|
62
|
-
const
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
71
|
+
const mantissa = d3Format('.1~f')(absValue / divisor);
|
|
72
|
+
// Roll-up: if mantissa rounds to 1000, use the next unit up.
|
|
73
|
+
// When i === 0 (T), there's no larger unit so we emit "1000T".
|
|
74
|
+
if (mantissa === '1000' && i > 0) {
|
|
75
|
+
return `${sign}1${UNIT_TABLE[i - 1].suffix}`;
|
|
76
|
+
}
|
|
77
|
+
return `${sign}${mantissa}${suffix}`;
|
|
66
78
|
}
|
|
67
79
|
}
|
|
68
80
|
|
|
69
81
|
return formatNumber(value);
|
|
70
82
|
}
|
|
71
83
|
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Ordinal formatting
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Format a number as an English ordinal: 1 -> "1st", 2 -> "2nd", 3 -> "3rd",
|
|
90
|
+
* 11 -> "11th", 22 -> "22nd". Values are rounded to the nearest integer first;
|
|
91
|
+
* intended for rank axes where the domain is integer positions.
|
|
92
|
+
*/
|
|
93
|
+
export function formatOrdinal(value: number): string {
|
|
94
|
+
if (!Number.isFinite(value)) return String(value);
|
|
95
|
+
const n = Math.round(value);
|
|
96
|
+
const abs = Math.abs(n);
|
|
97
|
+
const rem100 = abs % 100;
|
|
98
|
+
const rem10 = abs % 10;
|
|
99
|
+
let suffix: string;
|
|
100
|
+
if (rem100 >= 11 && rem100 <= 13) suffix = 'th';
|
|
101
|
+
else if (rem10 === 1) suffix = 'st';
|
|
102
|
+
else if (rem10 === 2) suffix = 'nd';
|
|
103
|
+
else if (rem10 === 3) suffix = 'rd';
|
|
104
|
+
else suffix = 'th';
|
|
105
|
+
return `${n}${suffix}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
72
108
|
// ---------------------------------------------------------------------------
|
|
73
109
|
// d3-format with suffix support
|
|
74
110
|
// ---------------------------------------------------------------------------
|
|
@@ -89,11 +125,17 @@ const D3_FORMAT_SUFFIX_RE = /^(.*~?[efgsrdxXobcnp%])(.+)$/;
|
|
|
89
125
|
* Build a number formatter from a d3-format string, with support for a
|
|
90
126
|
* trailing literal suffix that d3-format itself would reject.
|
|
91
127
|
*
|
|
128
|
+
* Also accepts the OpenChart extension `'ordinal'`, which renders English
|
|
129
|
+
* ordinals ("1st", "2nd", "3rd") for rank axes.
|
|
130
|
+
*
|
|
92
131
|
* Returns null if the format string is falsy or unparseable.
|
|
93
132
|
*/
|
|
94
133
|
export function buildD3Formatter(formatStr: string | undefined): ((v: number) => string) | null {
|
|
95
134
|
if (!formatStr) return null;
|
|
96
135
|
|
|
136
|
+
// OpenChart extension: rank-axis ordinals ("1st", "2nd", "3rd").
|
|
137
|
+
if (formatStr === 'ordinal') return formatOrdinal;
|
|
138
|
+
|
|
97
139
|
try {
|
|
98
140
|
const fmt = d3Format(formatStr);
|
|
99
141
|
// Replace SI prefix "G" (giga) with "B" (billion) for financial readability
|
|
@@ -117,6 +159,189 @@ export function buildD3Formatter(formatStr: string | undefined): ((v: number) =>
|
|
|
117
159
|
return null;
|
|
118
160
|
}
|
|
119
161
|
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Number formatter type + semantic formatters
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
export type NumberFormatter = (value: number) => string;
|
|
167
|
+
|
|
168
|
+
export function formatPercent(value: number, options?: { fraction?: boolean }): string {
|
|
169
|
+
const fraction = options?.fraction ?? true;
|
|
170
|
+
if (fraction) {
|
|
171
|
+
return d3Format('.1~%')(value);
|
|
172
|
+
}
|
|
173
|
+
return `${d3Format(',.1~f')(value)}%`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function formatCurrency(value: number, options?: { compact?: boolean }): string {
|
|
177
|
+
const compact = options?.compact ?? false;
|
|
178
|
+
const abs = Math.abs(value);
|
|
179
|
+
const sign = value < 0 ? '-' : '';
|
|
180
|
+
|
|
181
|
+
if (compact) {
|
|
182
|
+
if (abs >= 1000) {
|
|
183
|
+
return `${sign}$${abbreviateNumber(abs)}`;
|
|
184
|
+
}
|
|
185
|
+
return `${sign}$${formatNumber(abs)}`;
|
|
186
|
+
}
|
|
187
|
+
if (Number.isInteger(value)) {
|
|
188
|
+
return sign + d3Format('$,')(abs);
|
|
189
|
+
}
|
|
190
|
+
return sign + d3Format('$,.2f')(abs);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Years guard
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
export function isYearLikeValues(values: readonly number[]): boolean {
|
|
198
|
+
if (values.length === 0) return false;
|
|
199
|
+
for (const v of values) {
|
|
200
|
+
if (!Number.isFinite(v) || !Number.isInteger(v) || v < 1500 || v > 2500) return false;
|
|
201
|
+
}
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Field format context
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
export interface FieldFormatContext {
|
|
210
|
+
extent?: [number, number];
|
|
211
|
+
allIntegers?: boolean;
|
|
212
|
+
surface?: 'chart' | 'table';
|
|
213
|
+
step?: number;
|
|
214
|
+
stepReference?: number;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function computeFieldFormatContext(
|
|
218
|
+
values: Iterable<unknown>,
|
|
219
|
+
surface?: 'chart' | 'table',
|
|
220
|
+
): FieldFormatContext {
|
|
221
|
+
let min = Infinity;
|
|
222
|
+
let max = -Infinity;
|
|
223
|
+
let allIntegers = true;
|
|
224
|
+
let hasFinite = false;
|
|
225
|
+
|
|
226
|
+
for (const raw of values) {
|
|
227
|
+
const v = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN;
|
|
228
|
+
if (!Number.isFinite(v)) continue;
|
|
229
|
+
hasFinite = true;
|
|
230
|
+
if (v < min) min = v;
|
|
231
|
+
if (v > max) max = v;
|
|
232
|
+
if (allIntegers && !Number.isInteger(v)) allIntegers = false;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const ctx: FieldFormatContext = {};
|
|
236
|
+
if (hasFinite) {
|
|
237
|
+
ctx.extent = [min, max];
|
|
238
|
+
ctx.allIntegers = allIntegers;
|
|
239
|
+
}
|
|
240
|
+
if (surface) ctx.surface = surface;
|
|
241
|
+
return ctx;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function isYearContext(ctx: FieldFormatContext | undefined): boolean {
|
|
245
|
+
if (!ctx?.extent || !ctx.allIntegers) return false;
|
|
246
|
+
const [min, max] = ctx.extent;
|
|
247
|
+
return min >= 1500 && max <= 2500;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// Keyword-aware formatter resolution
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
export function resolveNumberFormatter(
|
|
255
|
+
formatStr: string | undefined,
|
|
256
|
+
ctx?: FieldFormatContext,
|
|
257
|
+
): NumberFormatter | null {
|
|
258
|
+
if (!formatStr) return null;
|
|
259
|
+
|
|
260
|
+
if (formatStr === 'ordinal') return formatOrdinal;
|
|
261
|
+
|
|
262
|
+
if (formatStr === 'percent') {
|
|
263
|
+
// Heuristic: if all values fit in [0,1] (maxAbs <= 1), treat them as
|
|
264
|
+
// fractions (0.45 -> "45%"). Values > 1 are treated as pre-scaled
|
|
265
|
+
// (45 -> "45%"). Datasets like [0, 0.5, 1.1] are ambiguous; the <= 1
|
|
266
|
+
// threshold errs on the side of the most common convention (fractions).
|
|
267
|
+
const fraction = ctx?.extent
|
|
268
|
+
? Math.max(Math.abs(ctx.extent[0]), Math.abs(ctx.extent[1])) <= 1
|
|
269
|
+
: true;
|
|
270
|
+
return (v: number) => formatPercent(v, { fraction });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (formatStr === 'currency') {
|
|
274
|
+
const isTable = ctx?.surface === 'table';
|
|
275
|
+
if (!isTable && ctx?.step != null && (ctx.stepReference ?? 0) >= 1000) {
|
|
276
|
+
const stepFmt = buildCompactStepFormatter(ctx.step);
|
|
277
|
+
return (v: number) => {
|
|
278
|
+
const sign = v < 0 ? '-' : '';
|
|
279
|
+
return `${sign}$${stepFmt(Math.abs(v))}`;
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
return (v: number) => formatCurrency(v, { compact: !isTable });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return buildD3Formatter(formatStr);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// Default number formatter
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
export function defaultNumberFormatter(ctx?: FieldFormatContext): NumberFormatter {
|
|
293
|
+
if (isYearContext(ctx)) {
|
|
294
|
+
return (v: number) => String(Math.round(v));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (ctx?.surface === 'table') {
|
|
298
|
+
return formatNumber;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (ctx?.step != null && (ctx.stepReference ?? maxAbs(ctx.extent)) >= 1000) {
|
|
302
|
+
return buildCompactStepFormatter(ctx.step);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return (v: number) => {
|
|
306
|
+
if (!Number.isFinite(v)) return String(v);
|
|
307
|
+
// When no context is provided (ARIA marks, standalone helpers) we
|
|
308
|
+
// fall back to a per-value year heuristic: integers in [1500, 2500]
|
|
309
|
+
// render bare. This trades false positives on non-year integers
|
|
310
|
+
// (e.g. "connections: 2024") for avoiding "2k" on actual year axes.
|
|
311
|
+
// Callers with field context should always pass it to get the
|
|
312
|
+
// field-level isYearContext check instead.
|
|
313
|
+
if (ctx?.extent === undefined && ctx?.allIntegers === undefined) {
|
|
314
|
+
if (Number.isInteger(v) && v >= 1500 && v <= 2500) return String(v);
|
|
315
|
+
}
|
|
316
|
+
return Math.abs(v) >= 1000 ? abbreviateNumber(v) : formatNumber(v);
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function maxAbs(extent: [number, number] | undefined): number {
|
|
321
|
+
if (!extent) return 0;
|
|
322
|
+
return Math.max(Math.abs(extent[0]), Math.abs(extent[1]));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// Step-aware compact tick formatter
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
export function buildCompactStepFormatter(step: number): NumberFormatter {
|
|
330
|
+
return (v: number) => {
|
|
331
|
+
if (v === 0) return '0';
|
|
332
|
+
if (!Number.isFinite(v)) return String(v);
|
|
333
|
+
const abs = Math.abs(v);
|
|
334
|
+
if (abs < 1000) return formatNumber(v);
|
|
335
|
+
const { suffix, divisor } = unitFor(abs);
|
|
336
|
+
const p = fractionDigits(step / divisor);
|
|
337
|
+
if (p > 2) {
|
|
338
|
+
return Number.isInteger(v) ? d3Format(',')(v) : d3Format(',.2f')(v);
|
|
339
|
+
}
|
|
340
|
+
const sign = v < 0 ? '-' : '';
|
|
341
|
+
return sign + d3Format(`.${p}~f`)(abs / divisor) + suffix;
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
120
345
|
// ---------------------------------------------------------------------------
|
|
121
346
|
// Date formatting
|
|
122
347
|
// ---------------------------------------------------------------------------
|
package/src/locale/index.ts
CHANGED
|
@@ -2,11 +2,20 @@
|
|
|
2
2
|
* Locale module barrel export.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
export type { DateGranularity } from './format';
|
|
5
|
+
export type { DateGranularity, FieldFormatContext, NumberFormatter } from './format';
|
|
6
6
|
export {
|
|
7
7
|
abbreviateNumber,
|
|
8
|
+
buildCompactStepFormatter,
|
|
8
9
|
buildD3Formatter,
|
|
9
10
|
buildTemporalFormatter,
|
|
11
|
+
computeFieldFormatContext,
|
|
12
|
+
defaultNumberFormatter,
|
|
13
|
+
formatCurrency,
|
|
10
14
|
formatDate,
|
|
11
15
|
formatNumber,
|
|
16
|
+
formatOrdinal,
|
|
17
|
+
formatPercent,
|
|
18
|
+
isYearContext,
|
|
19
|
+
isYearLikeValues,
|
|
20
|
+
resolveNumberFormatter,
|
|
12
21
|
} from './format';
|
|
@@ -59,8 +59,13 @@ export function getHeightClass(height: number): HeightClass {
|
|
|
59
59
|
/** Label display mode at a given breakpoint. */
|
|
60
60
|
export type LabelMode = 'all' | 'important' | 'none';
|
|
61
61
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Legend position at a given breakpoint. `'top-left'` is a map-only overlay
|
|
64
|
+
* position: the legend floats inside the map area's top-left corner (with its
|
|
65
|
+
* own backdrop) instead of reserving a block of figure height. Charts treat
|
|
66
|
+
* it as the default position.
|
|
67
|
+
*/
|
|
68
|
+
export type LegendPosition = 'top' | 'top-left' | 'right' | 'bottom' | 'bottom-right' | 'inline';
|
|
64
69
|
|
|
65
70
|
/** Annotation position strategy. */
|
|
66
71
|
export type AnnotationPosition = 'inline' | 'tooltip-only';
|
package/src/responsive/index.ts
CHANGED
|
@@ -39,11 +39,13 @@ export {
|
|
|
39
39
|
MAX_LEFT_LABEL_FRACTION_DEFAULT,
|
|
40
40
|
MAX_LEFT_LABEL_FRACTION_MEDIUM,
|
|
41
41
|
MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
|
|
42
|
+
maxRotatedLabelWidth,
|
|
42
43
|
NARROW_VIEWPORT_MAX,
|
|
43
44
|
TICK_LABEL_OFFSET,
|
|
44
45
|
TICK_LINE_HEIGHT_FACTOR,
|
|
45
46
|
TOP_PAD_EXTRA_NARROW,
|
|
46
47
|
TOP_PAD_NARROW_MAX,
|
|
48
|
+
truncateRotatedLabel,
|
|
47
49
|
X_AXIS_BAND_HEIGHT,
|
|
48
50
|
X_AXIS_ROTATED_EXTENT_CAP,
|
|
49
51
|
X_AXIS_TITLE_BAND,
|
|
@@ -174,10 +174,13 @@ export const X_AXIS_TITLE_BAND = 22;
|
|
|
174
174
|
export const X_AXIS_TITLE_BAND_ROTATED = 20;
|
|
175
175
|
|
|
176
176
|
/**
|
|
177
|
-
* Ceiling on the vertical extent reserved for rotated x tick labels.
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
177
|
+
* Ceiling on the vertical extent reserved for rotated x tick labels. The angle
|
|
178
|
+
* ladder (engine layout/axes/rotation) avoids -90° for labels longer than this.
|
|
179
|
+
*
|
|
180
|
+
* The cap bounds the RESERVATION, so a label whose rotated projection exceeds it
|
|
181
|
+
* must be truncated to fit — see `truncateRotatedLabel`. Reserving a capped band
|
|
182
|
+
* while drawing the full string is what let long ticks spill past the axis and
|
|
183
|
+
* collide with the source line.
|
|
181
184
|
*/
|
|
182
185
|
export const X_AXIS_ROTATED_EXTENT_CAP = 120;
|
|
183
186
|
|
|
@@ -189,6 +192,15 @@ export const X_AXIS_ROTATED_EXTENT_CAP = 120;
|
|
|
189
192
|
*/
|
|
190
193
|
export const TICK_LINE_HEIGHT_FACTOR = 1.2;
|
|
191
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Gap between the axis line and the top of the rotated label band. Mirrors the
|
|
197
|
+
* tick-mark-to-label offset used for flat labels; keeps the reservation matched
|
|
198
|
+
* to the drawn footprint across Blink and WebKit, whose rotated-glyph metrics
|
|
199
|
+
* differ by a couple of pixels. Shared by the extent reservation and the
|
|
200
|
+
* truncation budget so they stay in lockstep.
|
|
201
|
+
*/
|
|
202
|
+
const AXIS_TO_LABEL_GAP = 3;
|
|
203
|
+
|
|
192
204
|
export interface XAxisExtentInput {
|
|
193
205
|
labels: string[];
|
|
194
206
|
tickAngle?: number;
|
|
@@ -216,11 +228,6 @@ export function computeXAxisExtentFromLabels(input: XAxisExtentInput): number {
|
|
|
216
228
|
// unless the text is fully vertical). Measuring only the sin term
|
|
217
229
|
// under-reserved space, letting rotated ticks spill into the source line.
|
|
218
230
|
const lineHeight = input.tickFontSize * TICK_LINE_HEIGHT_FACTOR;
|
|
219
|
-
// Small gap between the axis line and the top of the rotated label band.
|
|
220
|
-
// Mirrors the tick-mark-to-label offset used for flat labels; keeps the
|
|
221
|
-
// reservation matched to the drawn footprint across Blink and WebKit,
|
|
222
|
-
// whose rotated-glyph metrics differ by a couple of pixels.
|
|
223
|
-
const AXIS_TO_LABEL_GAP = 3;
|
|
224
231
|
const rotatedHeight = Math.min(
|
|
225
232
|
maxLabelWidth * Math.sin(angleRad) + lineHeight * Math.cos(angleRad) + AXIS_TO_LABEL_GAP,
|
|
226
233
|
X_AXIS_ROTATED_EXTENT_CAP,
|
|
@@ -228,5 +235,65 @@ export function computeXAxisExtentFromLabels(input: XAxisExtentInput): number {
|
|
|
228
235
|
return input.hasTitle ? rotatedHeight + X_AXIS_TITLE_BAND_ROTATED : rotatedHeight;
|
|
229
236
|
}
|
|
230
237
|
|
|
231
|
-
|
|
238
|
+
const tickExtent = 4 + input.tickFontSize + Math.max(11, Math.ceil(input.tickFontSize * 0.7));
|
|
239
|
+
const flatHeight = Math.max(baseHeight, tickExtent);
|
|
240
|
+
return input.hasTitle ? flatHeight + X_AXIS_TITLE_BAND : flatHeight;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The widest a rotated tick label may be before its vertical projection exceeds
|
|
245
|
+
* `X_AXIS_ROTATED_EXTENT_CAP`.
|
|
246
|
+
*
|
|
247
|
+
* Inverts the rotated-extent formula in `computeXAxisExtentFromLabels`:
|
|
248
|
+
*
|
|
249
|
+
* extent = width*sin(θ) + lineHeight*cos(θ) + gap
|
|
250
|
+
* width = (cap - lineHeight*cos(θ) - gap) / sin(θ)
|
|
251
|
+
*
|
|
252
|
+
* Deriving it from the same terms (rather than hardcoding a second number) keeps
|
|
253
|
+
* the truncation budget and the space reservation locked together — if one
|
|
254
|
+
* changes, the other follows.
|
|
255
|
+
*/
|
|
256
|
+
export function maxRotatedLabelWidth(tickAngle: number, tickFontSize: number): number {
|
|
257
|
+
const angleRad = Math.abs(tickAngle) * (Math.PI / 180);
|
|
258
|
+
const sin = Math.sin(angleRad);
|
|
259
|
+
// A flat (or near-flat) label has no vertical projection to bound.
|
|
260
|
+
if (sin <= 0.001) return Number.POSITIVE_INFINITY;
|
|
261
|
+
const lineHeight = tickFontSize * TICK_LINE_HEIGHT_FACTOR;
|
|
262
|
+
const usable = X_AXIS_ROTATED_EXTENT_CAP - lineHeight * Math.cos(angleRad) - AXIS_TO_LABEL_GAP;
|
|
263
|
+
return Math.max(0, usable / sin);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Truncate a rotated tick label with an ellipsis so it fits inside the capped
|
|
268
|
+
* reservation. Returns the label unchanged when it already fits.
|
|
269
|
+
*
|
|
270
|
+
* Binary-search on character count against the real measure function, so it
|
|
271
|
+
* respects proportional fonts rather than assuming a fixed character width.
|
|
272
|
+
*/
|
|
273
|
+
export function truncateRotatedLabel(
|
|
274
|
+
label: string,
|
|
275
|
+
tickAngle: number,
|
|
276
|
+
tickFontSize: number,
|
|
277
|
+
tickFontWeight: number,
|
|
278
|
+
measure: (text: string, fontSize: number, fontWeight?: number) => number = estimateTextWidth,
|
|
279
|
+
): string {
|
|
280
|
+
const budget = maxRotatedLabelWidth(tickAngle, tickFontSize);
|
|
281
|
+
if (!Number.isFinite(budget)) return label;
|
|
282
|
+
if (measure(label, tickFontSize, tickFontWeight) <= budget) return label;
|
|
283
|
+
|
|
284
|
+
const ELLIPSIS = '…';
|
|
285
|
+
// Nothing sensible fits: keep a single character + ellipsis rather than
|
|
286
|
+
// returning an empty tick.
|
|
287
|
+
let lo = 0;
|
|
288
|
+
let hi = label.length;
|
|
289
|
+
while (lo < hi) {
|
|
290
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
291
|
+
const candidate = label.slice(0, mid) + ELLIPSIS;
|
|
292
|
+
if (measure(candidate, tickFontSize, tickFontWeight) <= budget) {
|
|
293
|
+
lo = mid;
|
|
294
|
+
} else {
|
|
295
|
+
hi = mid - 1;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return lo > 0 ? label.slice(0, lo).trimEnd() + ELLIPSIS : ELLIPSIS;
|
|
232
299
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke test that the generated LLM-facing artifacts cover every mark type.
|
|
3
|
+
*
|
|
4
|
+
* `scripts/generate-schema.mjs` and `scripts/generate-llms.mjs` emit the
|
|
5
|
+
* committed `packages/core/schema/*.json` and root `llms.txt`. This test
|
|
6
|
+
* asserts the two artifacts reference all 16 declared mark types, so adding a
|
|
7
|
+
* mark to MARK_TYPES without regenerating (or a generator regression that drops
|
|
8
|
+
* a mark) is caught here rather than shipping a schema/doc that silently omits
|
|
9
|
+
* it. CI's `check:generated` step guarantees the artifacts are fresh; this test
|
|
10
|
+
* guarantees they are complete.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { describe, expect, it } from 'vitest';
|
|
17
|
+
import { MARK_TYPES } from '../../types/spec';
|
|
18
|
+
|
|
19
|
+
const coreRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
|
20
|
+
const repoRoot = join(coreRoot, '..', '..');
|
|
21
|
+
|
|
22
|
+
const chartSchema = readFileSync(join(coreRoot, 'schema', 'chart.schema.json'), 'utf8');
|
|
23
|
+
const llmsTxt = readFileSync(join(repoRoot, 'llms.txt'), 'utf8');
|
|
24
|
+
|
|
25
|
+
describe('generated artifacts cover every mark type', () => {
|
|
26
|
+
it('chart.schema.json references all 16 mark types', () => {
|
|
27
|
+
for (const mark of MARK_TYPES) {
|
|
28
|
+
expect(chartSchema, `chart.schema.json is missing mark "${mark}"`).toContain(`"${mark}"`);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('llms.txt documents all 16 mark types in the grammar table', () => {
|
|
33
|
+
for (const mark of MARK_TYPES) {
|
|
34
|
+
// The generated mark table renders each mark as an inline-code cell.
|
|
35
|
+
expect(llmsTxt, `llms.txt is missing mark "${mark}"`).toContain(`\`${mark}\``);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('MARK_TYPES has exactly 16 entries (the surface this plan targets)', () => {
|
|
40
|
+
expect(MARK_TYPES.size).toBe(16);
|
|
41
|
+
});
|
|
42
|
+
});
|