@cfasim-ui/docs 0.3.11
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/LICENSE +201 -0
- package/charts/ChartMenu/ChartMenu.vue +140 -0
- package/charts/ChartMenu/download.ts +44 -0
- package/charts/ChartTooltip/ChartTooltip.vue +97 -0
- package/charts/ChoroplethMap/ChoroplethMap.md +398 -0
- package/charts/ChoroplethMap/ChoroplethMap.vue +777 -0
- package/charts/ChoroplethMap/hsaMapping.ts +4116 -0
- package/charts/DataTable/DataTable.md +143 -0
- package/charts/DataTable/DataTable.vue +277 -0
- package/charts/LineChart/LineChart.md +472 -0
- package/charts/LineChart/LineChart.vue +1216 -0
- package/charts/index.ts +23 -0
- package/charts/tooltip-position.ts +49 -0
- package/components/Box/Box.md +49 -0
- package/components/Box/Box.vue +52 -0
- package/components/Button/Button.md +67 -0
- package/components/Button/Button.vue +81 -0
- package/components/Expander/Expander.md +34 -0
- package/components/Expander/Expander.vue +95 -0
- package/components/Hint/Hint.md +29 -0
- package/components/Hint/Hint.vue +83 -0
- package/components/Icon/Icon.md +67 -0
- package/components/Icon/Icon.vue +112 -0
- package/components/LightDarkToggle/LightDarkToggle.vue +49 -0
- package/components/NumberInput/NumberInput.md +305 -0
- package/components/NumberInput/NumberInput.vue +531 -0
- package/components/SelectBox/SelectBox.md +110 -0
- package/components/SelectBox/SelectBox.vue +195 -0
- package/components/SidebarLayout/SidebarLayout.md +104 -0
- package/components/SidebarLayout/SidebarLayout.vue +466 -0
- package/components/Spinner/Spinner.md +51 -0
- package/components/Spinner/Spinner.vue +55 -0
- package/components/TextInput/TextInput.md +82 -0
- package/components/TextInput/TextInput.vue +94 -0
- package/components/Toggle/Toggle.md +81 -0
- package/components/Toggle/Toggle.vue +81 -0
- package/components/index.ts +15 -0
- package/index.json +121 -0
- package/package.json +24 -0
- package/pyodide/index.ts +7 -0
- package/pyodide/pyodide.worker.ts +233 -0
- package/pyodide/pyodideWorkerApi.ts +102 -0
- package/pyodide/useModel.ts +86 -0
- package/pyodide/vitePlugin.js +51 -0
- package/shared/ModelOutput.ts +88 -0
- package/shared/csv.ts +22 -0
- package/shared/index.ts +24 -0
- package/shared/transferUtils.ts +126 -0
- package/shared/useUrlParams.ts +296 -0
- package/theme/all.js +5 -0
- package/theme/base.css +176 -0
- package/theme/cfasim.css +3 -0
- package/theme/theme.css +113 -0
- package/theme/themes/cdc.css +22 -0
- package/theme/utilities.css +518 -0
- package/wasm/index.ts +2 -0
- package/wasm/useModel.ts +53 -0
- package/wasm/vitePlugin.js +35 -0
- package/wasm/wasm.worker.ts +74 -0
- package/wasm/wasmWorkerApi.ts +38 -0
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
computed,
|
|
4
|
+
ref,
|
|
5
|
+
watch,
|
|
6
|
+
onMounted,
|
|
7
|
+
onUnmounted,
|
|
8
|
+
useId,
|
|
9
|
+
toRaw,
|
|
10
|
+
} from "vue";
|
|
11
|
+
import { geoPath, geoAlbersUsa } from "d3-geo";
|
|
12
|
+
import { zoom as d3Zoom } from "d3-zoom";
|
|
13
|
+
import { select } from "d3-selection";
|
|
14
|
+
import { feature, mesh, merge } from "topojson-client";
|
|
15
|
+
import type { Topology, GeometryCollection } from "topojson-specification";
|
|
16
|
+
import { fipsToHsa, hsaNames } from "./hsaMapping.js";
|
|
17
|
+
import ChartMenu from "../ChartMenu/ChartMenu.vue";
|
|
18
|
+
import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
|
|
19
|
+
import { saveSvg, savePng } from "../ChartMenu/download.js";
|
|
20
|
+
import { placeTooltip } from "../tooltip-position.js";
|
|
21
|
+
|
|
22
|
+
export type GeoType = "states" | "counties" | "hsas";
|
|
23
|
+
|
|
24
|
+
export interface StateData {
|
|
25
|
+
/** FIPS code (e.g. "06" for California, "04015" for a county) or name */
|
|
26
|
+
id: string;
|
|
27
|
+
value: number | string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ChoroplethColorScale {
|
|
31
|
+
/** Minimum color (CSS color string). Default: "#e5f0fa" */
|
|
32
|
+
min?: string;
|
|
33
|
+
/** Maximum color (CSS color string). Default: "#08519c" */
|
|
34
|
+
max?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ThresholdStop {
|
|
38
|
+
/** Lower bound (inclusive). Values at or above this threshold get this color. */
|
|
39
|
+
min: number;
|
|
40
|
+
color: string;
|
|
41
|
+
/** Optional label for the legend (defaults to the min value) */
|
|
42
|
+
label?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CategoricalStop {
|
|
46
|
+
/** The categorical value to match */
|
|
47
|
+
value: string;
|
|
48
|
+
/** CSS color string */
|
|
49
|
+
color: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const props = withDefaults(
|
|
53
|
+
defineProps<{
|
|
54
|
+
/** TopoJSON topology object (e.g. from us-atlas/states-10m.json or us-atlas/counties-10m.json).
|
|
55
|
+
* Must contain a "states" object for geoType="states", or both "states" and "counties" objects
|
|
56
|
+
* for geoType="counties" or geoType="hsas". */
|
|
57
|
+
topology: Topology;
|
|
58
|
+
data?: StateData[];
|
|
59
|
+
/** Geographic type: "states" (default), "counties", or "hsas" (Health Service Areas) */
|
|
60
|
+
geoType?: GeoType;
|
|
61
|
+
width?: number;
|
|
62
|
+
height?: number;
|
|
63
|
+
colorScale?: ChoroplethColorScale | ThresholdStop[] | CategoricalStop[];
|
|
64
|
+
title?: string;
|
|
65
|
+
noDataColor?: string;
|
|
66
|
+
strokeColor?: string;
|
|
67
|
+
strokeWidth?: number;
|
|
68
|
+
menu?: boolean | string;
|
|
69
|
+
/** Show legend. Default: true */
|
|
70
|
+
legend?: boolean;
|
|
71
|
+
/** Title displayed next to the legend */
|
|
72
|
+
legendTitle?: string;
|
|
73
|
+
/** Enable mouse-wheel zooming. Default: false */
|
|
74
|
+
zoom?: boolean;
|
|
75
|
+
/** Enable click-and-drag panning. Default: false */
|
|
76
|
+
pan?: boolean;
|
|
77
|
+
/** Tooltip activation mode */
|
|
78
|
+
tooltipTrigger?: "hover" | "click";
|
|
79
|
+
/** Custom tooltip formatter. Receives { id, name, value } and returns HTML string. */
|
|
80
|
+
tooltipFormat?: (data: {
|
|
81
|
+
id: string;
|
|
82
|
+
name: string;
|
|
83
|
+
value?: number | string;
|
|
84
|
+
}) => string;
|
|
85
|
+
/**
|
|
86
|
+
* Boundary for tooltip flip/clamp. `"none"` always places to the right of
|
|
87
|
+
* the pointer with no clamping. `"chart"` (default) uses the map
|
|
88
|
+
* container's bounding box. `"window"` uses the viewport.
|
|
89
|
+
*/
|
|
90
|
+
tooltipClamp?: "none" | "chart" | "window";
|
|
91
|
+
}>(),
|
|
92
|
+
{
|
|
93
|
+
geoType: "states",
|
|
94
|
+
noDataColor: "#ddd",
|
|
95
|
+
strokeColor: "#fff",
|
|
96
|
+
strokeWidth: 0.5,
|
|
97
|
+
menu: true,
|
|
98
|
+
legend: true,
|
|
99
|
+
zoom: false,
|
|
100
|
+
pan: false,
|
|
101
|
+
tooltipClamp: "chart",
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const emit = defineEmits<{
|
|
106
|
+
(
|
|
107
|
+
e: "stateClick",
|
|
108
|
+
state: { id: string; name: string; value?: number | string },
|
|
109
|
+
): void;
|
|
110
|
+
(
|
|
111
|
+
e: "stateHover",
|
|
112
|
+
state: { id: string; name: string; value?: number | string } | null,
|
|
113
|
+
): void;
|
|
114
|
+
}>();
|
|
115
|
+
|
|
116
|
+
const uid = useId();
|
|
117
|
+
const gradientId = `choropleth-gradient-${uid}`;
|
|
118
|
+
const containerRef = ref<HTMLElement | null>(null);
|
|
119
|
+
const svgRef = ref<SVGSVGElement | null>(null);
|
|
120
|
+
const mapGroupRef = ref<SVGGElement | null>(null);
|
|
121
|
+
const measuredWidth = ref(0);
|
|
122
|
+
let hoveredEl: SVGPathElement | null = null;
|
|
123
|
+
let tooltipEl: HTMLDivElement | null = null;
|
|
124
|
+
let isZooming = false;
|
|
125
|
+
// TODO: map hover/tooltip causes performance issues on mobile (SVG stroke-width
|
|
126
|
+
// changes + compositing layers degrade zoom/pan). Disabled on touch devices.
|
|
127
|
+
const isTouchDevice = typeof window !== "undefined" && "ontouchstart" in window;
|
|
128
|
+
let observer: ResizeObserver | null = null;
|
|
129
|
+
let zoomBehavior: ReturnType<typeof d3Zoom<SVGSVGElement, unknown>> | null =
|
|
130
|
+
null;
|
|
131
|
+
|
|
132
|
+
function setupInteraction() {
|
|
133
|
+
if (isTouchDevice) return;
|
|
134
|
+
const g = mapGroupRef.value;
|
|
135
|
+
if (!g) return;
|
|
136
|
+
g.addEventListener("click", onDelegatedEvent);
|
|
137
|
+
g.addEventListener("mouseover", onDelegatedEvent);
|
|
138
|
+
g.addEventListener("mousemove", onDelegatedMouseMove);
|
|
139
|
+
g.addEventListener("mouseout", onDelegatedMouseOut);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function teardownInteraction() {
|
|
143
|
+
const g = mapGroupRef.value;
|
|
144
|
+
if (!g) return;
|
|
145
|
+
g.removeEventListener("click", onDelegatedEvent);
|
|
146
|
+
g.removeEventListener("mouseover", onDelegatedEvent);
|
|
147
|
+
g.removeEventListener("mousemove", onDelegatedMouseMove);
|
|
148
|
+
g.removeEventListener("mouseout", onDelegatedMouseOut);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
onMounted(() => {
|
|
152
|
+
if (containerRef.value) {
|
|
153
|
+
measuredWidth.value = containerRef.value.clientWidth;
|
|
154
|
+
observer = new ResizeObserver((entries) => {
|
|
155
|
+
const entry = entries[0];
|
|
156
|
+
if (entry) measuredWidth.value = entry.contentRect.width;
|
|
157
|
+
});
|
|
158
|
+
observer.observe(containerRef.value);
|
|
159
|
+
}
|
|
160
|
+
setupZoom();
|
|
161
|
+
setupInteraction();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
onUnmounted(() => {
|
|
165
|
+
observer?.disconnect();
|
|
166
|
+
teardownZoom();
|
|
167
|
+
teardownInteraction();
|
|
168
|
+
hideTooltip();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
function setupZoom() {
|
|
172
|
+
if (!svgRef.value || !mapGroupRef.value) return;
|
|
173
|
+
if (!props.zoom && !props.pan) return;
|
|
174
|
+
|
|
175
|
+
const svg = select(svgRef.value);
|
|
176
|
+
zoomBehavior = d3Zoom<SVGSVGElement, unknown>()
|
|
177
|
+
.scaleExtent(props.zoom ? [1, 12] : [1, 1])
|
|
178
|
+
.on("start", () => {
|
|
179
|
+
isZooming = true;
|
|
180
|
+
clearHover();
|
|
181
|
+
})
|
|
182
|
+
.on("zoom", (event) => {
|
|
183
|
+
if (mapGroupRef.value) {
|
|
184
|
+
mapGroupRef.value.setAttribute("transform", event.transform);
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
.on("end", () => {
|
|
188
|
+
isZooming = false;
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
if (!props.pan) {
|
|
192
|
+
zoomBehavior.filter(
|
|
193
|
+
(event) => event.type === "wheel" || event.type === "dblclick",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
svg.call(zoomBehavior);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function teardownZoom() {
|
|
201
|
+
if (svgRef.value && zoomBehavior) {
|
|
202
|
+
select(svgRef.value).on(".zoom", null);
|
|
203
|
+
zoomBehavior = null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
watch(
|
|
208
|
+
() => [props.zoom, props.pan],
|
|
209
|
+
() => {
|
|
210
|
+
teardownZoom();
|
|
211
|
+
teardownInteraction();
|
|
212
|
+
setupZoom();
|
|
213
|
+
setupInteraction();
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const width = computed(() => props.width ?? (measuredWidth.value || 600));
|
|
218
|
+
const aspectRatio = computed(() => {
|
|
219
|
+
if (props.width && props.height) return props.height / props.width;
|
|
220
|
+
return 0.625;
|
|
221
|
+
});
|
|
222
|
+
const height = computed(() => width.value * aspectRatio.value);
|
|
223
|
+
|
|
224
|
+
type NamedGeometry = GeometryCollection<{ name: string }>;
|
|
225
|
+
type StatesTopo = Topology<{ states: NamedGeometry }>;
|
|
226
|
+
type CountiesTopo = Topology<{
|
|
227
|
+
counties: NamedGeometry;
|
|
228
|
+
states: NamedGeometry;
|
|
229
|
+
}>;
|
|
230
|
+
|
|
231
|
+
const hsaFeaturesGeo = computed(() => {
|
|
232
|
+
const topo = toRaw(props.topology) as unknown as CountiesTopo;
|
|
233
|
+
const countyGeometries = topo.objects.counties.geometries;
|
|
234
|
+
const groups = new Map<string, typeof countyGeometries>();
|
|
235
|
+
|
|
236
|
+
for (const geom of countyGeometries) {
|
|
237
|
+
const fips = String(geom.id).padStart(5, "0");
|
|
238
|
+
const hsaCode = fipsToHsa[fips];
|
|
239
|
+
if (!hsaCode) continue;
|
|
240
|
+
if (!groups.has(hsaCode)) groups.set(hsaCode, []);
|
|
241
|
+
groups.get(hsaCode)!.push(geom);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const features: GeoJSON.Feature[] = [];
|
|
245
|
+
for (const [hsaCode, geoms] of groups) {
|
|
246
|
+
features.push({
|
|
247
|
+
type: "Feature",
|
|
248
|
+
id: hsaCode,
|
|
249
|
+
properties: { name: hsaNames[hsaCode] ?? hsaCode },
|
|
250
|
+
geometry: merge(topo as unknown as Topology, geoms as any),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return { type: "FeatureCollection" as const, features };
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const featuresGeo = computed(() => {
|
|
258
|
+
if (props.geoType === "hsas") return hsaFeaturesGeo.value;
|
|
259
|
+
if (props.geoType === "counties") {
|
|
260
|
+
const topo = toRaw(props.topology) as unknown as CountiesTopo;
|
|
261
|
+
return feature(topo, topo.objects.counties);
|
|
262
|
+
}
|
|
263
|
+
const topo = toRaw(props.topology) as unknown as StatesTopo;
|
|
264
|
+
return feature(topo, topo.objects.states);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const stateBordersPath = computed(() => {
|
|
268
|
+
if (props.geoType !== "counties" && props.geoType !== "hsas") return null;
|
|
269
|
+
const topo = toRaw(props.topology) as unknown as CountiesTopo;
|
|
270
|
+
return mesh(topo, topo.objects.states, (a, b) => a !== b);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const projection = computed(() =>
|
|
274
|
+
geoAlbersUsa().fitExtent(
|
|
275
|
+
[
|
|
276
|
+
[0, topOffset.value],
|
|
277
|
+
[width.value, height.value + topOffset.value],
|
|
278
|
+
],
|
|
279
|
+
featuresGeo.value,
|
|
280
|
+
),
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const pathGenerator = computed(() => geoPath(projection.value));
|
|
284
|
+
|
|
285
|
+
const effectiveStrokeWidth = computed(() =>
|
|
286
|
+
props.geoType === "counties" || props.geoType === "hsas"
|
|
287
|
+
? props.strokeWidth * 0.5
|
|
288
|
+
: props.strokeWidth,
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
const dataMap = computed(() => {
|
|
292
|
+
const map = new Map<string, number | string>();
|
|
293
|
+
if (!props.data) return map;
|
|
294
|
+
for (const d of props.data) {
|
|
295
|
+
map.set(d.id, d.value);
|
|
296
|
+
const geo = featuresGeo.value.features.find(
|
|
297
|
+
(f) => f.properties?.name === d.id,
|
|
298
|
+
);
|
|
299
|
+
if (geo?.id != null) map.set(String(geo.id), d.value);
|
|
300
|
+
}
|
|
301
|
+
return map;
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const extent = computed(() => {
|
|
305
|
+
if (!props.data || props.data.length === 0) return { min: 0, max: 1 };
|
|
306
|
+
let min = Infinity;
|
|
307
|
+
let max = -Infinity;
|
|
308
|
+
for (const d of props.data) {
|
|
309
|
+
if (typeof d.value === "number") {
|
|
310
|
+
if (d.value < min) min = d.value;
|
|
311
|
+
if (d.value > max) max = d.value;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (!isFinite(min)) return { min: 0, max: 1 };
|
|
315
|
+
if (min === max) return { min, max: min + 1 };
|
|
316
|
+
return { min, max };
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const isCategorical = computed(
|
|
320
|
+
() =>
|
|
321
|
+
Array.isArray(props.colorScale) &&
|
|
322
|
+
props.colorScale.length > 0 &&
|
|
323
|
+
"value" in props.colorScale[0],
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
const isThreshold = computed(
|
|
327
|
+
() => Array.isArray(props.colorScale) && !isCategorical.value,
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const minColor = computed(() =>
|
|
331
|
+
!isThreshold.value
|
|
332
|
+
? ((props.colorScale as ChoroplethColorScale | undefined)?.min ?? "#e5f0fa")
|
|
333
|
+
: "",
|
|
334
|
+
);
|
|
335
|
+
const maxColor = computed(() =>
|
|
336
|
+
!isThreshold.value
|
|
337
|
+
? ((props.colorScale as ChoroplethColorScale | undefined)?.max ?? "#08519c")
|
|
338
|
+
: "",
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
function parseHex(hex: string): [number, number, number] {
|
|
342
|
+
const h = hex.replace("#", "");
|
|
343
|
+
return [
|
|
344
|
+
parseInt(h.slice(0, 2), 16),
|
|
345
|
+
parseInt(h.slice(2, 4), 16),
|
|
346
|
+
parseInt(h.slice(4, 6), 16),
|
|
347
|
+
];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function interpolateColor(t: number): string {
|
|
351
|
+
const [r1, g1, b1] = parseHex(minColor.value);
|
|
352
|
+
const [r2, g2, b2] = parseHex(maxColor.value);
|
|
353
|
+
const r = Math.round(r1 + (r2 - r1) * t);
|
|
354
|
+
const g = Math.round(g1 + (g2 - g1) * t);
|
|
355
|
+
const b = Math.round(b1 + (b2 - b1) * t);
|
|
356
|
+
return `rgb(${r},${g},${b})`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function thresholdColor(value: number): string {
|
|
360
|
+
const stops = (props.colorScale as ThresholdStop[])
|
|
361
|
+
.slice()
|
|
362
|
+
.sort((a, b) => b.min - a.min);
|
|
363
|
+
for (const stop of stops) {
|
|
364
|
+
if (value >= stop.min) return stop.color;
|
|
365
|
+
}
|
|
366
|
+
return props.noDataColor!;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function categoricalColor(value: string | number): string {
|
|
370
|
+
const stops = props.colorScale as CategoricalStop[];
|
|
371
|
+
const match = stops.find((s) => s.value === String(value));
|
|
372
|
+
return match ? match.color : props.noDataColor!;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function stateColor(id: string | number): string {
|
|
376
|
+
const value = dataMap.value.get(String(id));
|
|
377
|
+
if (value == null) return props.noDataColor!;
|
|
378
|
+
if (isCategorical.value) return categoricalColor(value);
|
|
379
|
+
if (isThreshold.value) return thresholdColor(value as number);
|
|
380
|
+
const { min, max } = extent.value;
|
|
381
|
+
const t = ((value as number) - min) / (max - min);
|
|
382
|
+
return interpolateColor(t);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function stateName(feat: (typeof featuresGeo.value.features)[number]): string {
|
|
386
|
+
return feat.properties?.name ?? String(feat.id);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function stateValue(
|
|
390
|
+
feat: (typeof featuresGeo.value.features)[number],
|
|
391
|
+
): number | string | undefined {
|
|
392
|
+
return dataMap.value.get(String(feat.id));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const featMap = computed(() => {
|
|
396
|
+
const m = new Map<string, (typeof featuresGeo.value.features)[number]>();
|
|
397
|
+
for (const f of featuresGeo.value.features) m.set(String(f.id), f);
|
|
398
|
+
return m;
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
function resolveTarget(el: Element | null): {
|
|
402
|
+
pathEl: SVGPathElement;
|
|
403
|
+
feat: (typeof featuresGeo.value.features)[number];
|
|
404
|
+
} | null {
|
|
405
|
+
let target = el;
|
|
406
|
+
while (target && !(target as HTMLElement).dataset?.featId) {
|
|
407
|
+
target = target.parentElement;
|
|
408
|
+
}
|
|
409
|
+
if (!target) return null;
|
|
410
|
+
const feat = featMap.value.get((target as HTMLElement).dataset.featId!);
|
|
411
|
+
if (!feat) return null;
|
|
412
|
+
return { pathEl: target as SVGPathElement, feat };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function showTooltip(
|
|
416
|
+
feat: (typeof featuresGeo.value.features)[number],
|
|
417
|
+
clientX: number,
|
|
418
|
+
clientY: number,
|
|
419
|
+
) {
|
|
420
|
+
if (!tooltipEl) {
|
|
421
|
+
tooltipEl = document.createElement("div");
|
|
422
|
+
tooltipEl.className = "chart-tooltip-content";
|
|
423
|
+
tooltipEl.style.position = "fixed";
|
|
424
|
+
tooltipEl.style.transform = "translateY(-50%)";
|
|
425
|
+
document.body.appendChild(tooltipEl);
|
|
426
|
+
}
|
|
427
|
+
const name = stateName(feat);
|
|
428
|
+
const value = stateValue(feat);
|
|
429
|
+
const data = { id: String(feat.id), name, value };
|
|
430
|
+
if (props.tooltipFormat) {
|
|
431
|
+
tooltipEl.innerHTML = props.tooltipFormat(data);
|
|
432
|
+
} else {
|
|
433
|
+
tooltipEl.textContent = value != null ? `${name}: ${value}` : name;
|
|
434
|
+
}
|
|
435
|
+
const chartRect = containerRef.value?.getBoundingClientRect();
|
|
436
|
+
const { left, top } = placeTooltip(
|
|
437
|
+
clientX,
|
|
438
|
+
clientY,
|
|
439
|
+
tooltipEl.offsetWidth,
|
|
440
|
+
tooltipEl.offsetHeight,
|
|
441
|
+
props.tooltipClamp,
|
|
442
|
+
chartRect,
|
|
443
|
+
);
|
|
444
|
+
tooltipEl.style.left = `${left}px`;
|
|
445
|
+
tooltipEl.style.top = `${top}px`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function hideTooltip() {
|
|
449
|
+
if (tooltipEl) {
|
|
450
|
+
tooltipEl.remove();
|
|
451
|
+
tooltipEl = null;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function setHover(
|
|
456
|
+
pathEl: SVGPathElement,
|
|
457
|
+
feat: (typeof featuresGeo.value.features)[number],
|
|
458
|
+
) {
|
|
459
|
+
if (hoveredEl && hoveredEl !== pathEl) {
|
|
460
|
+
hoveredEl.setAttribute("stroke-width", String(effectiveStrokeWidth.value));
|
|
461
|
+
hoveredEl.setAttribute("stroke", props.strokeColor);
|
|
462
|
+
}
|
|
463
|
+
hoveredEl = pathEl;
|
|
464
|
+
pathEl.parentNode?.appendChild(pathEl);
|
|
465
|
+
pathEl.setAttribute("stroke-width", String(effectiveStrokeWidth.value + 1));
|
|
466
|
+
pathEl.setAttribute("stroke", "#555");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function clearHover() {
|
|
470
|
+
if (hoveredEl) {
|
|
471
|
+
hoveredEl.setAttribute("stroke-width", String(effectiveStrokeWidth.value));
|
|
472
|
+
hoveredEl.setAttribute("stroke", props.strokeColor);
|
|
473
|
+
hoveredEl = null;
|
|
474
|
+
emit("stateHover", null);
|
|
475
|
+
}
|
|
476
|
+
hideTooltip();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Delegated event handlers (native DOM, attached to <g>)
|
|
480
|
+
function onDelegatedEvent(event: Event) {
|
|
481
|
+
if (isZooming) return;
|
|
482
|
+
const me = event as MouseEvent;
|
|
483
|
+
const hit = resolveTarget(me.target as Element);
|
|
484
|
+
if (!hit) return;
|
|
485
|
+
if (event.type === "click") {
|
|
486
|
+
emit("stateClick", {
|
|
487
|
+
id: String(hit.feat.id),
|
|
488
|
+
name: stateName(hit.feat),
|
|
489
|
+
value: stateValue(hit.feat),
|
|
490
|
+
});
|
|
491
|
+
} else if (event.type === "mouseover") {
|
|
492
|
+
setHover(hit.pathEl, hit.feat);
|
|
493
|
+
if (props.tooltipTrigger) showTooltip(hit.feat, me.clientX, me.clientY);
|
|
494
|
+
emit("stateHover", {
|
|
495
|
+
id: String(hit.feat.id),
|
|
496
|
+
name: stateName(hit.feat),
|
|
497
|
+
value: stateValue(hit.feat),
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function onDelegatedMouseMove(event: MouseEvent) {
|
|
503
|
+
if (isZooming || !tooltipEl) return;
|
|
504
|
+
tooltipEl.style.left = `${event.clientX + 16}px`;
|
|
505
|
+
tooltipEl.style.top = `${event.clientY}px`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function onDelegatedMouseOut(event: MouseEvent) {
|
|
509
|
+
const related = event.relatedTarget as Element | null;
|
|
510
|
+
if (related && mapGroupRef.value?.contains(related)) return;
|
|
511
|
+
clearHover();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function menuFilename() {
|
|
515
|
+
return typeof props.menu === "string" ? props.menu : "choropleth";
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const showLegend = computed(
|
|
519
|
+
() =>
|
|
520
|
+
props.legend && (isCategorical.value || isThreshold.value || props.data),
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
const sortedThresholdStops = computed(() =>
|
|
524
|
+
(props.colorScale as ThresholdStop[]).slice().sort((a, b) => a.min - b.min),
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
const titleHeight = computed(() => (props.title ? 24 : 0));
|
|
528
|
+
const legendHeight = computed(() => (showLegend.value ? 28 : 0));
|
|
529
|
+
const topOffset = computed(() => titleHeight.value + legendHeight.value);
|
|
530
|
+
|
|
531
|
+
const svgHeight = computed(() => height.value + topOffset.value);
|
|
532
|
+
|
|
533
|
+
const legendY = computed(() => titleHeight.value + 18);
|
|
534
|
+
|
|
535
|
+
const gradientStops = computed(() => {
|
|
536
|
+
const steps = 10;
|
|
537
|
+
const result: { offset: string; color: string }[] = [];
|
|
538
|
+
for (let i = 0; i <= steps; i++) {
|
|
539
|
+
const t = i / steps;
|
|
540
|
+
result.push({
|
|
541
|
+
offset: `${(t * 100).toFixed(0)}%`,
|
|
542
|
+
color: interpolateColor(t),
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
return result;
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
const continuousTicks = computed(() => {
|
|
549
|
+
const { min, max } = extent.value;
|
|
550
|
+
const range = max - min;
|
|
551
|
+
const count = 3;
|
|
552
|
+
const ticks: { value: string; pct: number }[] = [];
|
|
553
|
+
for (let i = 1; i <= count; i++) {
|
|
554
|
+
const t = i / (count + 1);
|
|
555
|
+
const v = min + range * t;
|
|
556
|
+
const formatted = Number.isInteger(v)
|
|
557
|
+
? String(v)
|
|
558
|
+
: v.toFixed(1).replace(/\.0$/, "");
|
|
559
|
+
ticks.push({ value: formatted, pct: t * 100 });
|
|
560
|
+
}
|
|
561
|
+
return ticks;
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const discreteLegendItems = computed(() => {
|
|
565
|
+
const items: { key: string; color: string; label: string }[] = [];
|
|
566
|
+
if (isCategorical.value) {
|
|
567
|
+
for (const stop of props.colorScale as CategoricalStop[]) {
|
|
568
|
+
items.push({ key: stop.value, color: stop.color, label: stop.value });
|
|
569
|
+
}
|
|
570
|
+
} else if (isThreshold.value) {
|
|
571
|
+
for (const stop of sortedThresholdStops.value) {
|
|
572
|
+
items.push({
|
|
573
|
+
key: String(stop.min),
|
|
574
|
+
color: stop.color,
|
|
575
|
+
label: stop.label ?? String(stop.min),
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return items;
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
const discreteLegendTotalWidth = computed(() => {
|
|
583
|
+
const titleWidth = props.legendTitle ? props.legendTitle.length * 8 + 12 : 0;
|
|
584
|
+
let w = titleWidth;
|
|
585
|
+
for (const item of discreteLegendItems.value) {
|
|
586
|
+
w += 16 + item.label.length * 7 + 12;
|
|
587
|
+
}
|
|
588
|
+
return w - (discreteLegendItems.value.length > 0 ? 12 : 0);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
const discreteLegendPositions = computed(() => {
|
|
592
|
+
const titleWidth = props.legendTitle ? props.legendTitle.length * 8 + 12 : 0;
|
|
593
|
+
let x = titleWidth;
|
|
594
|
+
return discreteLegendItems.value.map((item) => {
|
|
595
|
+
const pos = x;
|
|
596
|
+
x += 16 + item.label.length * 7 + 12;
|
|
597
|
+
return pos;
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
const legendXOffset = computed(() => {
|
|
602
|
+
if (isCategorical.value || isThreshold.value) {
|
|
603
|
+
return (width.value - discreteLegendTotalWidth.value) / 2;
|
|
604
|
+
}
|
|
605
|
+
const barWidth = 160;
|
|
606
|
+
const titleWidth = props.legendTitle ? props.legendTitle.length * 8 + 12 : 0;
|
|
607
|
+
return (width.value - titleWidth - barWidth) / 2;
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
const menuItems = computed<ChartMenuItem[]>(() => {
|
|
611
|
+
const fname = menuFilename();
|
|
612
|
+
return [
|
|
613
|
+
{
|
|
614
|
+
label: "Save as SVG",
|
|
615
|
+
action: () => {
|
|
616
|
+
if (svgRef.value) saveSvg(svgRef.value, fname);
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
label: "Save as PNG",
|
|
621
|
+
action: () => {
|
|
622
|
+
if (svgRef.value) savePng(svgRef.value, fname);
|
|
623
|
+
},
|
|
624
|
+
},
|
|
625
|
+
];
|
|
626
|
+
});
|
|
627
|
+
</script>
|
|
628
|
+
|
|
629
|
+
<template>
|
|
630
|
+
<div ref="containerRef" :class="['choropleth-wrapper', { pannable: pan }]">
|
|
631
|
+
<ChartMenu v-if="menu" :items="menuItems" />
|
|
632
|
+
<svg ref="svgRef" :width="width" :height="svgHeight">
|
|
633
|
+
<g ref="mapGroupRef">
|
|
634
|
+
<path
|
|
635
|
+
v-for="feat in featuresGeo.features"
|
|
636
|
+
:key="String(feat.id)"
|
|
637
|
+
:data-feat-id="String(feat.id)"
|
|
638
|
+
:d="pathGenerator(feat) ?? undefined"
|
|
639
|
+
:fill="stateColor(feat.id!)"
|
|
640
|
+
:stroke="strokeColor"
|
|
641
|
+
:stroke-width="effectiveStrokeWidth"
|
|
642
|
+
class="state-path"
|
|
643
|
+
>
|
|
644
|
+
<title v-if="!tooltipTrigger">
|
|
645
|
+
{{ stateName(feat)
|
|
646
|
+
}}{{ stateValue(feat) != null ? `: ${stateValue(feat)}` : "" }}
|
|
647
|
+
</title>
|
|
648
|
+
</path>
|
|
649
|
+
<path
|
|
650
|
+
v-if="stateBordersPath"
|
|
651
|
+
:d="pathGenerator(stateBordersPath) ?? undefined"
|
|
652
|
+
fill="none"
|
|
653
|
+
:stroke="strokeColor"
|
|
654
|
+
:stroke-width="1"
|
|
655
|
+
stroke-linejoin="round"
|
|
656
|
+
pointer-events="none"
|
|
657
|
+
/>
|
|
658
|
+
</g>
|
|
659
|
+
<!-- Legend -->
|
|
660
|
+
<g
|
|
661
|
+
v-if="showLegend"
|
|
662
|
+
class="choropleth-legend"
|
|
663
|
+
:transform="`translate(${legendXOffset},${legendY})`"
|
|
664
|
+
>
|
|
665
|
+
<!-- Categorical or Threshold: dots with labels -->
|
|
666
|
+
<template v-if="isCategorical || isThreshold">
|
|
667
|
+
<text
|
|
668
|
+
v-if="legendTitle"
|
|
669
|
+
:y="5"
|
|
670
|
+
font-size="13"
|
|
671
|
+
font-weight="600"
|
|
672
|
+
fill="currentColor"
|
|
673
|
+
>
|
|
674
|
+
{{ legendTitle }}
|
|
675
|
+
</text>
|
|
676
|
+
<template v-for="(item, i) in discreteLegendItems" :key="item.key">
|
|
677
|
+
<rect
|
|
678
|
+
:x="discreteLegendPositions[i]"
|
|
679
|
+
:y="-5"
|
|
680
|
+
width="12"
|
|
681
|
+
height="12"
|
|
682
|
+
rx="3"
|
|
683
|
+
:fill="item.color"
|
|
684
|
+
/>
|
|
685
|
+
<text
|
|
686
|
+
:x="discreteLegendPositions[i] + 16"
|
|
687
|
+
:y="5"
|
|
688
|
+
font-size="13"
|
|
689
|
+
fill="currentColor"
|
|
690
|
+
>
|
|
691
|
+
{{ item.label }}
|
|
692
|
+
</text>
|
|
693
|
+
</template>
|
|
694
|
+
</template>
|
|
695
|
+
<!-- Continuous: gradient bar with ticks -->
|
|
696
|
+
<template v-else>
|
|
697
|
+
<text
|
|
698
|
+
v-if="legendTitle"
|
|
699
|
+
:y="5"
|
|
700
|
+
font-size="13"
|
|
701
|
+
font-weight="600"
|
|
702
|
+
fill="currentColor"
|
|
703
|
+
>
|
|
704
|
+
{{ legendTitle }}
|
|
705
|
+
</text>
|
|
706
|
+
<defs>
|
|
707
|
+
<linearGradient :id="gradientId" x1="0" x2="1" y1="0" y2="0">
|
|
708
|
+
<stop
|
|
709
|
+
v-for="s in gradientStops"
|
|
710
|
+
:key="s.offset"
|
|
711
|
+
:offset="s.offset"
|
|
712
|
+
:stop-color="s.color"
|
|
713
|
+
/>
|
|
714
|
+
</linearGradient>
|
|
715
|
+
</defs>
|
|
716
|
+
<rect
|
|
717
|
+
:x="legendTitle ? legendTitle.length * 8 + 12 : 0"
|
|
718
|
+
:y="-6"
|
|
719
|
+
:width="160"
|
|
720
|
+
:height="12"
|
|
721
|
+
rx="2"
|
|
722
|
+
:fill="`url(#${gradientId})`"
|
|
723
|
+
/>
|
|
724
|
+
<text
|
|
725
|
+
v-for="tick in continuousTicks"
|
|
726
|
+
:key="tick.value"
|
|
727
|
+
:x="
|
|
728
|
+
(legendTitle ? legendTitle.length * 8 + 12 : 0) +
|
|
729
|
+
(tick.pct / 100) * 160
|
|
730
|
+
"
|
|
731
|
+
:y="20"
|
|
732
|
+
font-size="11"
|
|
733
|
+
fill="currentColor"
|
|
734
|
+
opacity="0.7"
|
|
735
|
+
text-anchor="middle"
|
|
736
|
+
>
|
|
737
|
+
{{ tick.value }}
|
|
738
|
+
</text>
|
|
739
|
+
</template>
|
|
740
|
+
</g>
|
|
741
|
+
<text
|
|
742
|
+
v-if="title"
|
|
743
|
+
:x="width / 2"
|
|
744
|
+
:y="18"
|
|
745
|
+
text-anchor="middle"
|
|
746
|
+
font-size="14"
|
|
747
|
+
font-weight="600"
|
|
748
|
+
fill="currentColor"
|
|
749
|
+
>
|
|
750
|
+
{{ title }}
|
|
751
|
+
</text>
|
|
752
|
+
</svg>
|
|
753
|
+
</div>
|
|
754
|
+
</template>
|
|
755
|
+
|
|
756
|
+
<style scoped>
|
|
757
|
+
.choropleth-wrapper {
|
|
758
|
+
position: relative;
|
|
759
|
+
width: 100%;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
.choropleth-wrapper.pannable svg {
|
|
763
|
+
cursor: grab;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
.choropleth-wrapper.pannable svg:active {
|
|
767
|
+
cursor: grabbing;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
.choropleth-wrapper:hover :deep(.chart-menu-button) {
|
|
771
|
+
opacity: 1;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
.state-path {
|
|
775
|
+
cursor: pointer;
|
|
776
|
+
}
|
|
777
|
+
</style>
|