@cfasim-ui/charts 0.1.2 → 0.1.3

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.
@@ -0,0 +1,629 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch, onMounted, onUnmounted, useId } from "vue";
3
+ import { geoPath, geoAlbersUsa } from "d3-geo";
4
+ import { zoom as d3Zoom, zoomIdentity } from "d3-zoom";
5
+ import { select } from "d3-selection";
6
+ import { feature, mesh, merge } from "topojson-client";
7
+ import type { Topology, GeometryCollection } from "topojson-specification";
8
+ import usStates from "us-atlas/states-10m.json";
9
+ import usCounties from "us-atlas/counties-10m.json";
10
+ import { fipsToHsa, hsaNames } from "./hsaMapping.js";
11
+ import ChartMenu from "../ChartMenu/ChartMenu.vue";
12
+ import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
13
+ import { saveSvg, savePng } from "../ChartMenu/download.js";
14
+
15
+ export type GeoType = "states" | "counties" | "hsas";
16
+
17
+ export interface StateData {
18
+ /** FIPS code (e.g. "06" for California, "04015" for a county) or name */
19
+ id: string;
20
+ value: number | string;
21
+ }
22
+
23
+ export interface ChoroplethColorScale {
24
+ /** Minimum color (CSS color string). Default: "#e5f0fa" */
25
+ min?: string;
26
+ /** Maximum color (CSS color string). Default: "#08519c" */
27
+ max?: string;
28
+ }
29
+
30
+ export interface ThresholdStop {
31
+ /** Lower bound (inclusive). Values at or above this threshold get this color. */
32
+ min: number;
33
+ color: string;
34
+ /** Optional label for the legend (defaults to the min value) */
35
+ label?: string;
36
+ }
37
+
38
+ export interface CategoricalStop {
39
+ /** The categorical value to match */
40
+ value: string;
41
+ /** CSS color string */
42
+ color: string;
43
+ }
44
+
45
+ const props = withDefaults(
46
+ defineProps<{
47
+ data?: StateData[];
48
+ /** Geographic type: "states" (default), "counties", or "hsas" (Health Service Areas) */
49
+ geoType?: GeoType;
50
+ width?: number;
51
+ height?: number;
52
+ colorScale?: ChoroplethColorScale | ThresholdStop[] | CategoricalStop[];
53
+ title?: string;
54
+ noDataColor?: string;
55
+ strokeColor?: string;
56
+ strokeWidth?: number;
57
+ menu?: boolean | string;
58
+ /** Show legend. Default: true */
59
+ legend?: boolean;
60
+ /** Title displayed next to the legend */
61
+ legendTitle?: string;
62
+ /** Enable mouse-wheel zooming. Default: false */
63
+ zoom?: boolean;
64
+ /** Enable click-and-drag panning. Default: false */
65
+ pan?: boolean;
66
+ }>(),
67
+ {
68
+ geoType: "states",
69
+ noDataColor: "#ddd",
70
+ strokeColor: "#fff",
71
+ strokeWidth: 0.5,
72
+ menu: true,
73
+ legend: true,
74
+ zoom: false,
75
+ pan: false,
76
+ },
77
+ );
78
+
79
+ const emit = defineEmits<{
80
+ (
81
+ e: "stateClick",
82
+ state: { id: string; name: string; value?: number | string },
83
+ ): void;
84
+ (
85
+ e: "stateHover",
86
+ state: { id: string; name: string; value?: number | string } | null,
87
+ ): void;
88
+ }>();
89
+
90
+ const uid = useId();
91
+ const gradientId = `choropleth-gradient-${uid}`;
92
+ const containerRef = ref<HTMLElement | null>(null);
93
+ const svgRef = ref<SVGSVGElement | null>(null);
94
+ const mapGroupRef = ref<SVGGElement | null>(null);
95
+ const measuredWidth = ref(0);
96
+ let hoveredEl: SVGPathElement | null = null;
97
+ let observer: ResizeObserver | null = null;
98
+ let zoomBehavior: ReturnType<typeof d3Zoom<SVGSVGElement, unknown>> | null =
99
+ null;
100
+
101
+ onMounted(() => {
102
+ if (containerRef.value) {
103
+ measuredWidth.value = containerRef.value.clientWidth;
104
+ observer = new ResizeObserver((entries) => {
105
+ const entry = entries[0];
106
+ if (entry) measuredWidth.value = entry.contentRect.width;
107
+ });
108
+ observer.observe(containerRef.value);
109
+ }
110
+ setupZoom();
111
+ });
112
+
113
+ onUnmounted(() => {
114
+ observer?.disconnect();
115
+ teardownZoom();
116
+ });
117
+
118
+ function setupZoom() {
119
+ if (!svgRef.value || !mapGroupRef.value) return;
120
+ if (!props.zoom && !props.pan) return;
121
+
122
+ const svg = select(svgRef.value);
123
+ zoomBehavior = d3Zoom<SVGSVGElement, unknown>()
124
+ .scaleExtent(props.zoom ? [1, 12] : [1, 1])
125
+ .on("zoom", (event) => {
126
+ if (mapGroupRef.value) {
127
+ mapGroupRef.value.setAttribute("transform", event.transform);
128
+ }
129
+ });
130
+
131
+ if (!props.pan) {
132
+ zoomBehavior.filter(
133
+ (event) => event.type === "wheel" || event.type === "dblclick",
134
+ );
135
+ }
136
+
137
+ svg.call(zoomBehavior);
138
+ }
139
+
140
+ function teardownZoom() {
141
+ if (svgRef.value && zoomBehavior) {
142
+ select(svgRef.value).on(".zoom", null);
143
+ zoomBehavior = null;
144
+ }
145
+ }
146
+
147
+ watch(
148
+ () => [props.zoom, props.pan],
149
+ () => {
150
+ teardownZoom();
151
+ setupZoom();
152
+ },
153
+ );
154
+
155
+ const width = computed(() => props.width ?? (measuredWidth.value || 600));
156
+ const aspectRatio = computed(() => {
157
+ if (props.width && props.height) return props.height / props.width;
158
+ return 0.625;
159
+ });
160
+ const height = computed(() => width.value * aspectRatio.value);
161
+
162
+ type NamedGeometry = GeometryCollection<{ name: string }>;
163
+ const statesTopo = usStates as unknown as Topology<{ states: NamedGeometry }>;
164
+ const countiesTopo = usCounties as unknown as Topology<{
165
+ counties: NamedGeometry;
166
+ states: NamedGeometry;
167
+ }>;
168
+
169
+ const hsaFeaturesGeo = computed(() => {
170
+ const countyGeometries = countiesTopo.objects.counties.geometries;
171
+ const groups = new Map<string, typeof countyGeometries>();
172
+
173
+ for (const geom of countyGeometries) {
174
+ const fips = String(geom.id).padStart(5, "0");
175
+ const hsaCode = fipsToHsa[fips];
176
+ if (!hsaCode) continue;
177
+ if (!groups.has(hsaCode)) groups.set(hsaCode, []);
178
+ groups.get(hsaCode)!.push(geom);
179
+ }
180
+
181
+ const features: GeoJSON.Feature[] = [];
182
+ for (const [hsaCode, geoms] of groups) {
183
+ features.push({
184
+ type: "Feature",
185
+ id: hsaCode,
186
+ properties: { name: hsaNames[hsaCode] ?? hsaCode },
187
+ geometry: merge(countiesTopo as unknown as Topology, geoms as any),
188
+ });
189
+ }
190
+
191
+ return { type: "FeatureCollection" as const, features };
192
+ });
193
+
194
+ const featuresGeo = computed(() => {
195
+ if (props.geoType === "hsas") return hsaFeaturesGeo.value;
196
+ if (props.geoType === "counties") {
197
+ return feature(countiesTopo, countiesTopo.objects.counties);
198
+ }
199
+ return feature(statesTopo, statesTopo.objects.states);
200
+ });
201
+
202
+ const stateBordersPath = computed(() => {
203
+ if (props.geoType !== "counties" && props.geoType !== "hsas") return null;
204
+ return mesh(countiesTopo, countiesTopo.objects.states, (a, b) => a !== b);
205
+ });
206
+
207
+ const projection = computed(() =>
208
+ geoAlbersUsa().fitExtent(
209
+ [
210
+ [0, topOffset.value],
211
+ [width.value, height.value + topOffset.value],
212
+ ],
213
+ featuresGeo.value,
214
+ ),
215
+ );
216
+
217
+ const pathGenerator = computed(() => geoPath(projection.value));
218
+
219
+ const effectiveStrokeWidth = computed(() =>
220
+ props.geoType === "counties" || props.geoType === "hsas"
221
+ ? props.strokeWidth * 0.5
222
+ : props.strokeWidth,
223
+ );
224
+
225
+ const dataMap = computed(() => {
226
+ const map = new Map<string, number | string>();
227
+ if (!props.data) return map;
228
+ for (const d of props.data) {
229
+ map.set(d.id, d.value);
230
+ const geo = featuresGeo.value.features.find(
231
+ (f) => f.properties?.name === d.id,
232
+ );
233
+ if (geo?.id != null) map.set(String(geo.id), d.value);
234
+ }
235
+ return map;
236
+ });
237
+
238
+ const extent = computed(() => {
239
+ if (!props.data || props.data.length === 0) return { min: 0, max: 1 };
240
+ let min = Infinity;
241
+ let max = -Infinity;
242
+ for (const d of props.data) {
243
+ if (typeof d.value === "number") {
244
+ if (d.value < min) min = d.value;
245
+ if (d.value > max) max = d.value;
246
+ }
247
+ }
248
+ if (!isFinite(min)) return { min: 0, max: 1 };
249
+ if (min === max) return { min, max: min + 1 };
250
+ return { min, max };
251
+ });
252
+
253
+ const isCategorical = computed(
254
+ () =>
255
+ Array.isArray(props.colorScale) &&
256
+ props.colorScale.length > 0 &&
257
+ "value" in props.colorScale[0],
258
+ );
259
+
260
+ const isThreshold = computed(
261
+ () => Array.isArray(props.colorScale) && !isCategorical.value,
262
+ );
263
+
264
+ const minColor = computed(() =>
265
+ !isThreshold.value
266
+ ? ((props.colorScale as ChoroplethColorScale | undefined)?.min ?? "#e5f0fa")
267
+ : "",
268
+ );
269
+ const maxColor = computed(() =>
270
+ !isThreshold.value
271
+ ? ((props.colorScale as ChoroplethColorScale | undefined)?.max ?? "#08519c")
272
+ : "",
273
+ );
274
+
275
+ function parseHex(hex: string): [number, number, number] {
276
+ const h = hex.replace("#", "");
277
+ return [
278
+ parseInt(h.slice(0, 2), 16),
279
+ parseInt(h.slice(2, 4), 16),
280
+ parseInt(h.slice(4, 6), 16),
281
+ ];
282
+ }
283
+
284
+ function interpolateColor(t: number): string {
285
+ const [r1, g1, b1] = parseHex(minColor.value);
286
+ const [r2, g2, b2] = parseHex(maxColor.value);
287
+ const r = Math.round(r1 + (r2 - r1) * t);
288
+ const g = Math.round(g1 + (g2 - g1) * t);
289
+ const b = Math.round(b1 + (b2 - b1) * t);
290
+ return `rgb(${r},${g},${b})`;
291
+ }
292
+
293
+ function thresholdColor(value: number): string {
294
+ const stops = (props.colorScale as ThresholdStop[])
295
+ .slice()
296
+ .sort((a, b) => b.min - a.min);
297
+ for (const stop of stops) {
298
+ if (value >= stop.min) return stop.color;
299
+ }
300
+ return props.noDataColor!;
301
+ }
302
+
303
+ function categoricalColor(value: string | number): string {
304
+ const stops = props.colorScale as CategoricalStop[];
305
+ const match = stops.find((s) => s.value === String(value));
306
+ return match ? match.color : props.noDataColor!;
307
+ }
308
+
309
+ function stateColor(id: string | number): string {
310
+ const value = dataMap.value.get(String(id));
311
+ if (value == null) return props.noDataColor!;
312
+ if (isCategorical.value) return categoricalColor(value);
313
+ if (isThreshold.value) return thresholdColor(value as number);
314
+ const { min, max } = extent.value;
315
+ const t = ((value as number) - min) / (max - min);
316
+ return interpolateColor(t);
317
+ }
318
+
319
+ function stateName(feat: (typeof featuresGeo.value.features)[number]): string {
320
+ return feat.properties?.name ?? String(feat.id);
321
+ }
322
+
323
+ function stateValue(
324
+ feat: (typeof featuresGeo.value.features)[number],
325
+ ): number | string | undefined {
326
+ return dataMap.value.get(String(feat.id));
327
+ }
328
+
329
+ function handleClick(feat: (typeof featuresGeo.value.features)[number]) {
330
+ emit("stateClick", {
331
+ id: String(feat.id),
332
+ name: stateName(feat),
333
+ value: stateValue(feat),
334
+ });
335
+ }
336
+
337
+ function handleMouseEnter(
338
+ feat: (typeof featuresGeo.value.features)[number],
339
+ event: MouseEvent,
340
+ ) {
341
+ const el = event.currentTarget as SVGPathElement;
342
+ hoveredEl = el;
343
+ el.setAttribute("stroke-width", String(effectiveStrokeWidth.value + 1));
344
+ emit("stateHover", {
345
+ id: String(feat.id),
346
+ name: stateName(feat),
347
+ value: stateValue(feat),
348
+ });
349
+ }
350
+
351
+ function handleMouseLeave() {
352
+ if (hoveredEl) {
353
+ hoveredEl.setAttribute("stroke-width", String(effectiveStrokeWidth.value));
354
+ hoveredEl = null;
355
+ }
356
+ emit("stateHover", null);
357
+ }
358
+
359
+ function menuFilename() {
360
+ return typeof props.menu === "string" ? props.menu : "choropleth";
361
+ }
362
+
363
+ const showLegend = computed(
364
+ () =>
365
+ props.legend && (isCategorical.value || isThreshold.value || props.data),
366
+ );
367
+
368
+ const sortedThresholdStops = computed(() =>
369
+ (props.colorScale as ThresholdStop[]).slice().sort((a, b) => a.min - b.min),
370
+ );
371
+
372
+ const titleHeight = computed(() => (props.title ? 24 : 0));
373
+ const legendHeight = computed(() => (showLegend.value ? 28 : 0));
374
+ const topOffset = computed(() => titleHeight.value + legendHeight.value);
375
+
376
+ const svgHeight = computed(() => height.value + topOffset.value);
377
+
378
+ const legendY = computed(() => titleHeight.value + 18);
379
+
380
+ const gradientStops = computed(() => {
381
+ const steps = 10;
382
+ const result: { offset: string; color: string }[] = [];
383
+ for (let i = 0; i <= steps; i++) {
384
+ const t = i / steps;
385
+ result.push({
386
+ offset: `${(t * 100).toFixed(0)}%`,
387
+ color: interpolateColor(t),
388
+ });
389
+ }
390
+ return result;
391
+ });
392
+
393
+ const continuousTicks = computed(() => {
394
+ const { min, max } = extent.value;
395
+ const range = max - min;
396
+ const count = 3;
397
+ const ticks: { value: string; pct: number }[] = [];
398
+ for (let i = 1; i <= count; i++) {
399
+ const t = i / (count + 1);
400
+ const v = min + range * t;
401
+ const formatted = Number.isInteger(v)
402
+ ? String(v)
403
+ : v.toFixed(1).replace(/\.0$/, "");
404
+ ticks.push({ value: formatted, pct: t * 100 });
405
+ }
406
+ return ticks;
407
+ });
408
+
409
+ const discreteLegendItems = computed(() => {
410
+ const items: { key: string; color: string; label: string }[] = [];
411
+ if (isCategorical.value) {
412
+ for (const stop of props.colorScale as CategoricalStop[]) {
413
+ items.push({ key: stop.value, color: stop.color, label: stop.value });
414
+ }
415
+ } else if (isThreshold.value) {
416
+ for (const stop of sortedThresholdStops.value) {
417
+ items.push({
418
+ key: String(stop.min),
419
+ color: stop.color,
420
+ label: stop.label ?? String(stop.min),
421
+ });
422
+ }
423
+ }
424
+ return items;
425
+ });
426
+
427
+ const discreteLegendTotalWidth = computed(() => {
428
+ const titleWidth = props.legendTitle ? props.legendTitle.length * 8 + 12 : 0;
429
+ let w = titleWidth;
430
+ for (const item of discreteLegendItems.value) {
431
+ w += 16 + item.label.length * 7 + 12;
432
+ }
433
+ return w - (discreteLegendItems.value.length > 0 ? 12 : 0);
434
+ });
435
+
436
+ const discreteLegendPositions = computed(() => {
437
+ const titleWidth = props.legendTitle ? props.legendTitle.length * 8 + 12 : 0;
438
+ let x = titleWidth;
439
+ return discreteLegendItems.value.map((item) => {
440
+ const pos = x;
441
+ x += 16 + item.label.length * 7 + 12;
442
+ return pos;
443
+ });
444
+ });
445
+
446
+ const legendXOffset = computed(() => {
447
+ if (isCategorical.value || isThreshold.value) {
448
+ return (width.value - discreteLegendTotalWidth.value) / 2;
449
+ }
450
+ const barWidth = 160;
451
+ const titleWidth = props.legendTitle ? props.legendTitle.length * 8 + 12 : 0;
452
+ return (width.value - titleWidth - barWidth) / 2;
453
+ });
454
+
455
+ const menuItems = computed<ChartMenuItem[]>(() => {
456
+ const fname = menuFilename();
457
+ return [
458
+ {
459
+ label: "Save as SVG",
460
+ action: () => {
461
+ if (svgRef.value) saveSvg(svgRef.value, fname);
462
+ },
463
+ },
464
+ {
465
+ label: "Save as PNG",
466
+ action: () => {
467
+ if (svgRef.value) savePng(svgRef.value, fname);
468
+ },
469
+ },
470
+ ];
471
+ });
472
+ </script>
473
+
474
+ <template>
475
+ <div ref="containerRef" :class="['choropleth-wrapper', { pannable: pan }]">
476
+ <ChartMenu v-if="menu" :items="menuItems" />
477
+ <svg ref="svgRef" :width="width" :height="svgHeight">
478
+ <g ref="mapGroupRef">
479
+ <path
480
+ v-for="feat in featuresGeo.features"
481
+ :key="String(feat.id)"
482
+ :d="pathGenerator(feat) ?? undefined"
483
+ :fill="stateColor(feat.id!)"
484
+ :stroke="strokeColor"
485
+ :stroke-width="effectiveStrokeWidth"
486
+ class="state-path"
487
+ @click="handleClick(feat)"
488
+ @mouseenter="handleMouseEnter(feat, $event)"
489
+ @mouseleave="handleMouseLeave"
490
+ >
491
+ <title>
492
+ {{ stateName(feat)
493
+ }}{{ stateValue(feat) != null ? `: ${stateValue(feat)}` : "" }}
494
+ </title>
495
+ </path>
496
+ <path
497
+ v-if="stateBordersPath"
498
+ :d="pathGenerator(stateBordersPath) ?? undefined"
499
+ fill="none"
500
+ :stroke="strokeColor"
501
+ :stroke-width="1"
502
+ stroke-linejoin="round"
503
+ pointer-events="none"
504
+ />
505
+ </g>
506
+ <!-- Legend -->
507
+ <g
508
+ v-if="showLegend"
509
+ class="choropleth-legend"
510
+ :transform="`translate(${legendXOffset},${legendY})`"
511
+ >
512
+ <!-- Categorical or Threshold: dots with labels -->
513
+ <template v-if="isCategorical || isThreshold">
514
+ <text
515
+ v-if="legendTitle"
516
+ :y="5"
517
+ font-size="13"
518
+ font-weight="600"
519
+ fill="currentColor"
520
+ >
521
+ {{ legendTitle }}
522
+ </text>
523
+ <template v-for="(item, i) in discreteLegendItems" :key="item.key">
524
+ <rect
525
+ :x="discreteLegendPositions[i]"
526
+ :y="-5"
527
+ width="12"
528
+ height="12"
529
+ rx="3"
530
+ :fill="item.color"
531
+ />
532
+ <text
533
+ :x="discreteLegendPositions[i] + 16"
534
+ :y="5"
535
+ font-size="13"
536
+ fill="currentColor"
537
+ >
538
+ {{ item.label }}
539
+ </text>
540
+ </template>
541
+ </template>
542
+ <!-- Continuous: gradient bar with ticks -->
543
+ <template v-else>
544
+ <text
545
+ v-if="legendTitle"
546
+ :y="5"
547
+ font-size="13"
548
+ font-weight="600"
549
+ fill="currentColor"
550
+ >
551
+ {{ legendTitle }}
552
+ </text>
553
+ <defs>
554
+ <linearGradient :id="gradientId" x1="0" x2="1" y1="0" y2="0">
555
+ <stop
556
+ v-for="s in gradientStops"
557
+ :key="s.offset"
558
+ :offset="s.offset"
559
+ :stop-color="s.color"
560
+ />
561
+ </linearGradient>
562
+ </defs>
563
+ <rect
564
+ :x="legendTitle ? legendTitle.length * 8 + 12 : 0"
565
+ :y="-6"
566
+ :width="160"
567
+ :height="12"
568
+ rx="2"
569
+ :fill="`url(#${gradientId})`"
570
+ />
571
+ <text
572
+ v-for="tick in continuousTicks"
573
+ :key="tick.value"
574
+ :x="
575
+ (legendTitle ? legendTitle.length * 8 + 12 : 0) +
576
+ (tick.pct / 100) * 160
577
+ "
578
+ :y="20"
579
+ font-size="11"
580
+ fill="currentColor"
581
+ opacity="0.7"
582
+ text-anchor="middle"
583
+ >
584
+ {{ tick.value }}
585
+ </text>
586
+ </template>
587
+ </g>
588
+ <text
589
+ v-if="title"
590
+ :x="width / 2"
591
+ :y="18"
592
+ text-anchor="middle"
593
+ font-size="14"
594
+ font-weight="600"
595
+ fill="currentColor"
596
+ >
597
+ {{ title }}
598
+ </text>
599
+ </svg>
600
+ </div>
601
+ </template>
602
+
603
+ <style scoped>
604
+ .choropleth-wrapper {
605
+ position: relative;
606
+ width: 100%;
607
+ }
608
+
609
+ .choropleth-wrapper.pannable svg {
610
+ cursor: grab;
611
+ }
612
+
613
+ .choropleth-wrapper.pannable svg:active {
614
+ cursor: grabbing;
615
+ }
616
+
617
+ .choropleth-wrapper:hover :deep(.chart-menu-button) {
618
+ opacity: 1;
619
+ }
620
+
621
+ .state-path {
622
+ cursor: pointer;
623
+ transition: fill-opacity 0.15s;
624
+ }
625
+
626
+ .state-path:hover {
627
+ fill-opacity: 0.8;
628
+ }
629
+ </style>