@opendata-ai/openchart-core 8.0.0-rc.2 → 8.0.0-rc.21

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 (82) hide show
  1. package/dist/index.d.ts +490 -25
  2. package/dist/index.js +57 -19
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +1 -1
  5. package/dist/styles.css.map +1 -1
  6. package/package.json +1 -2
  7. package/schema/chart.schema.json +485 -35
  8. package/schema/table.schema.json +35 -1
  9. package/schema/vizspec.schema.json +1305 -120
  10. package/src/accessibility/__tests__/alt-text.test.ts +0 -110
  11. package/src/accessibility/__tests__/aria.test.ts +0 -125
  12. package/src/accessibility/alt-text.ts +0 -155
  13. package/src/accessibility/aria.ts +0 -75
  14. package/src/accessibility/index.ts +0 -6
  15. package/src/colors/__tests__/colorblind.test.ts +0 -75
  16. package/src/colors/__tests__/contrast.test.ts +0 -119
  17. package/src/colors/__tests__/palettes.test.ts +0 -74
  18. package/src/colors/colorblind.ts +0 -131
  19. package/src/colors/contrast.ts +0 -143
  20. package/src/colors/index.ts +0 -36
  21. package/src/colors/palettes.ts +0 -206
  22. package/src/helpers/__tests__/deep-merge.test.ts +0 -54
  23. package/src/helpers/__tests__/spec-builders.test.ts +0 -338
  24. package/src/helpers/deep-merge.ts +0 -29
  25. package/src/helpers/spec-builders.ts +0 -479
  26. package/src/index.ts +0 -215
  27. package/src/labels/__tests__/collision.test.ts +0 -310
  28. package/src/labels/collision.ts +0 -249
  29. package/src/labels/index.ts +0 -13
  30. package/src/layout/__tests__/chrome.test.ts +0 -208
  31. package/src/layout/__tests__/text-measure.test.ts +0 -57
  32. package/src/layout/chrome.ts +0 -470
  33. package/src/layout/index.ts +0 -18
  34. package/src/layout/text-measure.ts +0 -404
  35. package/src/layout/text-wrap.ts +0 -93
  36. package/src/locale/__tests__/format.test.ts +0 -606
  37. package/src/locale/format.ts +0 -459
  38. package/src/locale/index.ts +0 -21
  39. package/src/responsive/__tests__/breakpoints.test.ts +0 -146
  40. package/src/responsive/__tests__/metrics.test.ts +0 -52
  41. package/src/responsive/breakpoints.ts +0 -172
  42. package/src/responsive/index.ts +0 -53
  43. package/src/responsive/metrics.ts +0 -299
  44. package/src/schema/__tests__/generated-artifacts.test.ts +0 -42
  45. package/src/schema/__tests__/schema.test.ts +0 -179
  46. package/src/styles/__tests__/reduced-motion-selectors.test.ts +0 -28
  47. package/src/styles/animation.css +0 -199
  48. package/src/styles/base.css +0 -102
  49. package/src/styles/chrome.css +0 -174
  50. package/src/styles/dark.css +0 -44
  51. package/src/styles/graph.css +0 -135
  52. package/src/styles/index.css +0 -28
  53. package/src/styles/keyframes.css +0 -135
  54. package/src/styles/legend.css +0 -18
  55. package/src/styles/reduced-motion.css +0 -67
  56. package/src/styles/series-search.css +0 -153
  57. package/src/styles/sparkline.css +0 -16
  58. package/src/styles/table-animation.css +0 -93
  59. package/src/styles/table.css +0 -398
  60. package/src/styles/token-definitions.ts +0 -199
  61. package/src/styles/tokens.css +0 -156
  62. package/src/styles/tooltip.css +0 -126
  63. package/src/styles/you-draw-it.css +0 -126
  64. package/src/theme/__tests__/dark-mode.test.ts +0 -232
  65. package/src/theme/__tests__/defaults.test.ts +0 -73
  66. package/src/theme/__tests__/resolve.test.ts +0 -277
  67. package/src/theme/dark-mode.ts +0 -258
  68. package/src/theme/defaults.ts +0 -110
  69. package/src/theme/index.ts +0 -8
  70. package/src/theme/presets.ts +0 -64
  71. package/src/theme/resolve.ts +0 -312
  72. package/src/types/__tests__/encoding.test.ts +0 -368
  73. package/src/types/__tests__/gradient.test.ts +0 -95
  74. package/src/types/__tests__/spec.test.ts +0 -499
  75. package/src/types/encoding.ts +0 -280
  76. package/src/types/events.ts +0 -154
  77. package/src/types/index.ts +0 -287
  78. package/src/types/layout.ts +0 -1966
  79. package/src/types/schema-roots.ts +0 -32
  80. package/src/types/spec.ts +0 -2867
  81. package/src/types/table.ts +0 -108
  82. 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
- });