@cfasim-ui/docs 0.4.11 → 0.4.13
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/charts/BarChart/BarChart.md +54 -1
- package/charts/BarChart/BarChart.vue +180 -28
- package/charts/ChoroplethMap/ChoroplethMap.md +8 -2
- package/charts/ChoroplethMap/ChoroplethMap.vue +89 -7
- package/charts/LineChart/LineChart.md +138 -9
- package/charts/LineChart/LineChart.vue +295 -87
- package/charts/_shared/ChartAnnotations.vue +56 -17
- package/charts/_shared/annotations.ts +14 -9
- package/charts/_shared/chartProps.ts +39 -0
- package/charts/_shared/dateAxis.ts +507 -0
- package/charts/_shared/index.ts +22 -0
- package/charts/_shared/useChartFoundation.ts +3 -0
- package/charts/_shared/useChartPadding.ts +53 -5
- package/charts/hsa-mapping.ts +1 -0
- package/charts/index.ts +0 -1
- package/index.json +1 -1
- package/package.json +1 -1
- package/shared/index.ts +6 -1
- package/shared/useUrlParams.ts +320 -42
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Date axis support: parsing, auto-detection, tick selection, and
|
|
3
|
+
* formatting for chart x-axes that carry temporal data. All functions
|
|
4
|
+
* are pure (no Vue reactivity, no DOM); chart components call into this
|
|
5
|
+
* module from their computed setup.
|
|
6
|
+
*
|
|
7
|
+
* Design notes:
|
|
8
|
+
* - Parsing is regex-based, not `Date.parse`. The ES spec parses bare
|
|
9
|
+
* `YYYY-MM-DD` as UTC but offset-less `YYYY-MM-DDTHH:mm:ss` as local,
|
|
10
|
+
* which makes a `timezone` prop incoherent. Manual parsing keeps the
|
|
11
|
+
* timezone interpretation consistent.
|
|
12
|
+
* - `isDateLike` deliberately rejects plain numbers so existing numeric
|
|
13
|
+
* demos (generation indices, day counters) aren't misclassified.
|
|
14
|
+
* - Tick stepping for month/year (and day/week in local mode) goes
|
|
15
|
+
* through `Date` field math so DST and month-length irregularities
|
|
16
|
+
* don't drift the tick boundaries.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type DateTickUnit =
|
|
20
|
+
| "year"
|
|
21
|
+
| "month"
|
|
22
|
+
| "week"
|
|
23
|
+
| "day"
|
|
24
|
+
| "hour"
|
|
25
|
+
| "minute"
|
|
26
|
+
| "second";
|
|
27
|
+
|
|
28
|
+
export type DateTimezone = "utc" | "local";
|
|
29
|
+
|
|
30
|
+
export type DateFormatPreset =
|
|
31
|
+
| "iso"
|
|
32
|
+
| "iso-datetime"
|
|
33
|
+
| "year"
|
|
34
|
+
| "month-year"
|
|
35
|
+
| "month-day"
|
|
36
|
+
| "day"
|
|
37
|
+
| "time"
|
|
38
|
+
| "datetime"
|
|
39
|
+
| "medium";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* How to render a tick label on a date axis. Accepts:
|
|
43
|
+
* - A preset name (`"iso"`, `"month-year"`, ...).
|
|
44
|
+
* - An `Intl.DateTimeFormatOptions` literal for full control.
|
|
45
|
+
* - A function receiving the tick's epoch-ms and the tick unit the
|
|
46
|
+
* chart picked. Useful for unit-aware custom formatting.
|
|
47
|
+
*/
|
|
48
|
+
export type DateFormat =
|
|
49
|
+
| DateFormatPreset
|
|
50
|
+
| Intl.DateTimeFormatOptions
|
|
51
|
+
| ((ms: number, unit?: DateTickUnit) => string);
|
|
52
|
+
|
|
53
|
+
const ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
54
|
+
const ISO_DATETIME_RE =
|
|
55
|
+
/^(\d{4})-(\d{2})-(\d{2})[Tt ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?(Z|[+-]\d{2}:?\d{2})?$/;
|
|
56
|
+
|
|
57
|
+
const MS_PER_SECOND = 1000;
|
|
58
|
+
const MS_PER_MINUTE = 60 * MS_PER_SECOND;
|
|
59
|
+
const MS_PER_HOUR = 60 * MS_PER_MINUTE;
|
|
60
|
+
const MS_PER_DAY = 24 * MS_PER_HOUR;
|
|
61
|
+
const MS_PER_WEEK = 7 * MS_PER_DAY;
|
|
62
|
+
const MS_PER_MONTH_APPROX = 30 * MS_PER_DAY;
|
|
63
|
+
const MS_PER_YEAR_APPROX = 365 * MS_PER_DAY;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Candidate tick intervals sorted by ascending duration. `pickInterval`
|
|
67
|
+
* walks this list in order and returns the smallest entry whose `ms`
|
|
68
|
+
* is at least the rough per-tick duration the caller is targeting,
|
|
69
|
+
* giving at most ~`targetCount` ticks. Year step set is locked to
|
|
70
|
+
* `{1,2,5,10,25,50,100}` (no "3-year" step).
|
|
71
|
+
*/
|
|
72
|
+
interface TickInterval {
|
|
73
|
+
unit: DateTickUnit;
|
|
74
|
+
step: number;
|
|
75
|
+
ms: number;
|
|
76
|
+
}
|
|
77
|
+
const TICK_INTERVALS: readonly TickInterval[] = [
|
|
78
|
+
{ unit: "second", step: 1, ms: 1 * MS_PER_SECOND },
|
|
79
|
+
{ unit: "second", step: 5, ms: 5 * MS_PER_SECOND },
|
|
80
|
+
{ unit: "second", step: 15, ms: 15 * MS_PER_SECOND },
|
|
81
|
+
{ unit: "second", step: 30, ms: 30 * MS_PER_SECOND },
|
|
82
|
+
{ unit: "minute", step: 1, ms: 1 * MS_PER_MINUTE },
|
|
83
|
+
{ unit: "minute", step: 5, ms: 5 * MS_PER_MINUTE },
|
|
84
|
+
{ unit: "minute", step: 15, ms: 15 * MS_PER_MINUTE },
|
|
85
|
+
{ unit: "minute", step: 30, ms: 30 * MS_PER_MINUTE },
|
|
86
|
+
{ unit: "hour", step: 1, ms: 1 * MS_PER_HOUR },
|
|
87
|
+
{ unit: "hour", step: 3, ms: 3 * MS_PER_HOUR },
|
|
88
|
+
{ unit: "hour", step: 6, ms: 6 * MS_PER_HOUR },
|
|
89
|
+
{ unit: "hour", step: 12, ms: 12 * MS_PER_HOUR },
|
|
90
|
+
{ unit: "day", step: 1, ms: 1 * MS_PER_DAY },
|
|
91
|
+
{ unit: "day", step: 2, ms: 2 * MS_PER_DAY },
|
|
92
|
+
{ unit: "week", step: 1, ms: 1 * MS_PER_WEEK },
|
|
93
|
+
{ unit: "month", step: 1, ms: 1 * MS_PER_MONTH_APPROX },
|
|
94
|
+
{ unit: "month", step: 3, ms: 3 * MS_PER_MONTH_APPROX },
|
|
95
|
+
{ unit: "month", step: 6, ms: 6 * MS_PER_MONTH_APPROX },
|
|
96
|
+
{ unit: "year", step: 1, ms: 1 * MS_PER_YEAR_APPROX },
|
|
97
|
+
{ unit: "year", step: 2, ms: 2 * MS_PER_YEAR_APPROX },
|
|
98
|
+
{ unit: "year", step: 5, ms: 5 * MS_PER_YEAR_APPROX },
|
|
99
|
+
{ unit: "year", step: 10, ms: 10 * MS_PER_YEAR_APPROX },
|
|
100
|
+
{ unit: "year", step: 25, ms: 25 * MS_PER_YEAR_APPROX },
|
|
101
|
+
{ unit: "year", step: 50, ms: 50 * MS_PER_YEAR_APPROX },
|
|
102
|
+
{ unit: "year", step: 100, ms: 100 * MS_PER_YEAR_APPROX },
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
const MAX_TICK_ITER = 1000;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Parse `v` into an epoch-ms timestamp under the given timezone.
|
|
109
|
+
* Accepts `Date` instances and ISO-shaped strings; returns `null` for
|
|
110
|
+
* anything else (including plain `number`s — see `isDateLike`).
|
|
111
|
+
*/
|
|
112
|
+
export function parseDate(v: unknown, tz: DateTimezone): number | null {
|
|
113
|
+
if (v instanceof Date) {
|
|
114
|
+
const t = v.getTime();
|
|
115
|
+
return Number.isFinite(t) ? t : null;
|
|
116
|
+
}
|
|
117
|
+
if (typeof v !== "string") return null;
|
|
118
|
+
|
|
119
|
+
const dm = ISO_DATE_RE.exec(v);
|
|
120
|
+
if (dm) {
|
|
121
|
+
const y = +dm[1];
|
|
122
|
+
const mo = +dm[2] - 1;
|
|
123
|
+
const d = +dm[3];
|
|
124
|
+
if (tz === "utc") return Date.UTC(y, mo, d);
|
|
125
|
+
return new Date(y, mo, d).getTime();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const tm = ISO_DATETIME_RE.exec(v);
|
|
129
|
+
if (tm) {
|
|
130
|
+
const y = +tm[1];
|
|
131
|
+
const mo = +tm[2] - 1;
|
|
132
|
+
const d = +tm[3];
|
|
133
|
+
const h = +tm[4];
|
|
134
|
+
const mi = +tm[5];
|
|
135
|
+
const se = tm[6] ? +tm[6] : 0;
|
|
136
|
+
// Regex caps the fractional-second group at 3 digits, so this is
|
|
137
|
+
// always 0..999.
|
|
138
|
+
const ms = tm[7] ? Number("0." + tm[7]) * 1000 : 0;
|
|
139
|
+
const offset = tm[8];
|
|
140
|
+
if (offset) {
|
|
141
|
+
// Explicit offset overrides the prop's tz interpretation.
|
|
142
|
+
if (offset === "Z" || offset === "z") {
|
|
143
|
+
return Date.UTC(y, mo, d, h, mi, se, ms);
|
|
144
|
+
}
|
|
145
|
+
const sign = offset[0] === "+" ? 1 : -1;
|
|
146
|
+
const offHour = +offset.slice(1, 3);
|
|
147
|
+
const offMin = +offset.slice(-2);
|
|
148
|
+
const offsetMs = sign * (offHour * 60 + offMin) * MS_PER_MINUTE;
|
|
149
|
+
return Date.UTC(y, mo, d, h, mi, se, ms) - offsetMs;
|
|
150
|
+
}
|
|
151
|
+
if (tz === "utc") return Date.UTC(y, mo, d, h, mi, se, ms);
|
|
152
|
+
return new Date(y, mo, d, h, mi, se, ms).getTime();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Whether `v` looks like a date value to the auto-detector. Strict:
|
|
160
|
+
* `Date` instances or ISO-shaped strings only. Plain numbers are *not*
|
|
161
|
+
* considered date-like so numeric demos can't be silently promoted to
|
|
162
|
+
* a date axis.
|
|
163
|
+
*/
|
|
164
|
+
export function isDateLike(v: unknown): boolean {
|
|
165
|
+
if (v instanceof Date) return Number.isFinite(v.getTime());
|
|
166
|
+
if (typeof v !== "string") return false;
|
|
167
|
+
return ISO_DATE_RE.test(v) || ISO_DATETIME_RE.test(v);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Whether every entry of `values` is date-like and parses cleanly. An
|
|
172
|
+
* empty array is *not* considered all-dates (auto-detection should
|
|
173
|
+
* stay opt-in by content).
|
|
174
|
+
*/
|
|
175
|
+
export function isAllDates(
|
|
176
|
+
values: ArrayLike<unknown>,
|
|
177
|
+
tz: DateTimezone,
|
|
178
|
+
): boolean {
|
|
179
|
+
if (values.length === 0) return false;
|
|
180
|
+
for (let i = 0; i < values.length; i++) {
|
|
181
|
+
const v = values[i];
|
|
182
|
+
if (!isDateLike(v)) return false;
|
|
183
|
+
if (parseDate(v, tz) === null) return false;
|
|
184
|
+
}
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Pick the smallest tick interval whose duration is at least the rough
|
|
190
|
+
* per-tick budget (`span / targetCount`). Guarantees at most
|
|
191
|
+
* ~`targetCount` ticks.
|
|
192
|
+
*/
|
|
193
|
+
function chooseInterval(spanMs: number, targetCount: number): TickInterval {
|
|
194
|
+
const rough = spanMs / Math.max(1, targetCount);
|
|
195
|
+
for (const candidate of TICK_INTERVALS) {
|
|
196
|
+
if (rough <= candidate.ms) return candidate;
|
|
197
|
+
}
|
|
198
|
+
return TICK_INTERVALS[TICK_INTERVALS.length - 1];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function snapToUnit(ms: number, unit: DateTickUnit, tz: DateTimezone): number {
|
|
202
|
+
const d = new Date(ms);
|
|
203
|
+
if (tz === "utc") {
|
|
204
|
+
switch (unit) {
|
|
205
|
+
case "second":
|
|
206
|
+
return Math.floor(ms / MS_PER_SECOND) * MS_PER_SECOND;
|
|
207
|
+
case "minute":
|
|
208
|
+
return Math.floor(ms / MS_PER_MINUTE) * MS_PER_MINUTE;
|
|
209
|
+
case "hour":
|
|
210
|
+
return Math.floor(ms / MS_PER_HOUR) * MS_PER_HOUR;
|
|
211
|
+
case "day":
|
|
212
|
+
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
|
|
213
|
+
case "week": {
|
|
214
|
+
// ISO 8601 week starts Monday. JS day: Sun=0..Sat=6.
|
|
215
|
+
const dayIndex = d.getUTCDay();
|
|
216
|
+
const daysSinceMonday = (dayIndex + 6) % 7;
|
|
217
|
+
return Date.UTC(
|
|
218
|
+
d.getUTCFullYear(),
|
|
219
|
+
d.getUTCMonth(),
|
|
220
|
+
d.getUTCDate() - daysSinceMonday,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
case "month":
|
|
224
|
+
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1);
|
|
225
|
+
case "year":
|
|
226
|
+
return Date.UTC(d.getUTCFullYear(), 0, 1);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
switch (unit) {
|
|
230
|
+
case "second":
|
|
231
|
+
return Math.floor(ms / MS_PER_SECOND) * MS_PER_SECOND;
|
|
232
|
+
case "minute":
|
|
233
|
+
return Math.floor(ms / MS_PER_MINUTE) * MS_PER_MINUTE;
|
|
234
|
+
case "hour":
|
|
235
|
+
return new Date(
|
|
236
|
+
d.getFullYear(),
|
|
237
|
+
d.getMonth(),
|
|
238
|
+
d.getDate(),
|
|
239
|
+
d.getHours(),
|
|
240
|
+
).getTime();
|
|
241
|
+
case "day":
|
|
242
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
243
|
+
case "week": {
|
|
244
|
+
const dayIndex = d.getDay();
|
|
245
|
+
const daysSinceMonday = (dayIndex + 6) % 7;
|
|
246
|
+
return new Date(
|
|
247
|
+
d.getFullYear(),
|
|
248
|
+
d.getMonth(),
|
|
249
|
+
d.getDate() - daysSinceMonday,
|
|
250
|
+
).getTime();
|
|
251
|
+
}
|
|
252
|
+
case "month":
|
|
253
|
+
return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
|
|
254
|
+
case "year":
|
|
255
|
+
return new Date(d.getFullYear(), 0, 1).getTime();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function addUnit(
|
|
260
|
+
ms: number,
|
|
261
|
+
unit: DateTickUnit,
|
|
262
|
+
step: number,
|
|
263
|
+
tz: DateTimezone,
|
|
264
|
+
): number {
|
|
265
|
+
if (unit === "second") return ms + step * MS_PER_SECOND;
|
|
266
|
+
if (unit === "minute") return ms + step * MS_PER_MINUTE;
|
|
267
|
+
if (unit === "hour" && tz === "utc") return ms + step * MS_PER_HOUR;
|
|
268
|
+
if (unit === "week" && tz === "utc") return ms + step * MS_PER_WEEK;
|
|
269
|
+
|
|
270
|
+
const d = new Date(ms);
|
|
271
|
+
if (tz === "utc") {
|
|
272
|
+
switch (unit) {
|
|
273
|
+
case "day":
|
|
274
|
+
return Date.UTC(
|
|
275
|
+
d.getUTCFullYear(),
|
|
276
|
+
d.getUTCMonth(),
|
|
277
|
+
d.getUTCDate() + step,
|
|
278
|
+
d.getUTCHours(),
|
|
279
|
+
d.getUTCMinutes(),
|
|
280
|
+
d.getUTCSeconds(),
|
|
281
|
+
d.getUTCMilliseconds(),
|
|
282
|
+
);
|
|
283
|
+
case "month":
|
|
284
|
+
return Date.UTC(
|
|
285
|
+
d.getUTCFullYear(),
|
|
286
|
+
d.getUTCMonth() + step,
|
|
287
|
+
d.getUTCDate(),
|
|
288
|
+
d.getUTCHours(),
|
|
289
|
+
d.getUTCMinutes(),
|
|
290
|
+
d.getUTCSeconds(),
|
|
291
|
+
d.getUTCMilliseconds(),
|
|
292
|
+
);
|
|
293
|
+
case "year":
|
|
294
|
+
return Date.UTC(
|
|
295
|
+
d.getUTCFullYear() + step,
|
|
296
|
+
d.getUTCMonth(),
|
|
297
|
+
d.getUTCDate(),
|
|
298
|
+
d.getUTCHours(),
|
|
299
|
+
d.getUTCMinutes(),
|
|
300
|
+
d.getUTCSeconds(),
|
|
301
|
+
d.getUTCMilliseconds(),
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// Local mode: Date field math handles DST and month length.
|
|
306
|
+
switch (unit) {
|
|
307
|
+
case "hour":
|
|
308
|
+
return new Date(
|
|
309
|
+
d.getFullYear(),
|
|
310
|
+
d.getMonth(),
|
|
311
|
+
d.getDate(),
|
|
312
|
+
d.getHours() + step,
|
|
313
|
+
d.getMinutes(),
|
|
314
|
+
d.getSeconds(),
|
|
315
|
+
d.getMilliseconds(),
|
|
316
|
+
).getTime();
|
|
317
|
+
case "day":
|
|
318
|
+
return new Date(
|
|
319
|
+
d.getFullYear(),
|
|
320
|
+
d.getMonth(),
|
|
321
|
+
d.getDate() + step,
|
|
322
|
+
d.getHours(),
|
|
323
|
+
d.getMinutes(),
|
|
324
|
+
d.getSeconds(),
|
|
325
|
+
d.getMilliseconds(),
|
|
326
|
+
).getTime();
|
|
327
|
+
case "week":
|
|
328
|
+
return new Date(
|
|
329
|
+
d.getFullYear(),
|
|
330
|
+
d.getMonth(),
|
|
331
|
+
d.getDate() + step * 7,
|
|
332
|
+
d.getHours(),
|
|
333
|
+
d.getMinutes(),
|
|
334
|
+
d.getSeconds(),
|
|
335
|
+
d.getMilliseconds(),
|
|
336
|
+
).getTime();
|
|
337
|
+
case "month":
|
|
338
|
+
return new Date(
|
|
339
|
+
d.getFullYear(),
|
|
340
|
+
d.getMonth() + step,
|
|
341
|
+
d.getDate(),
|
|
342
|
+
d.getHours(),
|
|
343
|
+
d.getMinutes(),
|
|
344
|
+
d.getSeconds(),
|
|
345
|
+
d.getMilliseconds(),
|
|
346
|
+
).getTime();
|
|
347
|
+
case "year":
|
|
348
|
+
return new Date(
|
|
349
|
+
d.getFullYear() + step,
|
|
350
|
+
d.getMonth(),
|
|
351
|
+
d.getDate(),
|
|
352
|
+
d.getHours(),
|
|
353
|
+
d.getMinutes(),
|
|
354
|
+
d.getSeconds(),
|
|
355
|
+
d.getMilliseconds(),
|
|
356
|
+
).getTime();
|
|
357
|
+
}
|
|
358
|
+
// Unreachable — the early returns above cover second/minute/hour-utc/week-utc.
|
|
359
|
+
return ms;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Choose tick values for a date axis spanning `[minMs, maxMs]`. The
|
|
364
|
+
* returned `unit` lets the default formatter pick a unit-appropriate
|
|
365
|
+
* preset (year ticks → "year", month → "month-year", day → "month-day",
|
|
366
|
+
* etc.) — same trick Vega-Lite uses.
|
|
367
|
+
*/
|
|
368
|
+
export function pickDateTicks(
|
|
369
|
+
minMs: number,
|
|
370
|
+
maxMs: number,
|
|
371
|
+
targetCount: number,
|
|
372
|
+
tz: DateTimezone,
|
|
373
|
+
): { values: number[]; unit: DateTickUnit } {
|
|
374
|
+
if (
|
|
375
|
+
!Number.isFinite(minMs) ||
|
|
376
|
+
!Number.isFinite(maxMs) ||
|
|
377
|
+
minMs >= maxMs ||
|
|
378
|
+
targetCount < 1
|
|
379
|
+
) {
|
|
380
|
+
return { values: [], unit: "day" };
|
|
381
|
+
}
|
|
382
|
+
const span = maxMs - minMs;
|
|
383
|
+
const { unit, step } = chooseInterval(span, targetCount);
|
|
384
|
+
|
|
385
|
+
let v = snapToUnit(minMs, unit, tz);
|
|
386
|
+
let safety = 0;
|
|
387
|
+
while (v < minMs && safety < MAX_TICK_ITER) {
|
|
388
|
+
v = addUnit(v, unit, step, tz);
|
|
389
|
+
safety++;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const values: number[] = [];
|
|
393
|
+
for (let i = 0; i < MAX_TICK_ITER && v <= maxMs; i++) {
|
|
394
|
+
values.push(v);
|
|
395
|
+
v = addUnit(v, unit, step, tz);
|
|
396
|
+
}
|
|
397
|
+
return { values, unit };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const PRESET_OPTIONS: Record<DateFormatPreset, Intl.DateTimeFormatOptions> = {
|
|
401
|
+
// "iso" and "iso-datetime" route through manual formatters below so
|
|
402
|
+
// the output stays exactly YYYY-MM-DD regardless of locale.
|
|
403
|
+
iso: { year: "numeric", month: "2-digit", day: "2-digit" },
|
|
404
|
+
"iso-datetime": {
|
|
405
|
+
year: "numeric",
|
|
406
|
+
month: "2-digit",
|
|
407
|
+
day: "2-digit",
|
|
408
|
+
hour: "2-digit",
|
|
409
|
+
minute: "2-digit",
|
|
410
|
+
second: "2-digit",
|
|
411
|
+
hourCycle: "h23",
|
|
412
|
+
},
|
|
413
|
+
year: { year: "numeric" },
|
|
414
|
+
"month-year": { month: "short", year: "numeric" },
|
|
415
|
+
"month-day": { month: "short", day: "numeric" },
|
|
416
|
+
day: { day: "numeric" },
|
|
417
|
+
time: { hour: "2-digit", minute: "2-digit", hourCycle: "h23" },
|
|
418
|
+
datetime: {
|
|
419
|
+
month: "short",
|
|
420
|
+
day: "numeric",
|
|
421
|
+
hour: "2-digit",
|
|
422
|
+
minute: "2-digit",
|
|
423
|
+
hourCycle: "h23",
|
|
424
|
+
},
|
|
425
|
+
medium: { dateStyle: "medium" } as Intl.DateTimeFormatOptions,
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const UNIT_DEFAULT_PRESET: Record<DateTickUnit, DateFormatPreset> = {
|
|
429
|
+
year: "year",
|
|
430
|
+
month: "month-year",
|
|
431
|
+
week: "month-day",
|
|
432
|
+
day: "month-day",
|
|
433
|
+
hour: "time",
|
|
434
|
+
minute: "time",
|
|
435
|
+
second: "time",
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
/** All valid preset names. Exported for the disjointness invariant test. */
|
|
439
|
+
export const DATE_FORMAT_PRESETS = Object.keys(
|
|
440
|
+
PRESET_OPTIONS,
|
|
441
|
+
) as readonly DateFormatPreset[];
|
|
442
|
+
|
|
443
|
+
function pad2(n: number): string {
|
|
444
|
+
return n < 10 ? "0" + n : String(n);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function formatIso(ms: number, tz: DateTimezone): string {
|
|
448
|
+
const d = new Date(ms);
|
|
449
|
+
if (tz === "utc") {
|
|
450
|
+
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
|
|
451
|
+
}
|
|
452
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function formatIsoDateTime(ms: number, tz: DateTimezone): string {
|
|
456
|
+
const d = new Date(ms);
|
|
457
|
+
if (tz === "utc") {
|
|
458
|
+
return (
|
|
459
|
+
`${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}` +
|
|
460
|
+
`T${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}Z`
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
return (
|
|
464
|
+
`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}` +
|
|
465
|
+
`T${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function isPreset(name: string): name is DateFormatPreset {
|
|
470
|
+
return name in PRESET_OPTIONS;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Format an epoch-ms timestamp into a tick label. When `format` is
|
|
475
|
+
* undefined, picks a preset based on the tick `unit` (Vega-Lite style:
|
|
476
|
+
* year ticks read as "2024", month ticks as "Jan 2024", etc.).
|
|
477
|
+
*/
|
|
478
|
+
export function formatDate(
|
|
479
|
+
ms: number,
|
|
480
|
+
format: DateFormat | undefined,
|
|
481
|
+
tz: DateTimezone,
|
|
482
|
+
unit?: DateTickUnit,
|
|
483
|
+
): string {
|
|
484
|
+
if (typeof format === "function") return format(ms, unit);
|
|
485
|
+
|
|
486
|
+
let presetName: DateFormatPreset | null = null;
|
|
487
|
+
let options: Intl.DateTimeFormatOptions;
|
|
488
|
+
if (format === undefined) {
|
|
489
|
+
presetName = unit ? UNIT_DEFAULT_PRESET[unit] : "iso";
|
|
490
|
+
options = PRESET_OPTIONS[presetName];
|
|
491
|
+
} else if (typeof format === "string") {
|
|
492
|
+
if (!isPreset(format)) {
|
|
493
|
+
throw new Error(`Unknown date format preset: "${format}"`);
|
|
494
|
+
}
|
|
495
|
+
presetName = format;
|
|
496
|
+
options = PRESET_OPTIONS[presetName];
|
|
497
|
+
} else {
|
|
498
|
+
options = format;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (presetName === "iso") return formatIso(ms, tz);
|
|
502
|
+
if (presetName === "iso-datetime") return formatIsoDateTime(ms, tz);
|
|
503
|
+
|
|
504
|
+
const finalOpts: Intl.DateTimeFormatOptions =
|
|
505
|
+
tz === "utc" ? { ...options, timeZone: "UTC" } : options;
|
|
506
|
+
return new Intl.DateTimeFormat(undefined, finalOpts).format(ms);
|
|
507
|
+
}
|
package/charts/_shared/index.ts
CHANGED
|
@@ -16,7 +16,15 @@ export {
|
|
|
16
16
|
export { useChartSize, type ChartSizeOptions } from "./useChartSize.js";
|
|
17
17
|
export {
|
|
18
18
|
useChartPadding,
|
|
19
|
+
resolveLabelStyle,
|
|
19
20
|
INLINE_LEGEND_ROW_HEIGHT,
|
|
21
|
+
TITLE_LINE_HEIGHT,
|
|
22
|
+
TITLE_FONT_SIZE,
|
|
23
|
+
TITLE_FONT_WEIGHT,
|
|
24
|
+
AXIS_LABEL_FONT_SIZE,
|
|
25
|
+
TICK_LABEL_FONT_SIZE,
|
|
26
|
+
TICK_LABEL_OPACITY,
|
|
27
|
+
LEGEND_FONT_SIZE,
|
|
20
28
|
type ChartPaddingOptions,
|
|
21
29
|
type ChartPadding,
|
|
22
30
|
type ChartBounds,
|
|
@@ -41,4 +49,18 @@ export type {
|
|
|
41
49
|
ChartHoverPayload,
|
|
42
50
|
ChartTooltipValue,
|
|
43
51
|
ChartTooltipBaseProps,
|
|
52
|
+
TitleStyle,
|
|
53
|
+
LabelStyle,
|
|
44
54
|
} from "./chartProps.js";
|
|
55
|
+
export {
|
|
56
|
+
parseDate,
|
|
57
|
+
isDateLike,
|
|
58
|
+
isAllDates,
|
|
59
|
+
pickDateTicks,
|
|
60
|
+
formatDate,
|
|
61
|
+
DATE_FORMAT_PRESETS,
|
|
62
|
+
type DateFormat,
|
|
63
|
+
type DateFormatPreset,
|
|
64
|
+
type DateTickUnit,
|
|
65
|
+
type DateTimezone,
|
|
66
|
+
} from "./dateAxis.js";
|
|
@@ -6,6 +6,7 @@ import { useChartPadding, type ChartPadding } from "./useChartPadding.js";
|
|
|
6
6
|
import { useChartTooltip } from "./useChartTooltip.js";
|
|
7
7
|
import type { TooltipClamp } from "../tooltip-position.js";
|
|
8
8
|
import { useChartMenu } from "./useChartMenu.js";
|
|
9
|
+
import type { TitleStyle } from "./chartProps.js";
|
|
9
10
|
|
|
10
11
|
const DEFAULT_WIDTH_FALLBACK = 400;
|
|
11
12
|
const DEFAULT_HEIGHT = 200;
|
|
@@ -15,6 +16,7 @@ export interface ChartFoundationOptions {
|
|
|
15
16
|
width: () => number | undefined;
|
|
16
17
|
height: () => number | undefined;
|
|
17
18
|
title: () => string | undefined;
|
|
19
|
+
titleStyle: () => TitleStyle | undefined;
|
|
18
20
|
xLabel: () => string | undefined;
|
|
19
21
|
yLabel: () => string | undefined;
|
|
20
22
|
debounce: () => number | undefined;
|
|
@@ -87,6 +89,7 @@ export function useChartFoundation(opts: ChartFoundationOptions) {
|
|
|
87
89
|
const { padding, legendY, inlineLegendLayout, innerW, innerH, bounds } =
|
|
88
90
|
useChartPadding({
|
|
89
91
|
title: opts.title,
|
|
92
|
+
titleStyle: opts.titleStyle,
|
|
90
93
|
xLabel: opts.xLabel,
|
|
91
94
|
yLabel: opts.yLabel,
|
|
92
95
|
inlineLegendLabels: opts.inlineLegendLabels,
|
|
@@ -1,7 +1,46 @@
|
|
|
1
1
|
import { computed } from "vue";
|
|
2
|
+
import type { LabelStyle, TitleStyle } from "./chartProps.js";
|
|
2
3
|
|
|
3
4
|
/** Vertical space reserved per row of the inline legend strip. */
|
|
4
5
|
export const INLINE_LEGEND_ROW_HEIGHT = 20;
|
|
6
|
+
/** Default line height (px) for the chart title; overridable per chart. */
|
|
7
|
+
export const TITLE_LINE_HEIGHT = 18;
|
|
8
|
+
/** Default font size (px) for the chart title; overridable per chart. */
|
|
9
|
+
export const TITLE_FONT_SIZE = 14;
|
|
10
|
+
/** Default font weight for the chart title; overridable per chart. */
|
|
11
|
+
export const TITLE_FONT_WEIGHT: number | string = 600;
|
|
12
|
+
/** Default font size (px) for axis labels (`xLabel` / `yLabel`). */
|
|
13
|
+
export const AXIS_LABEL_FONT_SIZE = 13;
|
|
14
|
+
/** Default font size (px) for axis tick labels (the numbers on each axis). */
|
|
15
|
+
export const TICK_LABEL_FONT_SIZE = 10;
|
|
16
|
+
/** Default fill-opacity for tick labels when no custom color is set. */
|
|
17
|
+
export const TICK_LABEL_OPACITY = 0.6;
|
|
18
|
+
/** Default font size (px) for inline legend item labels. */
|
|
19
|
+
export const LEGEND_FONT_SIZE = 11;
|
|
20
|
+
/** Vertical space below the title's last baseline before the next element. */
|
|
21
|
+
const TITLE_BOTTOM_GAP = 8;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve a `LabelStyle` against per-element defaults. Returns the
|
|
25
|
+
* fields the chart's `<text>` element needs. When `style.color` is set,
|
|
26
|
+
* the default fill-opacity is dropped so the exact color renders.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveLabelStyle(
|
|
29
|
+
style: LabelStyle | undefined,
|
|
30
|
+
defaults: { fontSize: number; fillOpacity?: number },
|
|
31
|
+
): {
|
|
32
|
+
fontSize: number;
|
|
33
|
+
fill: string;
|
|
34
|
+
fontWeight: number | string | undefined;
|
|
35
|
+
fillOpacity: number | undefined;
|
|
36
|
+
} {
|
|
37
|
+
return {
|
|
38
|
+
fontSize: style?.fontSize ?? defaults.fontSize,
|
|
39
|
+
fill: style?.color ?? "currentColor",
|
|
40
|
+
fontWeight: style?.fontWeight,
|
|
41
|
+
fillOpacity: style?.color != null ? undefined : defaults.fillOpacity,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
5
44
|
/**
|
|
6
45
|
* Estimated character width at font-size 11 used by inline-legend
|
|
7
46
|
* measurement. Matches the value used by LineChart's area-section labels
|
|
@@ -30,6 +69,7 @@ export type ChartPadding =
|
|
|
30
69
|
|
|
31
70
|
export interface ChartPaddingOptions {
|
|
32
71
|
title: () => string | undefined;
|
|
72
|
+
titleStyle?: () => TitleStyle | undefined;
|
|
33
73
|
xLabel: () => string | undefined;
|
|
34
74
|
yLabel: () => string | undefined;
|
|
35
75
|
/**
|
|
@@ -128,14 +168,22 @@ export function useChartPadding(opts: ChartPaddingOptions) {
|
|
|
128
168
|
return { positions, rowCount: row + 1 };
|
|
129
169
|
});
|
|
130
170
|
|
|
171
|
+
// Title height in px. `\n` in the title creates additional lines, each
|
|
172
|
+
// adding `titleStyle.lineHeight` (default 18). When there is no title,
|
|
173
|
+
// reserve a small top margin so the plot doesn't hug the top edge.
|
|
174
|
+
const titleHeight = computed(() => {
|
|
175
|
+
const t = opts.title();
|
|
176
|
+
if (!t) return 10;
|
|
177
|
+
const lineHeight = opts.titleStyle?.()?.lineHeight ?? TITLE_LINE_HEIGHT;
|
|
178
|
+
const lineCount = t.split("\n").length;
|
|
179
|
+
return lineCount * lineHeight + TITLE_BOTTOM_GAP;
|
|
180
|
+
});
|
|
181
|
+
|
|
131
182
|
const padding = computed(() => {
|
|
132
183
|
const extra = resolvePadding(opts.extraPadding?.());
|
|
133
184
|
const rowCount = inlineLegendLayout.value.rowCount;
|
|
134
185
|
return {
|
|
135
|
-
top:
|
|
136
|
-
(opts.title() ? 26 : 10) +
|
|
137
|
-
rowCount * INLINE_LEGEND_ROW_HEIGHT +
|
|
138
|
-
extra.top,
|
|
186
|
+
top: titleHeight.value + rowCount * INLINE_LEGEND_ROW_HEIGHT + extra.top,
|
|
139
187
|
bottom: (opts.xLabel() ? 38 : 30) + extra.bottom,
|
|
140
188
|
left: horizontalPadding.value.left,
|
|
141
189
|
right: horizontalPadding.value.right,
|
|
@@ -147,7 +195,7 @@ export function useChartPadding(opts: ChartPaddingOptions) {
|
|
|
147
195
|
// `extraPadding.top` becomes empty room between the legend and the
|
|
148
196
|
// plot. Subsequent rows are offset by `INLINE_LEGEND_ROW_HEIGHT`.
|
|
149
197
|
const legendY = computed(
|
|
150
|
-
() =>
|
|
198
|
+
() => titleHeight.value + INLINE_LEGEND_ROW_HEIGHT / 2,
|
|
151
199
|
);
|
|
152
200
|
|
|
153
201
|
const innerH = computed(
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { fipsToHsa, hsaNames } from "./ChoroplethMap/hsaMapping.js";
|
package/charts/index.ts
CHANGED
|
@@ -23,7 +23,6 @@ export {
|
|
|
23
23
|
type FocusValue,
|
|
24
24
|
type FocusStyle,
|
|
25
25
|
} from "./ChoroplethMap/ChoroplethMap.vue";
|
|
26
|
-
export { fipsToHsa, hsaNames } from "./ChoroplethMap/hsaMapping.js";
|
|
27
26
|
export { default as ChartTooltip } from "./ChartTooltip/ChartTooltip.vue";
|
|
28
27
|
export {
|
|
29
28
|
default as DataTable,
|
package/index.json
CHANGED
package/package.json
CHANGED
package/shared/index.ts
CHANGED
|
@@ -22,5 +22,10 @@ export {
|
|
|
22
22
|
deserialize,
|
|
23
23
|
paramsToQuery,
|
|
24
24
|
queryToParams,
|
|
25
|
+
jsonCodec,
|
|
26
|
+
} from "./useUrlParams.js";
|
|
27
|
+
export type {
|
|
28
|
+
UrlParamsOptions,
|
|
29
|
+
ParamCodec,
|
|
30
|
+
ParamCodecs,
|
|
25
31
|
} from "./useUrlParams.js";
|
|
26
|
-
export type { UrlParamsOptions } from "./useUrlParams.js";
|