@opendata-ai/openchart-core 7.10.0 → 8.0.0-rc.1
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 +1154 -119
- package/dist/index.js +530 -41
- 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 +8957 -0
- package/schema/table.schema.json +861 -0
- package/schema/vizspec.schema.json +17694 -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/index.ts +2 -0
- package/src/responsive/metrics.ts +74 -9
- 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 +197 -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 +115 -100
- 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 +12 -0
- package/src/types/index.ts +39 -3
- package/src/types/layout.ts +414 -8
- package/src/types/schema-roots.ts +32 -0
- package/src/types/spec.ts +643 -84
- 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';
|
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,
|
|
@@ -230,3 +237,61 @@ export function computeXAxisExtentFromLabels(input: XAxisExtentInput): number {
|
|
|
230
237
|
|
|
231
238
|
return input.hasTitle ? baseHeight + X_AXIS_TITLE_BAND : baseHeight;
|
|
232
239
|
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The widest a rotated tick label may be before its vertical projection exceeds
|
|
243
|
+
* `X_AXIS_ROTATED_EXTENT_CAP`.
|
|
244
|
+
*
|
|
245
|
+
* Inverts the rotated-extent formula in `computeXAxisExtentFromLabels`:
|
|
246
|
+
*
|
|
247
|
+
* extent = width*sin(θ) + lineHeight*cos(θ) + gap
|
|
248
|
+
* width = (cap - lineHeight*cos(θ) - gap) / sin(θ)
|
|
249
|
+
*
|
|
250
|
+
* Deriving it from the same terms (rather than hardcoding a second number) keeps
|
|
251
|
+
* the truncation budget and the space reservation locked together — if one
|
|
252
|
+
* changes, the other follows.
|
|
253
|
+
*/
|
|
254
|
+
export function maxRotatedLabelWidth(tickAngle: number, tickFontSize: number): number {
|
|
255
|
+
const angleRad = Math.abs(tickAngle) * (Math.PI / 180);
|
|
256
|
+
const sin = Math.sin(angleRad);
|
|
257
|
+
// A flat (or near-flat) label has no vertical projection to bound.
|
|
258
|
+
if (sin <= 0.001) return Number.POSITIVE_INFINITY;
|
|
259
|
+
const lineHeight = tickFontSize * TICK_LINE_HEIGHT_FACTOR;
|
|
260
|
+
const usable = X_AXIS_ROTATED_EXTENT_CAP - lineHeight * Math.cos(angleRad) - AXIS_TO_LABEL_GAP;
|
|
261
|
+
return Math.max(0, usable / sin);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Truncate a rotated tick label with an ellipsis so it fits inside the capped
|
|
266
|
+
* reservation. Returns the label unchanged when it already fits.
|
|
267
|
+
*
|
|
268
|
+
* Binary-search on character count against the real measure function, so it
|
|
269
|
+
* respects proportional fonts rather than assuming a fixed character width.
|
|
270
|
+
*/
|
|
271
|
+
export function truncateRotatedLabel(
|
|
272
|
+
label: string,
|
|
273
|
+
tickAngle: number,
|
|
274
|
+
tickFontSize: number,
|
|
275
|
+
tickFontWeight: number,
|
|
276
|
+
measure: (text: string, fontSize: number, fontWeight?: number) => number = estimateTextWidth,
|
|
277
|
+
): string {
|
|
278
|
+
const budget = maxRotatedLabelWidth(tickAngle, tickFontSize);
|
|
279
|
+
if (!Number.isFinite(budget)) return label;
|
|
280
|
+
if (measure(label, tickFontSize, tickFontWeight) <= budget) return label;
|
|
281
|
+
|
|
282
|
+
const ELLIPSIS = '…';
|
|
283
|
+
// Nothing sensible fits: keep a single character + ellipsis rather than
|
|
284
|
+
// returning an empty tick.
|
|
285
|
+
let lo = 0;
|
|
286
|
+
let hi = label.length;
|
|
287
|
+
while (lo < hi) {
|
|
288
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
289
|
+
const candidate = label.slice(0, mid) + ELLIPSIS;
|
|
290
|
+
if (measure(candidate, tickFontSize, tickFontWeight) <= budget) {
|
|
291
|
+
lo = mid;
|
|
292
|
+
} else {
|
|
293
|
+
hi = mid - 1;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return lo > 0 ? label.slice(0, lo).trimEnd() + ELLIPSIS : ELLIPSIS;
|
|
297
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Behavior tests for the published JSON Schema files.
|
|
3
|
+
*
|
|
4
|
+
* These validate the SHIPPED artifacts (`packages/core/schema/*.json`) against
|
|
5
|
+
* ajv, the same draft-07 validator an LLM tool-use harness would use. They
|
|
6
|
+
* prove three properties the plan requires:
|
|
7
|
+
*
|
|
8
|
+
* 1. Every one of the 16 mark types has a minimal valid spec the schema
|
|
9
|
+
* accepts (guards against a future mark being added to the types but not
|
|
10
|
+
* reflected in the generated schema).
|
|
11
|
+
* 2. Hallucinated fields (unknown top-level keys, unknown marks, unknown
|
|
12
|
+
* encoding channels) are rejected — the mechanic that kills empty-chart
|
|
13
|
+
* generation failures.
|
|
14
|
+
* 3. Representative realistic specs across chart and table validate.
|
|
15
|
+
*
|
|
16
|
+
* The per-mark specs are built from `MARK_ENCODING_RULES`, the same source the
|
|
17
|
+
* engine validates against, so the coverage tracks the real required-channel
|
|
18
|
+
* matrix rather than a hand-maintained copy.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import Ajv, { type ValidateFunction } from 'ajv';
|
|
25
|
+
import { describe, expect, it } from 'vitest';
|
|
26
|
+
import { MARK_ENCODING_RULES } from '../../types/encoding';
|
|
27
|
+
import { MARK_TYPES } from '../../types/spec';
|
|
28
|
+
|
|
29
|
+
const schemaDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'schema');
|
|
30
|
+
|
|
31
|
+
function loadValidator(filename: string): ValidateFunction {
|
|
32
|
+
const schema = JSON.parse(readFileSync(join(schemaDir, filename), 'utf8'));
|
|
33
|
+
// strict:false — the generated schema uses draft-07 with union types that
|
|
34
|
+
// ajv's strict mode flags; allowUnionTypes matches the tool-use posture.
|
|
35
|
+
const ajv = new Ajv({ strict: false, allowUnionTypes: true });
|
|
36
|
+
return ajv.compile(schema);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const validateChart = loadValidator('chart.schema.json');
|
|
40
|
+
const validateViz = loadValidator('vizspec.schema.json');
|
|
41
|
+
const validateTable = loadValidator('table.schema.json');
|
|
42
|
+
|
|
43
|
+
/** A data row carrying one column of each field type. */
|
|
44
|
+
const sampleData = [
|
|
45
|
+
{ cat: 'A', amount: 10, when: '2020-01-01' },
|
|
46
|
+
{ cat: 'B', amount: 20, when: '2021-01-01' },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/** Pick a field of an allowed type for a required channel. */
|
|
50
|
+
function fieldForTypes(allowedTypes: string[]): { field: string; type: string } {
|
|
51
|
+
if (allowedTypes.includes('temporal')) return { field: 'when', type: 'temporal' };
|
|
52
|
+
if (allowedTypes.includes('quantitative')) return { field: 'amount', type: 'quantitative' };
|
|
53
|
+
if (allowedTypes.includes('nominal')) return { field: 'cat', type: 'nominal' };
|
|
54
|
+
if (allowedTypes.includes('ordinal')) return { field: 'cat', type: 'ordinal' };
|
|
55
|
+
return { field: 'cat', type: 'nominal' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build a minimal spec the schema should accept for a given mark, from its
|
|
60
|
+
* required channels in MARK_ENCODING_RULES.
|
|
61
|
+
*/
|
|
62
|
+
function minimalSpecForMark(mark: string): Record<string, unknown> {
|
|
63
|
+
const rules = MARK_ENCODING_RULES[mark as keyof typeof MARK_ENCODING_RULES] ?? {};
|
|
64
|
+
const encoding: Record<string, unknown> = {};
|
|
65
|
+
for (const [channel, rule] of Object.entries(rules)) {
|
|
66
|
+
if (rule?.required) {
|
|
67
|
+
const types = rule.allowedTypes.length ? rule.allowedTypes : ['quantitative'];
|
|
68
|
+
encoding[channel] = fieldForTypes(types);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Ensure at least one positional channel so the spec is meaningful.
|
|
72
|
+
if (encoding.x === undefined && encoding.y === undefined) {
|
|
73
|
+
const yTypes = rules.y?.allowedTypes?.length ? rules.y.allowedTypes : ['quantitative'];
|
|
74
|
+
encoding.y = fieldForTypes(yTypes);
|
|
75
|
+
}
|
|
76
|
+
return { mark, data: sampleData, encoding };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('chart.schema.json', () => {
|
|
80
|
+
it('has a schema definition for exactly the 16 declared mark types', () => {
|
|
81
|
+
// Every MARK_TYPES entry must produce a spec the schema accepts.
|
|
82
|
+
for (const mark of MARK_TYPES) {
|
|
83
|
+
const spec = minimalSpecForMark(mark);
|
|
84
|
+
const valid = validateChart(spec);
|
|
85
|
+
expect(valid, `mark "${mark}" should validate: ${JSON.stringify(validateChart.errors)}`).toBe(
|
|
86
|
+
true,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('rejects an unknown mark type', () => {
|
|
92
|
+
const spec = {
|
|
93
|
+
mark: 'notarealmark',
|
|
94
|
+
data: sampleData,
|
|
95
|
+
encoding: { y: fieldForTypes(['quantitative']) },
|
|
96
|
+
};
|
|
97
|
+
expect(validateChart(spec)).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('rejects a hallucinated top-level field', () => {
|
|
101
|
+
const spec = {
|
|
102
|
+
mark: 'bar',
|
|
103
|
+
data: sampleData,
|
|
104
|
+
encoding: { y: { field: 'amount', type: 'quantitative' } },
|
|
105
|
+
totallyMadeUpField: 42,
|
|
106
|
+
};
|
|
107
|
+
expect(validateChart(spec)).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('rejects a hallucinated encoding channel', () => {
|
|
111
|
+
const spec = {
|
|
112
|
+
mark: 'bar',
|
|
113
|
+
data: sampleData,
|
|
114
|
+
encoding: { notAChannel: { field: 'amount', type: 'quantitative' } },
|
|
115
|
+
};
|
|
116
|
+
expect(validateChart(spec)).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('accepts a realistic bar chart with chrome and a color legend', () => {
|
|
120
|
+
const spec = {
|
|
121
|
+
mark: 'bar',
|
|
122
|
+
data: [
|
|
123
|
+
{ language: 'Python', popularity: 29 },
|
|
124
|
+
{ language: 'JavaScript', popularity: 24 },
|
|
125
|
+
],
|
|
126
|
+
encoding: {
|
|
127
|
+
x: { field: 'popularity', type: 'quantitative' },
|
|
128
|
+
y: { field: 'language', type: 'nominal' },
|
|
129
|
+
color: { field: 'popularity', type: 'quantitative' },
|
|
130
|
+
},
|
|
131
|
+
chrome: { title: 'Python leads', source: 'Source: survey' },
|
|
132
|
+
};
|
|
133
|
+
expect(validateChart(spec), JSON.stringify(validateChart.errors)).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('vizspec.schema.json', () => {
|
|
138
|
+
it('accepts a chart spec (mark-discriminated union member)', () => {
|
|
139
|
+
expect(validateViz(minimalSpecForMark('line'))).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('accepts a table spec (type-discriminated union member)', () => {
|
|
143
|
+
const spec = {
|
|
144
|
+
type: 'table',
|
|
145
|
+
data: sampleData,
|
|
146
|
+
columns: [{ key: 'cat' }, { key: 'amount' }],
|
|
147
|
+
};
|
|
148
|
+
expect(validateViz(spec), JSON.stringify(validateViz.errors)).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('rejects a spec that is neither a chart nor any known type', () => {
|
|
152
|
+
expect(validateViz({ nonsense: true })).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('table.schema.json', () => {
|
|
157
|
+
it('accepts a table with formatted columns', () => {
|
|
158
|
+
const spec = {
|
|
159
|
+
type: 'table',
|
|
160
|
+
data: sampleData,
|
|
161
|
+
columns: [
|
|
162
|
+
{ key: 'cat', label: 'Category' },
|
|
163
|
+
{ key: 'amount', format: '.0f', align: 'right' },
|
|
164
|
+
],
|
|
165
|
+
search: true,
|
|
166
|
+
};
|
|
167
|
+
expect(validateTable(spec), JSON.stringify(validateTable.errors)).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('rejects a hallucinated top-level field on a table', () => {
|
|
171
|
+
const spec = {
|
|
172
|
+
type: 'table',
|
|
173
|
+
data: sampleData,
|
|
174
|
+
columns: [{ key: 'cat' }],
|
|
175
|
+
madeUp: true,
|
|
176
|
+
};
|
|
177
|
+
expect(validateTable(spec)).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
});
|