@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.
Files changed (59) hide show
  1. package/dist/index.d.ts +1348 -132
  2. package/dist/index.js +556 -48
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +2 -1
  5. package/dist/styles.css.map +1 -0
  6. package/package.json +9 -3
  7. package/schema/chart.schema.json +9086 -0
  8. package/schema/table.schema.json +861 -0
  9. package/schema/vizspec.schema.json +17922 -0
  10. package/src/accessibility/alt-text.ts +39 -1
  11. package/src/accessibility/aria.ts +4 -2
  12. package/src/colors/__tests__/colorblind.test.ts +12 -0
  13. package/src/colors/colorblind.ts +11 -2
  14. package/src/colors/contrast.ts +11 -0
  15. package/src/colors/index.ts +9 -1
  16. package/src/colors/palettes.ts +47 -0
  17. package/src/helpers/__tests__/deep-merge.test.ts +54 -0
  18. package/src/helpers/__tests__/spec-builders.test.ts +3 -3
  19. package/src/helpers/deep-merge.ts +29 -0
  20. package/src/helpers/spec-builders.ts +7 -7
  21. package/src/index.ts +29 -1
  22. package/src/labels/collision.ts +1 -0
  23. package/src/layout/chrome.ts +24 -0
  24. package/src/layout/index.ts +1 -1
  25. package/src/locale/__tests__/format.test.ts +347 -11
  26. package/src/locale/format.ts +243 -18
  27. package/src/locale/index.ts +10 -1
  28. package/src/responsive/breakpoints.ts +7 -2
  29. package/src/responsive/index.ts +2 -0
  30. package/src/responsive/metrics.ts +77 -10
  31. package/src/schema/__tests__/generated-artifacts.test.ts +42 -0
  32. package/src/schema/__tests__/schema.test.ts +179 -0
  33. package/src/styles/__tests__/reduced-motion-selectors.test.ts +28 -0
  34. package/src/styles/animation.css +211 -168
  35. package/src/styles/base.css +89 -62
  36. package/src/styles/chrome.css +173 -179
  37. package/src/styles/dark.css +38 -35
  38. package/src/styles/graph.css +116 -111
  39. package/src/styles/index.css +5 -0
  40. package/src/styles/keyframes.css +130 -99
  41. package/src/styles/legend.css +14 -12
  42. package/src/styles/reduced-motion.css +61 -40
  43. package/src/styles/series-search.css +153 -0
  44. package/src/styles/sparkline.css +14 -12
  45. package/src/styles/table-animation.css +82 -80
  46. package/src/styles/table.css +314 -302
  47. package/src/styles/token-definitions.ts +199 -0
  48. package/src/styles/tokens.css +132 -128
  49. package/src/styles/tooltip.css +109 -115
  50. package/src/styles/you-draw-it.css +126 -0
  51. package/src/types/__tests__/encoding.test.ts +86 -17
  52. package/src/types/__tests__/spec.test.ts +16 -12
  53. package/src/types/encoding.ts +69 -25
  54. package/src/types/events.ts +107 -13
  55. package/src/types/index.ts +42 -3
  56. package/src/types/layout.ts +484 -11
  57. package/src/types/schema-roots.ts +32 -0
  58. package/src/types/spec.ts +718 -87
  59. package/src/types/table.ts +3 -2
@@ -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
- /** Suffixes for abbreviated numbers. */
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: 'K', divisor: 1_000 },
43
+ { threshold: 1_000, suffix: 'k', divisor: 1_000 },
43
44
  ];
44
45
 
45
- /**
46
- * Abbreviate a large number with a suffix.
47
- *
48
- * Examples:
49
- * - 1500000 -> "1.5M"
50
- * - 2300 -> "2.3K"
51
- * - 42 -> "42"
52
- * - 1000000000 -> "1B"
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 (const { threshold, suffix, divisor } of ABBREVIATIONS) {
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 abbreviated = absValue / divisor;
63
- // Use .1f precision, drop trailing .0
64
- const formatted = abbreviated % 1 === 0 ? String(abbreviated) : d3Format('.1f')(abbreviated);
65
- return `${sign}${formatted}${suffix}`;
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
  // ---------------------------------------------------------------------------
@@ -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
- /** Legend position at a given breakpoint. */
63
- export type LegendPosition = 'top' | 'right' | 'bottom' | 'bottom-right' | 'inline';
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';
@@ -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. Labels
178
- * whose rotated projection exceeds this clip at the reservation edge, so the
179
- * angle ladder (engine layout/axes/rotation) avoids -90° for labels longer
180
- * than this.
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
- return input.hasTitle ? baseHeight + X_AXIS_TITLE_BAND : baseHeight;
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
+ });