@cfasim-ui/charts 0.7.7 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/BarChart/BarChart.d.ts +9 -26
  2. package/dist/ChartMenu/ChartMenu.d.ts +3 -2
  3. package/dist/ChartTooltip/ChartTooltip.d.ts +9 -12
  4. package/dist/ChoroplethMap/ChoroplethMap.d.ts +28 -189
  5. package/dist/ChoroplethMap/ChoroplethTooltip.d.ts +20 -27
  6. package/dist/ChoroplethMap/canvasLayer.d.ts +27 -3
  7. package/dist/DataTable/DataTable.d.ts +3 -2
  8. package/dist/LineChart/LineChart.d.ts +9 -26
  9. package/dist/_shared/ChartAnnotations.d.ts +3 -2
  10. package/dist/_shared/ChartZoomControls.d.ts +3 -2
  11. package/dist/_shared/index.d.ts +2 -1
  12. package/dist/_shared/mapTheme.d.ts +159 -0
  13. package/dist/_shared/mapTheme.test.d.ts +1 -0
  14. package/dist/index.css +1 -1
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.js +1710 -1432
  17. package/docs/BarChart.md +776 -0
  18. package/docs/ChoroplethMap.md +1267 -0
  19. package/docs/DataTable.md +386 -0
  20. package/docs/LineChart.md +1267 -0
  21. package/docs/index.json +56 -0
  22. package/package.json +25 -21
  23. package/src/BarChart/BarChart.md +743 -0
  24. package/src/BarChart/BarChart.vue +1901 -0
  25. package/src/ChartMenu/ChartMenu.vue +220 -0
  26. package/src/ChartMenu/download.ts +120 -0
  27. package/src/ChartTooltip/ChartTooltip.vue +97 -0
  28. package/src/ChoroplethMap/ChoroplethMap.md +1227 -0
  29. package/src/ChoroplethMap/ChoroplethMap.vue +3676 -0
  30. package/src/ChoroplethMap/ChoroplethTooltip.vue +103 -0
  31. package/src/ChoroplethMap/canvasLayer.ts +373 -0
  32. package/src/ChoroplethMap/cityLayout.ts +261 -0
  33. package/src/ChoroplethMap/hsaMapping.ts +4116 -0
  34. package/src/DataTable/DataTable.md +372 -0
  35. package/src/DataTable/DataTable.vue +406 -0
  36. package/src/LineChart/LineChart.md +1225 -0
  37. package/src/LineChart/LineChart.vue +1555 -0
  38. package/src/_shared/ChartAnnotations.vue +420 -0
  39. package/src/_shared/ChartZoomControls.vue +138 -0
  40. package/src/_shared/annotations.ts +106 -0
  41. package/src/_shared/axes.ts +69 -0
  42. package/src/_shared/chartProps.ts +201 -0
  43. package/src/_shared/computeTicks.ts +42 -0
  44. package/src/_shared/contrast.ts +100 -0
  45. package/src/_shared/dateAxis.ts +501 -0
  46. package/src/_shared/index.ts +78 -0
  47. package/src/_shared/mapTheme.ts +551 -0
  48. package/src/_shared/scale.ts +86 -0
  49. package/src/_shared/seriesCsv.ts +68 -0
  50. package/src/_shared/touch.ts +8 -0
  51. package/src/_shared/useChartFoundation.ts +175 -0
  52. package/src/_shared/useChartFullscreen.ts +254 -0
  53. package/src/_shared/useChartMenu.ts +111 -0
  54. package/src/_shared/useChartPadding.ts +235 -0
  55. package/src/_shared/useChartSize.ts +58 -0
  56. package/src/_shared/useChartTooltip.ts +205 -0
  57. package/src/env.d.ts +4 -0
  58. package/src/hsa-mapping.ts +1 -0
  59. package/src/index.ts +41 -0
  60. package/src/tooltip-position.ts +55 -0
  61. package/src/us-cities/data.ts +1371 -0
  62. package/src/us-cities/index.ts +122 -0
  63. package/src/us-cities.ts +7 -0
