@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.
- package/README.md +1 -1
- package/dist/index.d.ts +403 -45
- package/dist/index.js +50 -18
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.css.map +1 -1
- package/package.json +1 -2
- package/schema/chart.schema.json +375 -69
- package/schema/table.schema.json +35 -1
- package/schema/vizspec.schema.json +992 -171
- package/src/accessibility/__tests__/alt-text.test.ts +0 -110
- package/src/accessibility/__tests__/aria.test.ts +0 -125
- package/src/accessibility/alt-text.ts +0 -155
- package/src/accessibility/aria.ts +0 -75
- package/src/accessibility/index.ts +0 -6
- package/src/colors/__tests__/colorblind.test.ts +0 -75
- package/src/colors/__tests__/contrast.test.ts +0 -119
- package/src/colors/__tests__/palettes.test.ts +0 -74
- package/src/colors/colorblind.ts +0 -131
- package/src/colors/contrast.ts +0 -143
- package/src/colors/index.ts +0 -36
- package/src/colors/palettes.ts +0 -206
- package/src/helpers/__tests__/deep-merge.test.ts +0 -54
- package/src/helpers/__tests__/spec-builders.test.ts +0 -338
- package/src/helpers/deep-merge.ts +0 -29
- package/src/helpers/spec-builders.ts +0 -479
- package/src/index.ts +0 -215
- package/src/labels/__tests__/collision.test.ts +0 -310
- package/src/labels/collision.ts +0 -249
- package/src/labels/index.ts +0 -13
- package/src/layout/__tests__/chrome.test.ts +0 -208
- package/src/layout/__tests__/text-measure.test.ts +0 -57
- package/src/layout/chrome.ts +0 -470
- package/src/layout/index.ts +0 -18
- package/src/layout/text-measure.ts +0 -404
- package/src/layout/text-wrap.ts +0 -93
- package/src/locale/__tests__/format.test.ts +0 -606
- package/src/locale/format.ts +0 -459
- package/src/locale/index.ts +0 -21
- package/src/responsive/__tests__/breakpoints.test.ts +0 -146
- package/src/responsive/__tests__/metrics.test.ts +0 -52
- package/src/responsive/breakpoints.ts +0 -177
- package/src/responsive/index.ts +0 -53
- package/src/responsive/metrics.ts +0 -299
- package/src/schema/__tests__/generated-artifacts.test.ts +0 -42
- package/src/schema/__tests__/schema.test.ts +0 -179
- package/src/styles/__tests__/reduced-motion-selectors.test.ts +0 -28
- package/src/styles/animation.css +0 -213
- package/src/styles/base.css +0 -102
- package/src/styles/chrome.css +0 -174
- package/src/styles/dark.css +0 -44
- package/src/styles/graph.css +0 -135
- package/src/styles/index.css +0 -28
- package/src/styles/keyframes.css +0 -151
- package/src/styles/legend.css +0 -18
- package/src/styles/reduced-motion.css +0 -67
- package/src/styles/series-search.css +0 -153
- package/src/styles/sparkline.css +0 -16
- package/src/styles/table-animation.css +0 -93
- package/src/styles/table.css +0 -398
- package/src/styles/token-definitions.ts +0 -199
- package/src/styles/tokens.css +0 -156
- package/src/styles/tooltip.css +0 -126
- package/src/styles/you-draw-it.css +0 -126
- package/src/theme/__tests__/dark-mode.test.ts +0 -232
- package/src/theme/__tests__/defaults.test.ts +0 -73
- package/src/theme/__tests__/resolve.test.ts +0 -277
- package/src/theme/dark-mode.ts +0 -258
- package/src/theme/defaults.ts +0 -110
- package/src/theme/index.ts +0 -8
- package/src/theme/presets.ts +0 -64
- package/src/theme/resolve.ts +0 -312
- package/src/types/__tests__/encoding.test.ts +0 -368
- package/src/types/__tests__/gradient.test.ts +0 -95
- package/src/types/__tests__/spec.test.ts +0 -499
- package/src/types/encoding.ts +0 -280
- package/src/types/events.ts +0 -236
- package/src/types/index.ts +0 -290
- package/src/types/layout.ts +0 -2024
- package/src/types/schema-roots.ts +0 -32
- package/src/types/spec.ts +0 -2925
- package/src/types/table.ts +0 -108
- package/src/types/theme.ts +0 -209
package/src/locale/format.ts
DELETED
|
@@ -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
|
-
}
|
package/src/locale/index.ts
DELETED
|
@@ -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
|
-
});
|