@cfasim-ui/charts 0.7.8 → 0.8.1
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/dist/BarChart/BarChart.d.ts +9 -26
- package/dist/ChartMenu/ChartMenu.d.ts +3 -2
- package/dist/ChartTooltip/ChartTooltip.d.ts +9 -12
- package/dist/ChoroplethMap/ChoroplethMap.d.ts +42 -190
- package/dist/ChoroplethMap/ChoroplethTooltip.d.ts +20 -27
- package/dist/ChoroplethMap/canvasLayer.d.ts +23 -6
- package/dist/ChoroplethMap/cityLayout.d.ts +3 -2
- package/dist/ChoroplethMap/mixedGeo.d.ts +74 -0
- package/dist/ChoroplethMap/mixedGeo.test.d.ts +1 -0
- package/dist/DataTable/DataTable.d.ts +3 -2
- package/dist/LineChart/LineChart.d.ts +9 -26
- package/dist/_shared/ChartAnnotations.d.ts +3 -2
- package/dist/_shared/ChartZoomControls.d.ts +3 -2
- package/dist/_shared/index.d.ts +2 -1
- package/dist/_shared/mapTheme.d.ts +159 -0
- package/dist/_shared/mapTheme.test.d.ts +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1821 -1462
- package/docs/BarChart.md +776 -0
- package/docs/ChoroplethMap.md +1394 -0
- package/docs/DataTable.md +386 -0
- package/docs/LineChart.md +1267 -0
- package/docs/index.json +56 -0
- package/package.json +25 -21
- package/src/BarChart/BarChart.md +743 -0
- package/src/BarChart/BarChart.vue +1901 -0
- package/src/ChartMenu/ChartMenu.vue +220 -0
- package/src/ChartMenu/download.ts +120 -0
- package/src/ChartTooltip/ChartTooltip.vue +97 -0
- package/src/ChoroplethMap/ChoroplethMap.md +1354 -0
- package/src/ChoroplethMap/ChoroplethMap.vue +3778 -0
- package/src/ChoroplethMap/ChoroplethTooltip.vue +103 -0
- package/src/ChoroplethMap/canvasLayer.ts +373 -0
- package/src/ChoroplethMap/cityLayout.ts +262 -0
- package/src/ChoroplethMap/hsaMapping.ts +4116 -0
- package/src/ChoroplethMap/mixedGeo.ts +201 -0
- package/src/DataTable/DataTable.md +372 -0
- package/src/DataTable/DataTable.vue +406 -0
- package/src/LineChart/LineChart.md +1225 -0
- package/src/LineChart/LineChart.vue +1555 -0
- package/src/_shared/ChartAnnotations.vue +420 -0
- package/src/_shared/ChartZoomControls.vue +138 -0
- package/src/_shared/annotations.ts +106 -0
- package/src/_shared/axes.ts +69 -0
- package/src/_shared/chartProps.ts +201 -0
- package/src/_shared/computeTicks.ts +42 -0
- package/src/_shared/contrast.ts +100 -0
- package/src/_shared/dateAxis.ts +501 -0
- package/src/_shared/index.ts +78 -0
- package/src/_shared/mapTheme.ts +551 -0
- package/src/_shared/scale.ts +86 -0
- package/src/_shared/seriesCsv.ts +68 -0
- package/src/_shared/touch.ts +8 -0
- package/src/_shared/useChartFoundation.ts +175 -0
- package/src/_shared/useChartFullscreen.ts +254 -0
- package/src/_shared/useChartMenu.ts +111 -0
- package/src/_shared/useChartPadding.ts +235 -0
- package/src/_shared/useChartSize.ts +58 -0
- package/src/_shared/useChartTooltip.ts +205 -0
- package/src/env.d.ts +4 -0
- package/src/hsa-mapping.ts +1 -0
- package/src/index.ts +41 -0
- package/src/tooltip-position.ts +55 -0
- package/src/us-cities/data.ts +1371 -0
- package/src/us-cities/index.ts +122 -0
- 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
|
+
}
|