@opendata-ai/openchart-core 8.0.0-rc.9 → 8.1.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.
Files changed (83) hide show
  1. package/README.md +1 -1
  2. package/dist/index.d.ts +403 -45
  3. package/dist/index.js +50 -18
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles.css +1 -1
  6. package/dist/styles.css.map +1 -1
  7. package/package.json +1 -2
  8. package/schema/chart.schema.json +375 -69
  9. package/schema/table.schema.json +35 -1
  10. package/schema/vizspec.schema.json +992 -171
  11. package/src/accessibility/__tests__/alt-text.test.ts +0 -110
  12. package/src/accessibility/__tests__/aria.test.ts +0 -125
  13. package/src/accessibility/alt-text.ts +0 -155
  14. package/src/accessibility/aria.ts +0 -75
  15. package/src/accessibility/index.ts +0 -6
  16. package/src/colors/__tests__/colorblind.test.ts +0 -75
  17. package/src/colors/__tests__/contrast.test.ts +0 -119
  18. package/src/colors/__tests__/palettes.test.ts +0 -74
  19. package/src/colors/colorblind.ts +0 -131
  20. package/src/colors/contrast.ts +0 -143
  21. package/src/colors/index.ts +0 -36
  22. package/src/colors/palettes.ts +0 -206
  23. package/src/helpers/__tests__/deep-merge.test.ts +0 -54
  24. package/src/helpers/__tests__/spec-builders.test.ts +0 -338
  25. package/src/helpers/deep-merge.ts +0 -29
  26. package/src/helpers/spec-builders.ts +0 -479
  27. package/src/index.ts +0 -215
  28. package/src/labels/__tests__/collision.test.ts +0 -310
  29. package/src/labels/collision.ts +0 -249
  30. package/src/labels/index.ts +0 -13
  31. package/src/layout/__tests__/chrome.test.ts +0 -208
  32. package/src/layout/__tests__/text-measure.test.ts +0 -57
  33. package/src/layout/chrome.ts +0 -470
  34. package/src/layout/index.ts +0 -18
  35. package/src/layout/text-measure.ts +0 -404
  36. package/src/layout/text-wrap.ts +0 -93
  37. package/src/locale/__tests__/format.test.ts +0 -606
  38. package/src/locale/format.ts +0 -459
  39. package/src/locale/index.ts +0 -21
  40. package/src/responsive/__tests__/breakpoints.test.ts +0 -146
  41. package/src/responsive/__tests__/metrics.test.ts +0 -52
  42. package/src/responsive/breakpoints.ts +0 -177
  43. package/src/responsive/index.ts +0 -53
  44. package/src/responsive/metrics.ts +0 -299
  45. package/src/schema/__tests__/generated-artifacts.test.ts +0 -42
  46. package/src/schema/__tests__/schema.test.ts +0 -179
  47. package/src/styles/__tests__/reduced-motion-selectors.test.ts +0 -28
  48. package/src/styles/animation.css +0 -213
  49. package/src/styles/base.css +0 -102
  50. package/src/styles/chrome.css +0 -174
  51. package/src/styles/dark.css +0 -44
  52. package/src/styles/graph.css +0 -135
  53. package/src/styles/index.css +0 -28
  54. package/src/styles/keyframes.css +0 -151
  55. package/src/styles/legend.css +0 -18
  56. package/src/styles/reduced-motion.css +0 -67
  57. package/src/styles/series-search.css +0 -153
  58. package/src/styles/sparkline.css +0 -16
  59. package/src/styles/table-animation.css +0 -93
  60. package/src/styles/table.css +0 -398
  61. package/src/styles/token-definitions.ts +0 -199
  62. package/src/styles/tokens.css +0 -156
  63. package/src/styles/tooltip.css +0 -126
  64. package/src/styles/you-draw-it.css +0 -126
  65. package/src/theme/__tests__/dark-mode.test.ts +0 -232
  66. package/src/theme/__tests__/defaults.test.ts +0 -73
  67. package/src/theme/__tests__/resolve.test.ts +0 -277
  68. package/src/theme/dark-mode.ts +0 -258
  69. package/src/theme/defaults.ts +0 -110
  70. package/src/theme/index.ts +0 -8
  71. package/src/theme/presets.ts +0 -64
  72. package/src/theme/resolve.ts +0 -312
  73. package/src/types/__tests__/encoding.test.ts +0 -368
  74. package/src/types/__tests__/gradient.test.ts +0 -95
  75. package/src/types/__tests__/spec.test.ts +0 -499
  76. package/src/types/encoding.ts +0 -280
  77. package/src/types/events.ts +0 -236
  78. package/src/types/index.ts +0 -290
  79. package/src/types/layout.ts +0 -2024
  80. package/src/types/schema-roots.ts +0 -32
  81. package/src/types/spec.ts +0 -2925
  82. package/src/types/table.ts +0 -108
  83. package/src/types/theme.ts +0 -209