@@ -0,0 +1,551 @@
1
+ /**
2
+ * Map theming. All paint styling for the choropleth surface lives in one
3
+ * `MapTheme` object: base fill, interior strokes, the state-borders mesh,
4
+ * an exterior outline, a background wash, and the hover/focus highlight.
5
+ *
6
+ * Every color accepts any CSS color the page can express (`var()`,
7
+ * `light-dark()`, `currentColor`, `color-mix()`), resolved against the map
8
+ * container's cascade by a hidden probe. Defaults route through
9
+ * `--choropleth-*` custom properties with `light-dark()` fallbacks, so a
10
+ * stylesheet alone can theme every map on a page; JS theme values win.
11
+ * When the effective values change (a class flip up the tree, an OS
12
+ * scheme change, a custom-property redefinition), the resolver notifies
13
+ * so the map can repaint. Ported from pleth's theme resolver.
14
+ */
15
+ import { computed, onMounted, onUnmounted, ref, type Ref } from "vue";
16
+ import { parseRgb } from "./contrast.js";
17
+
18
+ export interface MapTheme {
19
+ /**
20
+ * Base fill for features without a data value (any CSS color).
21
+ * Default: `var(--choropleth-fill, light-dark(#ddd, #3f3f46))`.
22
+ */
23
+ fill?: string;
24
+ /**
25
+ * Interior feature borders (any CSS color).
26
+ * Default: `var(--choropleth-stroke, light-dark(#fff, #18181b))`.
27
+ */
28
+ stroke?: string;
29
+ /**
30
+ * Feature border width in CSS px, kept constant on screen at any zoom.
31
+ * Unset falls back to the component default (0.5, halved on county/HSA
32
+ * maps); an explicit value applies as-is on every geoType. 0 disables.
33
+ */
34
+ strokeWidth?: number;
35
+ /**
36
+ * The state-boundary mesh drawn over county/HSA maps. Falls back to
37
+ * `stroke` unless a visible color resolves (from here or the
38
+ * `--choropleth-borders` custom property). Hide it with
39
+ * `bordersWidth: 0`.
40
+ */
41
+ borders?: string;
42
+ /** State-borders mesh width in CSS px. Default 1; 0 disables. */
43
+ bordersWidth?: number;
44
+ /**
45
+ * Exterior boundary of the rendered geography (the nation outline, or
46
+ * the state outline in single-state mode), drawn on top of interior
47
+ * borders. Off unless a visible color resolves, so the
48
+ * `--choropleth-outline` custom property can enable it (it defaults to
49
+ * `transparent`).
50
+ */
51
+ outline?: string;
52
+ /** Exterior outline width in CSS px. Default 1; 0 disables. */
53
+ outlineWidth?: number;
54
+ /**
55
+ * Background wash behind the map features. Off unless a visible color
56
+ * resolves, so the `--choropleth-background` custom property can enable
57
+ * it (it defaults to `transparent`).
58
+ */
59
+ background?: string;
60
+ /**
61
+ * Hover/focus highlight stroke. Default:
62
+ * `var(--choropleth-highlight, light-dark(#000, #fff))`. Per-item
63
+ * `FocusItem.stroke` still wins for individual focus outlines.
64
+ */
65
+ highlight?: string;
66
+ /**
67
+ * Marker overlay (the `cities` prop layer): dot and label color (any
68
+ * CSS color). Sets both `--choropleth-city-marker` and
69
+ * `--choropleth-city-label-color`; default `#1a1a1a` (dark markers
70
+ * with a thin white halo read over any fill in either scheme). The
71
+ * marker layer is SVG in both renderers, so these four keys resolve
72
+ * in CSS directly (no probe) and follow theme flips on their own.
73
+ */
74
+ markerColor?: string;
75
+ /**
76
+ * Marker overlay: halo color around dots and labels (sets
77
+ * `--choropleth-city-halo`). Default `#fff`.
78
+ */
79
+ markerHalo?: string;
80
+ /**
81
+ * Halo width around each marker dot, in CSS px (constant on screen at
82
+ * any zoom). Default 0.9; 0 disables the dot halo. The label halo is
83
+ * unaffected.
84
+ */
85
+ markerHaloWidth?: number;
86
+ /** Opacity of the whole marker layer (dots, labels, halos). Default 1. */
87
+ markerOpacity?: number;
88
+ }
89
+
90
+ /** Theme values resolved to canvas-paintable colors. */
91
+ export interface ResolvedMapTheme {
92
+ fill: string;
93
+ stroke: string;
94
+ strokeWidth: number | undefined;
95
+ /** Resolved borders color, falling back to `stroke`. */
96
+ borders: string;
97
+ bordersWidth: number | undefined;
98
+ /** undefined when the outline resolves invisible (off by default) */
99
+ outline: string | undefined;
100
+ outlineWidth: number | undefined;
101
+ /** undefined when the background resolves invisible (off by default) */
102
+ background: string | undefined;
103
+ highlight: string;
104
+ }
105
+
106
+ export const LIGHT_FILL = "#ddd";
107
+ export const DARK_FILL = "#3f3f46";
108
+ export const LIGHT_STROKE = "#fff";
109
+ export const DARK_STROKE = "#18181b";
110
+ export const LIGHT_HIGHLIGHT = "#000";
111
+ export const DARK_HIGHLIGHT = "#fff";
112
+
113
+ /**
114
+ * The built-in defaults: every channel routes through a `--choropleth-*`
115
+ * custom property, so CSS alone can retheme maps. `borders`, `outline`,
116
+ * and `background` use `transparent` sentinels, staying off (or, for
117
+ * borders, deferring to `stroke`) until a visible color resolves.
118
+ */
119
+ export const mapThemeDefaults = {
120
+ fill: `var(--choropleth-fill, light-dark(${LIGHT_FILL}, ${DARK_FILL}))`,
121
+ stroke: `var(--choropleth-stroke, light-dark(${LIGHT_STROKE}, ${DARK_STROKE}))`,
122
+ borders: "var(--choropleth-borders, transparent)",
123
+ outline: "var(--choropleth-outline, transparent)",
124
+ background: "var(--choropleth-background, transparent)",
125
+ highlight: `var(--choropleth-highlight, light-dark(${LIGHT_HIGHLIGHT}, ${DARK_HIGHLIGHT}))`,
126
+ } as const;
127
+
128
+ /** Shallow per-key equality; undefined and {} compare equal. */
129
+ export function themeEquals(
130
+ a: MapTheme | undefined,
131
+ b: MapTheme | undefined,
132
+ ): boolean {
133
+ return (
134
+ Object.is(a?.fill, b?.fill) &&
135
+ Object.is(a?.stroke, b?.stroke) &&
136
+ Object.is(a?.strokeWidth, b?.strokeWidth) &&
137
+ Object.is(a?.borders, b?.borders) &&
138
+ Object.is(a?.bordersWidth, b?.bordersWidth) &&
139
+ Object.is(a?.outline, b?.outline) &&
140
+ Object.is(a?.outlineWidth, b?.outlineWidth) &&
141
+ Object.is(a?.background, b?.background) &&
142
+ Object.is(a?.highlight, b?.highlight) &&
143
+ Object.is(a?.markerColor, b?.markerColor) &&
144
+ Object.is(a?.markerHalo, b?.markerHalo) &&
145
+ Object.is(a?.markerHaloWidth, b?.markerHaloWidth) &&
146
+ Object.is(a?.markerOpacity, b?.markerOpacity)
147
+ );
148
+ }
149
+
150
+ // zero-alpha in any computed serialization: the legacy comma forms put
151
+ // alpha as the 4th rgba()/hsla() argument, modern forms after a slash
152
+ const ALPHA_ZERO =
153
+ /^(?:rgba|hsla)\(.*,\s*0(?:\.0+)?\s*\)$|\/\s*0(?:\.0+)?\s*\)$/;
154
+
155
+ /** A resolved color that should actually paint; transparent means "off". */
156
+ export const visible = (c: string): string | undefined =>
157
+ c && c !== "transparent" && !ALPHA_ZERO.test(c) ? c : undefined;
158
+
159
+ /**
160
+ * Raw-value passthrough for environments without computed styles (test
161
+ * DOMs, containers with no layout): a theme value that already parses as
162
+ * a plain color paints as-is instead of dropping to the constants.
163
+ */
164
+ const plain = (c: string | undefined): string | undefined =>
165
+ c && parseRgb(c) ? c : undefined;
166
+
167
+ /**
168
+ * Bare var() parses but is invalid at computed-value time when the property
169
+ * is undefined, inheriting the page text color; give it a fallback instead.
170
+ * var() references that already carry a fallback are left alone.
171
+ */
172
+ export function withVarFallback(value: string, fallback: string): string {
173
+ return value.replace(/var\((\s*--[\w-]+\s*)\)/g, `var($1, ${fallback})`);
174
+ }
175
+
176
+ export interface MapThemeResolver {
177
+ resolve(theme: MapTheme | undefined): ResolvedMapTheme;
178
+ /**
179
+ * Resolve scale palette colors (colorScale endpoints and stop colors)
180
+ * through the cascade. Each entry gets a persistent watched span, so env
181
+ * changes to any entry notify. Returns a stable array identity until an
182
+ * entry actually resolves differently.
183
+ */
184
+ resolvePalette(
185
+ colors: readonly string[],
186
+ fallbacks: readonly string[],
187
+ ): readonly string[];
188
+ /** Force a re-read on the next resolve (env may have changed unobserved) */
189
+ invalidate(): void;
190
+ /**
191
+ * Compare probe reads against the cache and notify if anything changed.
192
+ * Transitions don't run in non-rendered subtrees, so a map hidden with
193
+ * `display:none` misses theme flips; call this when it may have been
194
+ * shown again (cheap: a handful of getComputedStyle reads, bail on equal).
195
+ */
196
+ recheck(): void;
197
+ /** True while the last resolve/palette read fell back to constants. */
198
+ unresolved(): boolean;
199
+ destroy(): void;
200
+ }
201
+
202
+ /**
203
+ * Hidden probe that resolves CSS color strings against the container's
204
+ * cascade, one channel span per themed color. Each span carries a
205
+ * step-start color transition, so any change to a resolved color (class
206
+ * flip up the tree, media query flip, var redefinition) fires a transition
207
+ * event and triggers `onEnvChange`.
208
+ */
209
+ export function createMapThemeResolver(
210
+ container: HTMLElement,
211
+ onEnvChange: () => void,
212
+ ): MapThemeResolver {
213
+ const doc = container.ownerDocument;
214
+ const win = doc.defaultView!;
215
+ // light-dark() invalid at computed-value time behaves as unset and
216
+ // inherits the page text color, so gate the adaptive defaults
217
+ const adaptive =
218
+ typeof win.CSS?.supports === "function" &&
219
+ win.CSS.supports("color", "light-dark(#fff, #000)");
220
+ const defaults = {
221
+ fill: adaptive
222
+ ? mapThemeDefaults.fill
223
+ : `var(--choropleth-fill, ${LIGHT_FILL})`,
224
+ stroke: adaptive
225
+ ? mapThemeDefaults.stroke
226
+ : `var(--choropleth-stroke, ${LIGHT_STROKE})`,
227
+ highlight: adaptive
228
+ ? mapThemeDefaults.highlight
229
+ : `var(--choropleth-highlight, ${LIGHT_HIGHLIGHT})`,
230
+ // transparent sentinels: these stay off until a visible color resolves
231
+ borders: mapThemeDefaults.borders,
232
+ outline: mapThemeDefaults.outline,
233
+ background: mapThemeDefaults.background,
234
+ };
235
+
236
+ const probe = doc.createElement("div");
237
+ probe.dataset.cfasimMapProbe = "1";
238
+ probe.setAttribute("aria-hidden", "true");
239
+ probe.style.cssText =
240
+ "position:absolute;width:0;height:0;overflow:hidden;" +
241
+ "visibility:hidden;pointer-events:none";
242
+ const channel = () => {
243
+ const el = doc.createElement("span");
244
+ // inline importance so `* { transition: none !important }` resets cannot
245
+ // disable detection; step-start so a synchronous read in the event
246
+ // handler sees the final color, never an interpolated blend
247
+ el.style.setProperty("transition", "color 1ms step-start", "important");
248
+ probe.appendChild(el);
249
+ return el;
250
+ };
251
+ const fillEl = channel();
252
+ const strokeEl = channel();
253
+ const bordersEl = channel();
254
+ const outlineEl = channel();
255
+ const bgEl = channel();
256
+ const highlightEl = channel();
257
+ container.appendChild(probe);
258
+
259
+ let fresh = false;
260
+ let last: MapTheme | undefined;
261
+ let rawBorders = "";
262
+ let rawOutline = "";
263
+ let rawBackground = "";
264
+ // swatch pool for scale palettes: grows to the largest palette seen,
265
+ // never shrinks; swatchCount marks the active prefix
266
+ const swatches: HTMLElement[] = [];
267
+ let swatchCount = 0;
268
+ let paletteFresh = false;
269
+ let lastPaletteInput: readonly string[] = [];
270
+ let rawPalette: string[] = [];
271
+ let cachedPalette: readonly string[] = [];
272
+ let cached: ResolvedMapTheme = {
273
+ fill: LIGHT_FILL,
274
+ stroke: LIGHT_STROKE,
275
+ strokeWidth: undefined,
276
+ borders: LIGHT_STROKE,
277
+ bordersWidth: undefined,
278
+ outline: undefined,
279
+ outlineWidth: undefined,
280
+ background: undefined,
281
+ highlight: LIGHT_HIGHLIGHT,
282
+ };
283
+
284
+ // parse-rejected values leave style.color empty and would silently
285
+ // resolve to the inherited text color; fall back instead. The reset
286
+ // matters: CSSOM keeps the previous value on an invalid assignment,
287
+ // which would defeat the emptiness check on rewrites.
288
+ function write(el: HTMLElement, value: string, fallback: string) {
289
+ // var()-wrapped light-dark() (like the defaults) parses everywhere
290
+ // but is invalid at computed-value time on unsupporting browsers and
291
+ // would inherit the page text color; degrade before writing
292
+ if (!adaptive && value.includes("light-dark(")) value = fallback;
293
+ el.style.color = "";
294
+ el.style.color = withVarFallback(value, fallback);
295
+ if (value && el.style.color === "") el.style.color = fallback;
296
+ }
297
+
298
+ const read = (el: HTMLElement) => win.getComputedStyle(el).color;
299
+
300
+ function widthsFrom(theme: MapTheme | undefined) {
301
+ return {
302
+ strokeWidth: theme?.strokeWidth,
303
+ bordersWidth: theme?.bordersWidth,
304
+ outlineWidth: theme?.outlineWidth,
305
+ };
306
+ }
307
+
308
+ function resolve(theme: MapTheme | undefined): ResolvedMapTheme {
309
+ if (fresh && themeEquals(theme, last)) return cached;
310
+ // Shallow copy: callers may mutate a reactive theme object in place,
311
+ // and storing the reference would make themeEquals compare it against
312
+ // itself forever.
313
+ last = theme && { ...theme };
314
+ // || rather than ??: empty strings mean unset, not "inherit text color"
315
+ write(fillEl, theme?.fill || defaults.fill, defaults.fill);
316
+ write(strokeEl, theme?.stroke || defaults.stroke, defaults.stroke);
317
+ write(bordersEl, theme?.borders || defaults.borders, defaults.borders);
318
+ write(outlineEl, theme?.outline || defaults.outline, defaults.outline);
319
+ write(bgEl, theme?.background || defaults.background, defaults.background);
320
+ write(
321
+ highlightEl,
322
+ theme?.highlight || defaults.highlight,
323
+ defaults.highlight,
324
+ );
325
+ const fillColor = read(fillEl);
326
+ if (fillColor === "") {
327
+ // unrendered or disconnected container: computed styles are
328
+ // unavailable, so paint plain theme colors (or constants) now and
329
+ // retry on the next resolve
330
+ fresh = false;
331
+ const stroke = plain(theme?.stroke) ?? LIGHT_STROKE;
332
+ cached = {
333
+ fill: plain(theme?.fill) ?? LIGHT_FILL,
334
+ stroke,
335
+ borders: plain(theme?.borders) ?? stroke,
336
+ outline: plain(theme?.outline),
337
+ background: plain(theme?.background),
338
+ highlight: plain(theme?.highlight) ?? LIGHT_HIGHLIGHT,
339
+ ...widthsFrom(theme),
340
+ };
341
+ return cached;
342
+ }
343
+ const stroke = read(strokeEl);
344
+ rawBorders = read(bordersEl);
345
+ rawOutline = read(outlineEl);
346
+ rawBackground = read(bgEl);
347
+ cached = {
348
+ fill: fillColor,
349
+ stroke,
350
+ borders: visible(rawBorders) ?? stroke,
351
+ outline: visible(rawOutline),
352
+ background: visible(rawBackground),
353
+ highlight: read(highlightEl),
354
+ ...widthsFrom(theme),
355
+ };
356
+ fresh = true;
357
+ return cached;
358
+ }
359
+
360
+ function resolvePalette(
361
+ colors: readonly string[],
362
+ fallbacks: readonly string[],
363
+ ): readonly string[] {
364
+ if (
365
+ paletteFresh &&
366
+ colors.length === lastPaletteInput.length &&
367
+ colors.every((c, i) => c === lastPaletteInput[i])
368
+ ) {
369
+ return cachedPalette;
370
+ }
371
+ lastPaletteInput = [...colors];
372
+ while (swatches.length < colors.length) {
373
+ const el = channel();
374
+ el.dataset.cfasimMapSwatch = String(swatches.length);
375
+ swatches.push(el);
376
+ }
377
+ swatchCount = colors.length;
378
+ for (let i = 0; i < swatchCount; i++) {
379
+ write(swatches[i]!, colors[i]!, fallbacks[i] ?? "");
380
+ }
381
+ rawPalette = swatches.slice(0, swatchCount).map(read);
382
+ if (rawPalette.includes("")) {
383
+ // unreadable entries (unrendered container): substitute the
384
+ // fallbacks and retry on the next resolve
385
+ paletteFresh = false;
386
+ cachedPalette = rawPalette.map((c, i) => c || fallbacks[i] || "");
387
+ return cachedPalette;
388
+ }
389
+ cachedPalette = [...rawPalette];
390
+ paletteFresh = true;
391
+ return cachedPalette;
392
+ }
393
+
394
+ // transition writes fire this too; the compare-and-bail keeps a
395
+ // prop-driven resolve from scheduling a second repaint
396
+ function recheck() {
397
+ const fillColor = read(fillEl);
398
+ if (fillColor === "") return; // unrendered: reads are meaningless
399
+ let changed = false;
400
+ if (fresh) {
401
+ const stroke = read(strokeEl);
402
+ const borders = read(bordersEl);
403
+ const outline = read(outlineEl);
404
+ const background = read(bgEl);
405
+ const highlight = read(highlightEl);
406
+ if (
407
+ fillColor !== cached.fill ||
408
+ stroke !== cached.stroke ||
409
+ borders !== rawBorders ||
410
+ outline !== rawOutline ||
411
+ background !== rawBackground ||
412
+ highlight !== cached.highlight
413
+ ) {
414
+ rawBorders = borders;
415
+ rawOutline = outline;
416
+ rawBackground = background;
417
+ cached = {
418
+ ...cached,
419
+ fill: fillColor,
420
+ stroke,
421
+ borders: visible(borders) ?? stroke,
422
+ outline: visible(outline),
423
+ background: visible(background),
424
+ highlight,
425
+ };
426
+ changed = true;
427
+ }
428
+ }
429
+ if (paletteFresh && swatchCount) {
430
+ let paletteChanged = false;
431
+ for (let i = 0; i < swatchCount; i++) {
432
+ const c = read(swatches[i]!);
433
+ if (c !== rawPalette[i]) {
434
+ rawPalette[i] = c;
435
+ paletteChanged = true;
436
+ }
437
+ }
438
+ if (paletteChanged) {
439
+ // new identity signals downstream scale memos to rebuild
440
+ cachedPalette = [...rawPalette];
441
+ changed = true;
442
+ }
443
+ }
444
+ if (changed) onEnvChange();
445
+ }
446
+
447
+ // detection events are internal noise; keep them out of app listeners
448
+ // on the container and its ancestors
449
+ const onTransition = (e: Event) => {
450
+ e.stopPropagation();
451
+ recheck();
452
+ };
453
+ probe.addEventListener("transitionstart", onTransition);
454
+ probe.addEventListener("transitionend", onTransition);
455
+ // covers scheme flips while the probe has no box (hidden container)
456
+ const mql = win.matchMedia
457
+ ? win.matchMedia("(prefers-color-scheme: dark)")
458
+ : null;
459
+ mql?.addEventListener("change", recheck);
460
+
461
+ return {
462
+ resolve,
463
+ resolvePalette,
464
+ invalidate() {
465
+ fresh = false;
466
+ paletteFresh = false;
467
+ },
468
+ recheck,
469
+ unresolved() {
470
+ return !fresh;
471
+ },
472
+ destroy() {
473
+ probe.removeEventListener("transitionstart", onTransition);
474
+ probe.removeEventListener("transitionend", onTransition);
475
+ mql?.removeEventListener("change", recheck);
476
+ probe.remove();
477
+ },
478
+ };
479
+ }
480
+
481
+ /** Probe-free resolution for non-DOM environments and pre-mount reads. */
482
+ function resolveWithoutDom(theme: MapTheme | undefined): ResolvedMapTheme {
483
+ const stroke = plain(theme?.stroke) ?? LIGHT_STROKE;
484
+ return {
485
+ fill: plain(theme?.fill) ?? LIGHT_FILL,
486
+ stroke,
487
+ strokeWidth: theme?.strokeWidth,
488
+ borders: plain(theme?.borders) ?? stroke,
489
+ bordersWidth: theme?.bordersWidth,
490
+ outline: plain(theme?.outline),
491
+ outlineWidth: theme?.outlineWidth,
492
+ background: plain(theme?.background),
493
+ highlight: plain(theme?.highlight) ?? LIGHT_HIGHLIGHT,
494
+ };
495
+ }
496
+
497
+ /**
498
+ * Vue wrapper around {@link createMapThemeResolver}: creates the probe in
499
+ * `container` on mount, exposes the resolved theme as a computed, and bumps
500
+ * an internal version (re-running dependents) whenever the environment
501
+ * changes a resolved color. `resolvePalette` is reactive the same way; call
502
+ * it from a computed so scale colors re-resolve on theme flips.
503
+ */
504
+ export function useMapTheme(
505
+ container: Ref<HTMLElement | null>,
506
+ theme: () => MapTheme | undefined,
507
+ ) {
508
+ const version = ref(0);
509
+ let resolver: MapThemeResolver | null = null;
510
+
511
+ onMounted(() => {
512
+ if (!container.value) return;
513
+ resolver = createMapThemeResolver(container.value, () => {
514
+ version.value++;
515
+ });
516
+ version.value++;
517
+ });
518
+ onUnmounted(() => {
519
+ resolver?.destroy();
520
+ resolver = null;
521
+ });
522
+
523
+ const resolved = computed<ResolvedMapTheme>(() => {
524
+ void version.value;
525
+ return resolver ? resolver.resolve(theme()) : resolveWithoutDom(theme());
526
+ });
527
+
528
+ function resolvePalette(
529
+ colors: readonly string[],
530
+ fallbacks: readonly string[],
531
+ ): readonly string[] {
532
+ void version.value;
533
+ return resolver ? resolver.resolvePalette(colors, fallbacks) : [...colors];
534
+ }
535
+
536
+ /**
537
+ * Re-sync with the environment when layout (re)appears. Covers two gaps
538
+ * transition-based detection can't see: a resolve that fell back to
539
+ * constants because the container had no layout (retry it), and a theme
540
+ * flip that happened while the map sat in a `display:none` subtree,
541
+ * where no transition ever fires (recheck reads vs cache and notifies
542
+ * only on change).
543
+ */
544
+ function ensureResolved() {
545
+ if (!resolver) return;
546
+ if (resolver.unresolved()) version.value++;
547
+ else resolver.recheck();
548
+ }
549
+
550
+ return { resolved, resolvePalette, ensureResolved };
551
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Linear and log scale helpers shared by chart components. The chart
3
+ * computes a `[min, max]` data extent then maps values to pixels via
4
+ * `scaleFraction`; log mode clamps `min` to a positive floor so we
5
+ * never take `log10(0)` or `log10(-x)`.
6
+ */
7
+
8
+ export type ScaleType = "linear" | "log";
9
+
10
+ /** Default floor used when a log-scale extent contains no positive data. */
11
+ export const LOG_FLOOR = 1;
12
+
13
+ /**
14
+ * Project a value onto a [0, 1] fraction of the axis range. The caller
15
+ * multiplies by the pixel range and adds the axis origin. On log
16
+ * scales, non-positive values collapse to the visible minimum so they
17
+ * sit on the axis floor instead of producing -Infinity.
18
+ */
19
+ export function scaleFraction(
20
+ v: number,
21
+ min: number,
22
+ max: number,
23
+ type: ScaleType,
24
+ ): number {
25
+ if (type === "log") {
26
+ const lmin = Math.log10(min);
27
+ const lmax = Math.log10(max);
28
+ const range = lmax - lmin || 1;
29
+ const safe = v > 0 ? v : min;
30
+ return (Math.log10(safe) - lmin) / range;
31
+ }
32
+ const range = max - min || 1;
33
+ return (v - min) / range;
34
+ }
35
+
36
+ /**
37
+ * Clamp the lower bound of a data extent so it's safe for log scale.
38
+ * For linear scales the inputs are returned unchanged. For log scales,
39
+ * `min` is raised to the smallest positive value in the data (or
40
+ * `LOG_FLOOR` when no positive values exist), and `max` is also
41
+ * floored to that value so a degenerate axis still renders.
42
+ */
43
+ export function clampExtentForScale(
44
+ min: number,
45
+ max: number,
46
+ type: ScaleType,
47
+ smallestPositive: number,
48
+ ): { min: number; max: number } {
49
+ if (type !== "log") return { min, max };
50
+ const floor =
51
+ smallestPositive > 0 && isFinite(smallestPositive)
52
+ ? smallestPositive
53
+ : LOG_FLOOR;
54
+ const lo = min > 0 ? min : floor;
55
+ const hi = max > 0 ? Math.max(max, lo) : lo;
56
+ return { min: lo, max: hi };
57
+ }
58
+
59
+ /**
60
+ * Generate tick values for a log axis. By default returns powers of 10
61
+ * inside `[min, max]`. Pass `ticks` as an explicit array to override;
62
+ * numeric `ticks` (linear interval) is ignored on a log axis since it
63
+ * would produce a swarm of densely-packed labels — use an array for
64
+ * non-default tick placement.
65
+ */
66
+ export function computeLogTickValues(opts: {
67
+ min: number;
68
+ max: number;
69
+ ticks?: number | number[];
70
+ }): number[] {
71
+ const { min, max, ticks } = opts;
72
+ if (!(min > 0) || !(max > 0) || min === max) return [];
73
+
74
+ if (Array.isArray(ticks)) {
75
+ return ticks.filter((v) => v > 0 && v >= min && v <= max);
76
+ }
77
+
78
+ const lo = Math.floor(Math.log10(min));
79
+ const hi = Math.ceil(Math.log10(max));
80
+ const out: number[] = [];
81
+ for (let e = lo; e <= hi; e++) {
82
+ const v = Math.pow(10, e);
83
+ if (v >= min && v <= max) out.push(v);
84
+ }
85
+ return out;
86
+ }
@@ -0,0 +1,68 @@
1
+ import type { ChartData } from "./axes.js";
2
+
3
+ export interface CsvSeries {
4
+ data: ChartData;
5
+ /** Optional parallel x-values; when all series share the same x, an `x` column is used. */
6
+ x?: ChartData;
7
+ }
8
+
9
+ /**
10
+ * Build a CSV string for one or more numeric series. When every series
11
+ * shares the same `x` reference an `x` column is emitted; otherwise
12
+ * rows are indexed.
13
+ */
14
+ export function seriesToCsv(series: CsvSeries[]): string {
15
+ if (series.length === 0) return "";
16
+ let maxLen = 0;
17
+ for (const s of series) if (s.data.length > maxLen) maxLen = s.data.length;
18
+ const sharedX = series.every((s) => s.x === series[0].x)
19
+ ? series[0].x
20
+ : undefined;
21
+ const xHeader = sharedX ? "x" : "index";
22
+ const headers =
23
+ series.length === 1
24
+ ? [xHeader, "value"]
25
+ : [xHeader, ...series.map((_, i) => `series_${i}`)];
26
+ const rows = [headers.join(",")];
27
+ for (let r = 0; r < maxLen; r++) {
28
+ const xCell = sharedX ? String(sharedX[r]) : r.toString();
29
+ const cells = [xCell];
30
+ for (const s of series) {
31
+ cells.push(r < s.data.length ? String(s.data[r]) : "");
32
+ }
33
+ rows.push(cells.join(","));
34
+ }
35
+ return rows.join("\n");
36
+ }
37
+
38
+ /**
39
+ * Build a CSV for a categorical chart (BarChart vertical / horizontal):
40
+ * one row per category, one column per series.
41
+ */
42
+ export function categoricalToCsv(
43
+ categories: readonly string[],
44
+ series: { label?: string; data: ChartData }[],
45
+ categoryHeader = "category",
46
+ ): string {
47
+ if (series.length === 0 || categories.length === 0) return "";
48
+ const headers =
49
+ series.length === 1
50
+ ? [categoryHeader, series[0].label || "value"]
51
+ : [categoryHeader, ...series.map((s, i) => s.label || `series_${i}`)];
52
+ const rows = [headers.join(",")];
53
+ for (let r = 0; r < categories.length; r++) {
54
+ const cells = [escapeCsvField(categories[r])];
55
+ for (const s of series) {
56
+ cells.push(r < s.data.length ? String(s.data[r]) : "");
57
+ }
58
+ rows.push(cells.join(","));
59
+ }
60
+ return rows.join("\n");
61
+ }
62
+
63
+ export function escapeCsvField(value: string): string {
64
+ if (value.includes(",") || value.includes('"') || value.includes("\n")) {
65
+ return `"${value.replace(/"/g, '""')}"`;
66
+ }
67
+ return value;
68
+ }