@@ -1,459 +0,0 @@
1
- /**
2
- * Locale-aware number and date formatting utilities.
3
- *
4
- * Uses d3-format and d3-time-format for formatting,
5
- * with convenience wrappers for common patterns.
6
- */
7
-
8
- import { format as d3Format } from 'd3-format';
9
- import { timeFormat, utcFormat } from 'd3-time-format';
10
-
11
- // ---------------------------------------------------------------------------
12
- // Number formatting
13
- // ---------------------------------------------------------------------------
14
-
15
- /**
16
- * Format a number with locale-appropriate separators.
17
- *
18
- * Uses d3-format under the hood. Default format: comma-separated
19
- * with auto-precision (e.g. 1500000 -> "1,500,000").
20
- *
21
- * @param value - The number to format.
22
- * @param locale - Locale string (currently unused, reserved for i18n).
23
- */
24
- export function formatNumber(value: number, _locale?: string): string {
25
- if (!Number.isFinite(value)) return String(value);
26
- if (Number.isInteger(value)) {
27
- return d3Format(',')(value);
28
- }
29
- if (Math.abs(value) < 0.005) {
30
- return d3Format('.2~r')(value);
31
- }
32
- return d3Format(',.2f')(value);
33
- }
34
-
35
- // ---------------------------------------------------------------------------
36
- // Number abbreviation
37
- // ---------------------------------------------------------------------------
38
-
39
- const UNIT_TABLE: Array<{ threshold: number; suffix: string; divisor: number }> = [
40
- { threshold: 1_000_000_000_000, suffix: 'T', divisor: 1_000_000_000_000 },
41
- { threshold: 1_000_000_000, suffix: 'B', divisor: 1_000_000_000 },
42
- { threshold: 1_000_000, suffix: 'M', divisor: 1_000_000 },
43
- { threshold: 1_000, suffix: 'k', divisor: 1_000 },
44
- ];
45
-
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
-
62
- export function abbreviateNumber(value: number): string {
63
- if (!Number.isFinite(value)) return String(value);
64
-
65
- const absValue = Math.abs(value);
66
- const sign = value < 0 ? '-' : '';
67
-
68
- for (let i = 0; i < UNIT_TABLE.length; i++) {
69
- const { threshold, suffix, divisor } = UNIT_TABLE[i];
70
- if (absValue >= threshold) {
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}`;
78
- }
79
- }
80
-
81
- return formatNumber(value);
82
- }
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
-
108
- // ---------------------------------------------------------------------------
109
- // d3-format with suffix support
110
- // ---------------------------------------------------------------------------
111
-
112
- /**
113
- * Regex that matches a valid d3-format specifier (with optional ~ trim flag)
114
- * followed by a literal suffix. The first capture group is the d3 format part,
115
- * the second is the trailing literal suffix (e.g. "T", "pp", etc.).
116
- *
117
- * Examples:
118
- * - "$,.2~fT" -> ["$,.2~f", "T"]
119
- * - ".0f%" -> [".0f", "%"] (% here is literal, not d3 percent type)
120
- * - "$,.0f" -> no match (valid d3 format, no suffix)
121
- */
122
- const D3_FORMAT_SUFFIX_RE = /^(.*~?[efgsrdxXobcnp%])(.+)$/;
123
-
124
- /**
125
- * Build a number formatter from a d3-format string, with support for a
126
- * trailing literal suffix that d3-format itself would reject.
127
- *
128
- * Also accepts the OpenChart extension `'ordinal'`, which renders English
129
- * ordinals ("1st", "2nd", "3rd") for rank axes.
130
- *
131
- * Returns null if the format string is falsy or unparseable.
132
- */
133
- export function buildD3Formatter(formatStr: string | undefined): ((v: number) => string) | null {
134
- if (!formatStr) return null;
135
-
136
- // OpenChart extension: rank-axis ordinals ("1st", "2nd", "3rd").
137
- if (formatStr === 'ordinal') return formatOrdinal;
138
-
139
- try {
140
- const fmt = d3Format(formatStr);
141
- // Replace SI prefix "G" (giga) with "B" (billion) for financial readability
142
- if (formatStr.includes('s')) {
143
- return (v: number) => fmt(v).replace(/G$/, 'B');
144
- }
145
- return fmt;
146
- } catch {
147
- // If d3-format rejects it, try stripping a trailing literal suffix
148
- const m = formatStr.match(D3_FORMAT_SUFFIX_RE);
149
- if (m) {
150
- try {
151
- const fmt = d3Format(m[1]);
152
- const suffix = m[2];
153
- return (v: number) => fmt(v) + suffix;
154
- } catch {
155
- // Unparseable even after suffix stripping
156
- }
157
- }
158
- }
159
- return null;
160
- }
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
-
345
- // ---------------------------------------------------------------------------
346
- // Date formatting
347
- // ---------------------------------------------------------------------------
348
-
349
- /** Granularity levels for date formatting. */
350
- export type DateGranularity = 'year' | 'quarter' | 'month' | 'week' | 'day' | 'hour' | 'minute';
351
-
352
- /** Format strings for each granularity level. */
353
- const GRANULARITY_FORMATS: Record<DateGranularity, string> = {
354
- year: '%Y',
355
- quarter: '', // Quarter is always special-cased in formatDate() below
356
- month: '%b %Y',
357
- week: '%b %d',
358
- day: '%b %d, %Y',
359
- hour: '%b %d %H:%M',
360
- minute: '%H:%M',
361
- };
362
-
363
- /**
364
- * Compact format strings used by axis ticks when full-format labels overlap.
365
- * Month is bare '%b': granularity inference runs per tick, so year-boundary
366
- * ticks already render as '2025' and the axis reads "2025 · Feb · Mar".
367
- * Year stays '%Y' — abbreviating to "'25" saves too little for the ambiguity.
368
- */
369
- const GRANULARITY_FORMATS_COMPACT: Record<DateGranularity, string> = {
370
- year: '%Y',
371
- quarter: '', // special-cased below, like GRANULARITY_FORMATS
372
- month: '%b',
373
- week: '%b %d',
374
- day: '%b %d',
375
- hour: '%H:%M',
376
- minute: '%H:%M',
377
- };
378
-
379
- /**
380
- * Format a date value for display.
381
- *
382
- * @param value - Date object, ISO string, or timestamp number.
383
- * @param locale - Locale string (currently unused, reserved for i18n).
384
- * @param granularity - Time granularity for format selection.
385
- * @param useUtc - Whether to infer granularity and format using UTC methods.
386
- * Pass `false` when formatting ticks from a local-time scale (d3 scaleTime),
387
- * so that e.g. midnight local isn't misread as an intra-day UTC time.
388
- * Defaults to `true` for backward compatibility.
389
- * @param compact - Use the compact format table (axis ticks whose full-format
390
- * labels would overlap). Defaults to `false`.
391
- */
392
- export function formatDate(
393
- value: Date | string | number,
394
- _locale?: string,
395
- granularity?: DateGranularity,
396
- useUtc: boolean = true,
397
- compact: boolean = false,
398
- ): string {
399
- const date = value instanceof Date ? value : new Date(value);
400
- if (Number.isNaN(date.getTime())) return String(value);
401
-
402
- const gran = granularity ?? inferGranularity(date, useUtc);
403
-
404
- // Special handling for quarter (not a d3 format token)
405
- if (gran === 'quarter') {
406
- const q = useUtc
407
- ? Math.ceil((date.getUTCMonth() + 1) / 3)
408
- : Math.ceil((date.getMonth() + 1) / 3);
409
- const year = useUtc ? date.getUTCFullYear() : date.getFullYear();
410
- if (compact) {
411
- return `Q${q} '${String(year).slice(-2)}`;
412
- }
413
- return `Q${q} ${year}`;
414
- }
415
-
416
- const formatStr = (compact ? GRANULARITY_FORMATS_COMPACT : GRANULARITY_FORMATS)[gran];
417
- if (useUtc) {
418
- return utcFormat(formatStr)(date);
419
- }
420
- return timeFormat(formatStr)(date);
421
- }
422
-
423
- /**
424
- * Build a formatter for temporal values using a d3-time-format string (e.g. "%Y", "%b %Y").
425
- * Returns a function that accepts a Date, string, or number and returns the formatted string.
426
- * Returns null if the format string is falsy.
427
- */
428
- export function buildTemporalFormatter(
429
- formatStr: string | undefined,
430
- ): ((value: Date | string | number) => string) | null {
431
- if (!formatStr) return null;
432
- const fmt = utcFormat(formatStr);
433
- return (value: Date | string | number) => {
434
- const date = value instanceof Date ? value : new Date(value);
435
- if (Number.isNaN(date.getTime())) return String(value);
436
- return fmt(date);
437
- };
438
- }
439
-
440
- /**
441
- * Infer the appropriate granularity from a date value.
442
- * If time components are all zero, assume day or higher.
443
- *
444
- * @param useUtc - When true, inspect UTC fields. When false, inspect local-time
445
- * fields. This must match the D3 scale type: scaleUtc -> true, scaleTime -> false.
446
- */
447
- function inferGranularity(date: Date, useUtc: boolean = true): DateGranularity {
448
- const hours = useUtc ? date.getUTCHours() : date.getHours();
449
- const minutes = useUtc ? date.getUTCMinutes() : date.getMinutes();
450
- const day = useUtc ? date.getUTCDate() : date.getDate();
451
- const month = useUtc ? date.getUTCMonth() : date.getMonth();
452
-
453
- if (hours !== 0 || minutes !== 0) {
454
- return minutes !== 0 ? 'minute' : 'hour';
455
- }
456
- if (day !== 1) return 'day';
457
- if (month !== 0) return 'month';
458
- return 'year';
459
- }
@@ -1,21 +0,0 @@
1
- /**
2
- * Locale module barrel export.
3
- */
4
-
5
- export type { DateGranularity, FieldFormatContext, NumberFormatter } from './format';
6
- export {
7
- abbreviateNumber,
8
- buildCompactStepFormatter,
9
- buildD3Formatter,
10
- buildTemporalFormatter,
11
- computeFieldFormatContext,
12
- defaultNumberFormatter,
13
- formatCurrency,
14
- formatDate,
15
- formatNumber,
16
- formatOrdinal,
17
- formatPercent,
18
- isYearContext,
19
- isYearLikeValues,
20
- resolveNumberFormatter,
21
- } from './format';
@@ -1,146 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { getBreakpoint, getHeightClass, getLayoutStrategy } from '../breakpoints';
3
-
4
- describe('getBreakpoint', () => {
5
- it('returns compact for widths below 400', () => {
6
- expect(getBreakpoint(320)).toBe('compact');
7
- expect(getBreakpoint(399)).toBe('compact');
8
- });
9
-
10
- it('returns medium for widths 400-700', () => {
11
- expect(getBreakpoint(400)).toBe('medium');
12
- expect(getBreakpoint(550)).toBe('medium');
13
- expect(getBreakpoint(700)).toBe('medium');
14
- });
15
-
16
- it('returns full for widths above 700', () => {
17
- expect(getBreakpoint(701)).toBe('full');
18
- expect(getBreakpoint(1200)).toBe('full');
19
- });
20
-
21
- it('handles edge cases', () => {
22
- expect(getBreakpoint(0)).toBe('compact');
23
- expect(getBreakpoint(5000)).toBe('full');
24
- });
25
- });
26
-
27
- describe('getLayoutStrategy', () => {
28
- it('compact has no labels and minimal axes', () => {
29
- const strategy = getLayoutStrategy('compact');
30
- expect(strategy.labelMode).toBe('none');
31
- expect(strategy.legendPosition).toBe('top');
32
- expect(strategy.annotationPosition).toBe('tooltip-only');
33
- expect(strategy.axisLabelDensity).toBe('minimal');
34
- });
35
-
36
- it('medium has important labels and reduced axes', () => {
37
- const strategy = getLayoutStrategy('medium');
38
- expect(strategy.labelMode).toBe('important');
39
- expect(strategy.legendPosition).toBe('top');
40
- expect(strategy.annotationPosition).toBe('inline');
41
- expect(strategy.axisLabelDensity).toBe('reduced');
42
- });
43
-
44
- it('full has all labels and legend on right', () => {
45
- const strategy = getLayoutStrategy('full');
46
- expect(strategy.labelMode).toBe('all');
47
- expect(strategy.legendPosition).toBe('right');
48
- expect(strategy.annotationPosition).toBe('inline');
49
- expect(strategy.axisLabelDensity).toBe('full');
50
- });
51
-
52
- it('different widths produce different strategies', () => {
53
- const compact = getLayoutStrategy(getBreakpoint(320));
54
- const full = getLayoutStrategy(getBreakpoint(800));
55
- expect(compact.legendPosition).not.toBe(full.legendPosition);
56
- expect(compact.labelMode).not.toBe(full.labelMode);
57
- });
58
-
59
- it('includes chromeMode and legendMaxHeight at normal height', () => {
60
- const strategy = getLayoutStrategy('full');
61
- expect(strategy.chromeMode).toBe('full');
62
- expect(strategy.legendMaxHeight).toBe(-1);
63
- });
64
- });
65
-
66
- // ---------------------------------------------------------------------------
67
- // Height class detection
68
- // ---------------------------------------------------------------------------
69
-
70
- describe('getHeightClass', () => {
71
- it('returns cramped for heights below 200', () => {
72
- expect(getHeightClass(100)).toBe('cramped');
73
- expect(getHeightClass(199)).toBe('cramped');
74
- });
75
-
76
- it('returns short for heights 200-350', () => {
77
- expect(getHeightClass(200)).toBe('short');
78
- expect(getHeightClass(280)).toBe('short');
79
- expect(getHeightClass(350)).toBe('short');
80
- });
81
-
82
- it('returns normal for heights above 350', () => {
83
- expect(getHeightClass(351)).toBe('normal');
84
- expect(getHeightClass(800)).toBe('normal');
85
- });
86
-
87
- it('handles edge cases', () => {
88
- expect(getHeightClass(0)).toBe('cramped');
89
- expect(getHeightClass(-10)).toBe('cramped');
90
- expect(getHeightClass(5000)).toBe('normal');
91
- });
92
- });
93
-
94
- // ---------------------------------------------------------------------------
95
- // Height-aware layout strategy
96
- // ---------------------------------------------------------------------------
97
-
98
- describe('getLayoutStrategy with height class', () => {
99
- it('normal height does not modify the width strategy', () => {
100
- const withoutHeight = getLayoutStrategy('full');
101
- const withNormal = getLayoutStrategy('full', 'normal');
102
- expect(withoutHeight).toEqual(withNormal);
103
- });
104
-
105
- it('cramped height hides chrome and labels', () => {
106
- const strategy = getLayoutStrategy('full', 'cramped');
107
- expect(strategy.chromeMode).toBe('hidden');
108
- expect(strategy.legendMaxHeight).toBe(0);
109
- expect(strategy.labelMode).toBe('none');
110
- expect(strategy.annotationPosition).toBe('tooltip-only');
111
- });
112
-
113
- it('cramped overrides even compact width strategy', () => {
114
- const strategy = getLayoutStrategy('compact', 'cramped');
115
- expect(strategy.chromeMode).toBe('hidden');
116
- expect(strategy.legendMaxHeight).toBe(0);
117
- expect(strategy.labelMode).toBe('none');
118
- });
119
-
120
- it('short height compresses chrome and caps legend', () => {
121
- const strategy = getLayoutStrategy('full', 'short');
122
- expect(strategy.chromeMode).toBe('compact');
123
- expect(strategy.legendMaxHeight).toBe(0.15);
124
- });
125
-
126
- it('short height preserves width-based label and legend settings', () => {
127
- const strategy = getLayoutStrategy('full', 'short');
128
- // Width strategy for 'full' sets these; short only touches chromeMode and legendMaxHeight
129
- expect(strategy.labelMode).toBe('all');
130
- expect(strategy.legendPosition).toBe('right');
131
- expect(strategy.axisLabelDensity).toBe('full');
132
- });
133
-
134
- it('short height preserves compact width label settings', () => {
135
- const strategy = getLayoutStrategy('compact', 'short');
136
- expect(strategy.labelMode).toBe('none');
137
- expect(strategy.legendPosition).toBe('top');
138
- expect(strategy.chromeMode).toBe('compact');
139
- });
140
-
141
- it('defaults heightClass to normal when omitted', () => {
142
- const strategy = getLayoutStrategy('medium');
143
- expect(strategy.chromeMode).toBe('full');
144
- expect(strategy.legendMaxHeight).toBe(-1);
145
- });
146
- });
@@ -1,52 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import {
3
- AXIS_TITLE_GAP,
4
- AXIS_TITLE_OFFSET_DEFAULT,
5
- axisTitleOffset,
6
- TICK_LABEL_OFFSET,
7
- } from '../metrics';
8
-
9
- // The rotated y-axis title is anchored at its center and its glyph box extends
10
- // half the title font size toward the tick labels. axisTitleOffset() returns the
11
- // distance from the chart edge to that center; visible clearance between the
12
- // widest tick label and the title's near edge is therefore:
13
- // offset - TICK_LABEL_OFFSET - tickLabelWidth - titleFontSize / 2
14
- const visibleClearance = (offset: number, tickLabelWidth: number, titleFontSize: number): number =>
15
- offset - TICK_LABEL_OFFSET - tickLabelWidth - titleFontSize / 2;
16
-
17
- describe('axisTitleOffset', () => {
18
- const WIDE = 1200; // wide viewport so the dynamic value wins over the floor
19
-
20
- it('leaves the title clear of the tick labels at the default font size', () => {
21
- const offset = axisTitleOffset(40, 13, WIDE);
22
- // ~AXIS_TITLE_GAP of clearance, never overlapping (negative).
23
- expect(visibleClearance(offset, 40, 13)).toBeGreaterThanOrEqual(AXIS_TITLE_GAP - 1);
24
- });
25
-
26
- it('keeps clearance constant as the title font size grows', () => {
27
- // The original bug: a fixed gap let large title fonts overlap the tick
28
- // labels because the title's half-glyph ate into the gap. Clearance must
29
- // stay ~AXIS_TITLE_GAP regardless of how big the title font is.
30
- const small = visibleClearance(axisTitleOffset(40, 13, WIDE), 40, 13);
31
- const large = visibleClearance(axisTitleOffset(40, 30, WIDE), 40, 30);
32
-
33
- expect(large).toBeGreaterThanOrEqual(AXIS_TITLE_GAP - 1);
34
- expect(Math.abs(large - small)).toBeLessThanOrEqual(1); // rounding only
35
- });
36
-
37
- it('pushes the title further left as tick labels get wider', () => {
38
- // Both label widths sit above the viewport floor, so the offset tracks the
39
- // label width 1:1 (a 60px wider label moves the title 60px further left).
40
- const narrow = axisTitleOffset(50, 13, WIDE);
41
- const wide = axisTitleOffset(110, 13, WIDE);
42
- expect(narrow).toBeGreaterThan(AXIS_TITLE_OFFSET_DEFAULT); // floor not in play
43
- expect(wide - narrow).toBeCloseTo(60, 0);
44
- });
45
-
46
- it('falls back to the viewport-minimum offset for short labels on wide containers', () => {
47
- // Tiny label + small font: the dynamic value is below the floor, so the
48
- // floor wins (prevents the title from hugging the chart edge).
49
- const offset = axisTitleOffset(2, 11, WIDE);
50
- expect(offset).toBe(AXIS_TITLE_OFFSET_DEFAULT);
51
- });
52
- });