@cfasim-ui/docs 0.4.1 → 0.4.2
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.
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
import countiesTopoForPerf from "us-atlas/counties-10m.json";
|
|
4
|
+
|
|
5
|
+
// Build one row per county (~3,143) with a deterministic-ish value so the
|
|
6
|
+
// perf example can render every region with a custom tooltip.
|
|
7
|
+
const denseCountyData = computed(() => {
|
|
8
|
+
const geoms = countiesTopoForPerf.objects.counties.geometries;
|
|
9
|
+
return geoms.map((g, i) => ({
|
|
10
|
+
id: String(g.id).padStart(5, "0"),
|
|
11
|
+
value: (i * 37) % 100,
|
|
12
|
+
}));
|
|
13
|
+
});
|
|
14
|
+
</script>
|
|
15
|
+
|
|
1
16
|
# ChoroplethMap
|
|
2
17
|
|
|
3
18
|
A US choropleth map using D3's Albers USA projection, which repositions Alaska and Hawaii to the bottom left. Supports state-level, county-level, and HSA-level (Health Service Areas) rendering via the `geoType` prop.
|
|
@@ -325,9 +340,8 @@ Set `geoType="hsas"` to render Health Service Area boundaries. HSAs are dissolve
|
|
|
325
340
|
### Custom tooltip number format
|
|
326
341
|
|
|
327
342
|
Pass `tooltip-value-format` to format numeric values shown in the tooltip
|
|
328
|
-
(both the native SVG `<title>` and the interactive HTML tooltip
|
|
329
|
-
|
|
330
|
-
control over the tooltip's HTML.
|
|
343
|
+
(both the native SVG `<title>` and the interactive HTML tooltip). Use the
|
|
344
|
+
`#tooltip` slot if you want full control over the tooltip's content.
|
|
331
345
|
|
|
332
346
|
<ComponentDemo>
|
|
333
347
|
<ChoroplethMap
|
|
@@ -363,6 +377,110 @@ control over the tooltip's HTML.
|
|
|
363
377
|
</template>
|
|
364
378
|
</ComponentDemo>
|
|
365
379
|
|
|
380
|
+
### Dense county map (~3,143 features) for tooltip perf profiling
|
|
381
|
+
|
|
382
|
+
Renders every US county with a value and a custom tooltip slot. Useful as a
|
|
383
|
+
manual perf harness — open DevTools Performance, record a hover/sweep across
|
|
384
|
+
many counties, and inspect tooltip update cost. The tooltip element is
|
|
385
|
+
mounted once and patched in place; `mousemove` writes the position
|
|
386
|
+
straight to the DOM.
|
|
387
|
+
|
|
388
|
+
<ComponentDemo>
|
|
389
|
+
<ChoroplethMap
|
|
390
|
+
:topology="countiesTopo"
|
|
391
|
+
geo-type="counties"
|
|
392
|
+
:data="denseCountyData"
|
|
393
|
+
:pan="true"
|
|
394
|
+
:zoom="true"
|
|
395
|
+
:color-scale="{ min: '#f0f5ff', max: '#08306b' }"
|
|
396
|
+
title="All US counties — tooltip perf demo"
|
|
397
|
+
:height="500"
|
|
398
|
+
>
|
|
399
|
+
<template #tooltip="{ id, name, value }">
|
|
400
|
+
<div style="font-weight: 600">{{ name }}</div>
|
|
401
|
+
<div style="opacity: 0.7; font-size: 0.85em">FIPS {{ id }}</div>
|
|
402
|
+
<div>Value: {{ value }}</div>
|
|
403
|
+
</template>
|
|
404
|
+
</ChoroplethMap>
|
|
405
|
+
|
|
406
|
+
<template #code>
|
|
407
|
+
|
|
408
|
+
```vue
|
|
409
|
+
<script setup>
|
|
410
|
+
import countiesTopo from "us-atlas/counties-10m.json";
|
|
411
|
+
|
|
412
|
+
// One row per county
|
|
413
|
+
const data = countiesTopo.objects.counties.geometries.map((g, i) => ({
|
|
414
|
+
id: String(g.id).padStart(5, "0"),
|
|
415
|
+
value: (i * 37) % 100,
|
|
416
|
+
}));
|
|
417
|
+
</script>
|
|
418
|
+
|
|
419
|
+
<ChoroplethMap
|
|
420
|
+
:topology="countiesTopo"
|
|
421
|
+
geo-type="counties"
|
|
422
|
+
:data="data"
|
|
423
|
+
pan
|
|
424
|
+
zoom
|
|
425
|
+
>
|
|
426
|
+
<template #tooltip="{ id, name, value }">
|
|
427
|
+
<div style="font-weight: 600">{{ name }}</div>
|
|
428
|
+
<div style="opacity: 0.7; font-size: 0.85em">FIPS {{ id }}</div>
|
|
429
|
+
<div>Value: {{ value }}</div>
|
|
430
|
+
</template>
|
|
431
|
+
</ChoroplethMap>
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
</template>
|
|
435
|
+
</ComponentDemo>
|
|
436
|
+
|
|
437
|
+
### Custom tooltip content (`#tooltip` slot)
|
|
438
|
+
|
|
439
|
+
Use the `#tooltip` slot to render any Vue template — components, scoped
|
|
440
|
+
styles, multi-line layouts — instead of the default `name: value`. The slot
|
|
441
|
+
receives `{ id, name, value, feature }` for the hovered region. Providing the
|
|
442
|
+
slot automatically enables interactive (HTML) tooltips, so you don't need to
|
|
443
|
+
set `tooltip-trigger`.
|
|
444
|
+
|
|
445
|
+
<ComponentDemo>
|
|
446
|
+
<ChoroplethMap
|
|
447
|
+
:topology="statesTopo"
|
|
448
|
+
:data="[
|
|
449
|
+
{ id: '06', value: 39538223 },
|
|
450
|
+
{ id: '48', value: 29145505 },
|
|
451
|
+
{ id: '12', value: 21538187 },
|
|
452
|
+
{ id: '36', value: 20201249 },
|
|
453
|
+
{ id: '17', value: 12812508 },
|
|
454
|
+
]"
|
|
455
|
+
title="US population (2020)"
|
|
456
|
+
:height="300"
|
|
457
|
+
>
|
|
458
|
+
<template #tooltip="{ name, value }">
|
|
459
|
+
<div style="font-weight:600">{{ name }}</div>
|
|
460
|
+
<div v-if="typeof value === 'number'">
|
|
461
|
+
Pop: {{ value.toLocaleString('en-US') }}
|
|
462
|
+
</div>
|
|
463
|
+
<div v-else style="opacity:0.6">No data</div>
|
|
464
|
+
</template>
|
|
465
|
+
</ChoroplethMap>
|
|
466
|
+
|
|
467
|
+
<template #code>
|
|
468
|
+
|
|
469
|
+
```vue
|
|
470
|
+
<ChoroplethMap :topology="statesTopo" :data="data" title="US population (2020)">
|
|
471
|
+
<template #tooltip="{ name, value }">
|
|
472
|
+
<div style="font-weight: 600">{{ name }}</div>
|
|
473
|
+
<div v-if="typeof value === 'number'">
|
|
474
|
+
Pop: {{ value.toLocaleString("en-US") }}
|
|
475
|
+
</div>
|
|
476
|
+
<div v-else style="opacity: 0.6">No data</div>
|
|
477
|
+
</template>
|
|
478
|
+
</ChoroplethMap>
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
</template>
|
|
482
|
+
</ComponentDemo>
|
|
483
|
+
|
|
366
484
|
## Props
|
|
367
485
|
|
|
368
486
|
| Prop | Type | Required | Default |
|
|
@@ -5,11 +5,11 @@ import {
|
|
|
5
5
|
watch,
|
|
6
6
|
onMounted,
|
|
7
7
|
onUnmounted,
|
|
8
|
-
useId,
|
|
9
8
|
toRaw,
|
|
9
|
+
useSlots,
|
|
10
10
|
} from "vue";
|
|
11
11
|
import { geoPath, geoAlbersUsa } from "d3-geo";
|
|
12
|
-
import { zoom as d3Zoom } from "d3-zoom";
|
|
12
|
+
import { zoom as d3Zoom, zoomIdentity } from "d3-zoom";
|
|
13
13
|
import { select } from "d3-selection";
|
|
14
14
|
import { feature, mesh, merge } from "topojson-client";
|
|
15
15
|
import type { Topology, GeometryCollection } from "topojson-specification";
|
|
@@ -18,6 +18,9 @@ import ChartMenu from "../ChartMenu/ChartMenu.vue";
|
|
|
18
18
|
import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
|
|
19
19
|
import { saveSvg, savePng } from "../ChartMenu/download.js";
|
|
20
20
|
import { placeTooltip } from "../tooltip-position.js";
|
|
21
|
+
import ChoroplethTooltip from "./ChoroplethTooltip.vue";
|
|
22
|
+
|
|
23
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
21
24
|
|
|
22
25
|
export type GeoType = "states" | "counties" | "hsas";
|
|
23
26
|
|
|
@@ -76,7 +79,12 @@ const props = withDefaults(
|
|
|
76
79
|
pan?: boolean;
|
|
77
80
|
/** Tooltip activation mode */
|
|
78
81
|
tooltipTrigger?: "hover" | "click";
|
|
79
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* @deprecated Use the `#tooltip` slot instead, which gives you full Vue
|
|
84
|
+
* rendering (components, scoped styles, reactivity). This HTML-string
|
|
85
|
+
* formatter is kept for backwards compatibility and will be removed in a
|
|
86
|
+
* future release.
|
|
87
|
+
*/
|
|
80
88
|
tooltipFormat?: (data: {
|
|
81
89
|
id: string;
|
|
82
90
|
name: string;
|
|
@@ -119,21 +127,67 @@ const emit = defineEmits<{
|
|
|
119
127
|
): void;
|
|
120
128
|
}>();
|
|
121
129
|
|
|
122
|
-
|
|
123
|
-
|
|
130
|
+
type ChoroplethFeature = GeoJSON.Feature<
|
|
131
|
+
GeoJSON.Geometry | null,
|
|
132
|
+
{ name?: string }
|
|
133
|
+
>;
|
|
134
|
+
|
|
135
|
+
/** Public payload shape — slot props, hover/click emits, tooltip cache. */
|
|
136
|
+
interface TooltipPayload {
|
|
137
|
+
id: string;
|
|
138
|
+
name: string;
|
|
139
|
+
value?: number | string;
|
|
140
|
+
feature: ChoroplethFeature;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
defineSlots<{
|
|
144
|
+
tooltip?(props: TooltipPayload): unknown;
|
|
145
|
+
}>();
|
|
146
|
+
|
|
147
|
+
// The child types `feature` as `unknown` (it has no map-specific knowledge);
|
|
148
|
+
// we always store a ChoroplethFeature, so narrow it back at the single point
|
|
149
|
+
// where we forward the slot.
|
|
150
|
+
const narrowSlotProps = (
|
|
151
|
+
raw: { feature: unknown } & Omit<TooltipPayload, "feature">,
|
|
152
|
+
): TooltipPayload => raw as TooltipPayload;
|
|
153
|
+
|
|
124
154
|
const containerRef = ref<HTMLElement | null>(null);
|
|
125
155
|
const svgRef = ref<SVGSVGElement | null>(null);
|
|
126
156
|
const mapGroupRef = ref<SVGGElement | null>(null);
|
|
127
|
-
const
|
|
157
|
+
const tooltipChildRef = ref<InstanceType<typeof ChoroplethTooltip> | null>(
|
|
158
|
+
null,
|
|
159
|
+
);
|
|
160
|
+
const slots = useSlots();
|
|
161
|
+
// Slot/prop presence doesn't change at runtime, so this is effectively
|
|
162
|
+
// computed once. Used to gate the teleported tooltip and the SVG <title>
|
|
163
|
+
// fallback.
|
|
164
|
+
const hasInteractiveTooltip = computed(
|
|
165
|
+
() => !!props.tooltipTrigger || !!props.tooltipFormat || !!slots.tooltip,
|
|
166
|
+
);
|
|
167
|
+
// Imperative path bookkeeping. Plain Maps rather than refs — Vue never reads
|
|
168
|
+
// these from a render scope, so mutating them does not trigger re-renders.
|
|
169
|
+
const pathsByFeatureId = new Map<string, SVGPathElement>();
|
|
170
|
+
const tooltipDataById = new Map<string, TooltipPayload>();
|
|
171
|
+
let bordersPathEl: SVGPathElement | null = null;
|
|
128
172
|
let hoveredEl: SVGPathElement | null = null;
|
|
129
|
-
let tooltipEl: HTMLDivElement | null = null;
|
|
130
173
|
let isZooming = false;
|
|
131
174
|
// TODO: map hover/tooltip causes performance issues on mobile (SVG stroke-width
|
|
132
175
|
// changes + compositing layers degrade zoom/pan). Disabled on touch devices.
|
|
133
176
|
const isTouchDevice = typeof window !== "undefined" && "ontouchstart" in window;
|
|
134
|
-
let
|
|
177
|
+
let tooltipObserver: ResizeObserver | null = null;
|
|
178
|
+
const lastTooltipSize = { width: 0, height: 0 };
|
|
179
|
+
let lastPointer: { x: number; y: number } | null = null;
|
|
180
|
+
let tooltipVisible = false;
|
|
135
181
|
let zoomBehavior: ReturnType<typeof d3Zoom<SVGSVGElement, unknown>> | null =
|
|
136
182
|
null;
|
|
183
|
+
// True once the user has zoomed or panned away from the identity transform.
|
|
184
|
+
// Drives the visibility of the reset button.
|
|
185
|
+
const isZoomed = ref(false);
|
|
186
|
+
// rAF-throttled cursor coords for moveTooltip; we coalesce many mousemove
|
|
187
|
+
// events into one transform write per animation frame.
|
|
188
|
+
let pendingMoveX = 0;
|
|
189
|
+
let pendingMoveY = 0;
|
|
190
|
+
let pendingMoveFrame = 0;
|
|
137
191
|
|
|
138
192
|
function setupInteraction() {
|
|
139
193
|
if (isTouchDevice) return;
|
|
@@ -154,24 +208,34 @@ function teardownInteraction() {
|
|
|
154
208
|
g.removeEventListener("mouseout", onDelegatedMouseOut);
|
|
155
209
|
}
|
|
156
210
|
|
|
211
|
+
// Scroll / resize don't reliably emit mouseout on the underlying path even
|
|
212
|
+
// though the cursor's relationship to the map has changed — the tooltip
|
|
213
|
+
// would otherwise get stuck at its old `position: fixed` coordinates.
|
|
214
|
+
function dismissOnViewportChange() {
|
|
215
|
+
clearHover();
|
|
216
|
+
}
|
|
217
|
+
|
|
157
218
|
onMounted(() => {
|
|
158
|
-
if (containerRef.value) {
|
|
159
|
-
measuredWidth.value = containerRef.value.clientWidth;
|
|
160
|
-
observer = new ResizeObserver((entries) => {
|
|
161
|
-
const entry = entries[0];
|
|
162
|
-
if (entry) measuredWidth.value = entry.contentRect.width;
|
|
163
|
-
});
|
|
164
|
-
observer.observe(containerRef.value);
|
|
165
|
-
}
|
|
166
219
|
setupZoom();
|
|
167
220
|
setupInteraction();
|
|
221
|
+
rebuildPaths();
|
|
222
|
+
attachTooltipObserver();
|
|
223
|
+
window.addEventListener("scroll", dismissOnViewportChange, {
|
|
224
|
+
passive: true,
|
|
225
|
+
capture: true,
|
|
226
|
+
});
|
|
227
|
+
window.addEventListener("resize", dismissOnViewportChange, { passive: true });
|
|
168
228
|
});
|
|
169
229
|
|
|
170
230
|
onUnmounted(() => {
|
|
171
|
-
|
|
231
|
+
tooltipObserver?.disconnect();
|
|
232
|
+
if (pendingMoveFrame) cancelAnimationFrame(pendingMoveFrame);
|
|
172
233
|
teardownZoom();
|
|
173
234
|
teardownInteraction();
|
|
174
|
-
|
|
235
|
+
window.removeEventListener("scroll", dismissOnViewportChange, {
|
|
236
|
+
capture: true,
|
|
237
|
+
});
|
|
238
|
+
window.removeEventListener("resize", dismissOnViewportChange);
|
|
175
239
|
});
|
|
176
240
|
|
|
177
241
|
function setupZoom() {
|
|
@@ -189,6 +253,8 @@ function setupZoom() {
|
|
|
189
253
|
if (mapGroupRef.value) {
|
|
190
254
|
mapGroupRef.value.setAttribute("transform", event.transform);
|
|
191
255
|
}
|
|
256
|
+
const t = event.transform;
|
|
257
|
+
isZoomed.value = t.k !== 1 || t.x !== 0 || t.y !== 0;
|
|
192
258
|
})
|
|
193
259
|
.on("end", () => {
|
|
194
260
|
isZooming = false;
|
|
@@ -210,6 +276,13 @@ function teardownZoom() {
|
|
|
210
276
|
}
|
|
211
277
|
}
|
|
212
278
|
|
|
279
|
+
function resetZoom() {
|
|
280
|
+
if (!svgRef.value || !zoomBehavior) return;
|
|
281
|
+
// Snap straight back to identity; the zoom callback fires and clears
|
|
282
|
+
// isZoomed, which hides the button.
|
|
283
|
+
zoomBehavior.transform(select(svgRef.value), zoomIdentity);
|
|
284
|
+
}
|
|
285
|
+
|
|
213
286
|
watch(
|
|
214
287
|
() => [props.zoom, props.pan],
|
|
215
288
|
() => {
|
|
@@ -220,12 +293,23 @@ watch(
|
|
|
220
293
|
},
|
|
221
294
|
);
|
|
222
295
|
|
|
223
|
-
|
|
296
|
+
// Canonical internal coordinate system. All layout (projection, legend,
|
|
297
|
+
// title) is computed at this size; the SVG's viewBox makes the browser
|
|
298
|
+
// scale the entire canvas to whatever the container provides, so there's no
|
|
299
|
+
// JS work on container resize. `props.width` / `props.height`, when set,
|
|
300
|
+
// drive the rendered SVG element size but not these canonical coords.
|
|
301
|
+
const CANONICAL_WIDTH = 1000;
|
|
224
302
|
const aspectRatio = computed(() => {
|
|
225
303
|
if (props.width && props.height) return props.height / props.width;
|
|
226
304
|
return 0.625;
|
|
227
305
|
});
|
|
228
|
-
const
|
|
306
|
+
const width = computed(() => CANONICAL_WIDTH);
|
|
307
|
+
const height = computed(() => CANONICAL_WIDTH * aspectRatio.value);
|
|
308
|
+
|
|
309
|
+
// Layout is fluid: the wrapper fills its parent's width and the SVG fills
|
|
310
|
+
// the wrapper via CSS. `props.width` / `props.height`, when both are
|
|
311
|
+
// passed, only shape the viewBox aspect ratio — they don't pin a display
|
|
312
|
+
// size, so the map always scales to the available width without overflow.
|
|
229
313
|
|
|
230
314
|
type NamedGeometry = GeometryCollection<{ name: string }>;
|
|
231
315
|
type StatesTopo = Topology<{ states: NamedGeometry }>;
|
|
@@ -279,8 +363,8 @@ const stateBordersPath = computed(() => {
|
|
|
279
363
|
const projection = computed(() =>
|
|
280
364
|
geoAlbersUsa().fitExtent(
|
|
281
365
|
[
|
|
282
|
-
[0,
|
|
283
|
-
[width.value, height.value
|
|
366
|
+
[0, 0],
|
|
367
|
+
[width.value, height.value],
|
|
284
368
|
],
|
|
285
369
|
featuresGeo.value,
|
|
286
370
|
),
|
|
@@ -294,15 +378,26 @@ const effectiveStrokeWidth = computed(() =>
|
|
|
294
378
|
: props.strokeWidth,
|
|
295
379
|
);
|
|
296
380
|
|
|
381
|
+
// O(features + data) name→id index, so `dataMap` doesn't fall back to a
|
|
382
|
+
// linear scan per data point (previously O(features × data)).
|
|
383
|
+
const nameToFeatureId = computed(() => {
|
|
384
|
+
const m = new Map<string, string>();
|
|
385
|
+
for (const f of featuresGeo.value.features) {
|
|
386
|
+
if (f.properties?.name != null && f.id != null) {
|
|
387
|
+
m.set(f.properties.name, String(f.id));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return m;
|
|
391
|
+
});
|
|
392
|
+
|
|
297
393
|
const dataMap = computed(() => {
|
|
298
394
|
const map = new Map<string, number | string>();
|
|
299
395
|
if (!props.data) return map;
|
|
396
|
+
const nameIdx = nameToFeatureId.value;
|
|
300
397
|
for (const d of props.data) {
|
|
301
398
|
map.set(d.id, d.value);
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
);
|
|
305
|
-
if (geo?.id != null) map.set(String(geo.id), d.value);
|
|
399
|
+
const fid = nameIdx.get(d.id);
|
|
400
|
+
if (fid) map.set(fid, d.value);
|
|
306
401
|
}
|
|
307
402
|
return map;
|
|
308
403
|
});
|
|
@@ -362,41 +457,44 @@ function interpolateColor(t: number): string {
|
|
|
362
457
|
return `rgb(${r},${g},${b})`;
|
|
363
458
|
}
|
|
364
459
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
460
|
+
// Sorted high-to-low so the first match wins (highest threshold ≤ value).
|
|
461
|
+
// Cached so we don't re-sort 3k+ times during a rebuild.
|
|
462
|
+
const thresholdStopsDesc = computed(() =>
|
|
463
|
+
isThreshold.value
|
|
464
|
+
? (props.colorScale as ThresholdStop[])
|
|
465
|
+
.slice()
|
|
466
|
+
.sort((a, b) => b.min - a.min)
|
|
467
|
+
: null,
|
|
468
|
+
);
|
|
374
469
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
470
|
+
const categoricalByValue = computed(() => {
|
|
471
|
+
if (!isCategorical.value) return null;
|
|
472
|
+
const m = new Map<string, string>();
|
|
473
|
+
for (const s of props.colorScale as CategoricalStop[])
|
|
474
|
+
m.set(s.value, s.color);
|
|
475
|
+
return m;
|
|
476
|
+
});
|
|
380
477
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
if (
|
|
478
|
+
/** Single color-resolution path. Returns the noData color for missing rows. */
|
|
479
|
+
function colorFor(id: string): string {
|
|
480
|
+
const value = dataMap.value.get(id);
|
|
481
|
+
const noData = props.noDataColor!;
|
|
482
|
+
if (value == null) return noData;
|
|
483
|
+
const cat = categoricalByValue.value;
|
|
484
|
+
if (cat) return cat.get(String(value)) ?? noData;
|
|
485
|
+
const thresholds = thresholdStopsDesc.value;
|
|
486
|
+
if (thresholds) {
|
|
487
|
+
const n = value as number;
|
|
488
|
+
for (const stop of thresholds) if (n >= stop.min) return stop.color;
|
|
489
|
+
return noData;
|
|
490
|
+
}
|
|
386
491
|
const { min, max } = extent.value;
|
|
387
|
-
|
|
388
|
-
return interpolateColor(t);
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
function stateName(feat: (typeof featuresGeo.value.features)[number]): string {
|
|
392
|
-
return feat.properties?.name ?? String(feat.id);
|
|
492
|
+
return interpolateColor(((value as number) - min) / (max - min));
|
|
393
493
|
}
|
|
394
494
|
|
|
395
|
-
|
|
495
|
+
const featureName = (
|
|
396
496
|
feat: (typeof featuresGeo.value.features)[number],
|
|
397
|
-
):
|
|
398
|
-
return dataMap.value.get(String(feat.id));
|
|
399
|
-
}
|
|
497
|
+
): string => feat.properties?.name ?? String(feat.id);
|
|
400
498
|
|
|
401
499
|
function formatTooltipValue(value: number | string | undefined): string {
|
|
402
500
|
if (value == null) return "";
|
|
@@ -406,77 +504,112 @@ function formatTooltipValue(value: number | string | undefined): string {
|
|
|
406
504
|
return String(value);
|
|
407
505
|
}
|
|
408
506
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
});
|
|
507
|
+
/** "Name" or "Name: formatted-value" — used for the SVG <title> fallback. */
|
|
508
|
+
function titleText(name: string, value: number | string | undefined): string {
|
|
509
|
+
return value == null ? name : `${name}: ${formatTooltipValue(value)}`;
|
|
510
|
+
}
|
|
414
511
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
512
|
+
// ─── Tooltip (fully synchronous; positioning uses cached size) ───────────
|
|
513
|
+
//
|
|
514
|
+
// The flow is:
|
|
515
|
+
// 1. mouseover → setData (Vue patches slot props on the *child*) → position
|
|
516
|
+
// using lastTooltipSize (possibly stale by one frame) → visibility:visible
|
|
517
|
+
// 2. tooltipObserver fires when the slot DOM has actually committed → we
|
|
518
|
+
// refresh lastTooltipSize and re-apply the position if still visible.
|
|
519
|
+
// 3. mousemove → rAF-throttled direct DOM write of transform; no reactivity.
|
|
520
|
+
// 4. mouseout (leaving the map) → visibility:hidden.
|
|
521
|
+
//
|
|
522
|
+
// There is no `await` and no token: out-of-order completion is impossible
|
|
523
|
+
// because every step is synchronous from the event handler's perspective.
|
|
524
|
+
|
|
525
|
+
function attachTooltipObserver() {
|
|
526
|
+
const el = tooltipChildRef.value?.getEl();
|
|
527
|
+
if (!el) return;
|
|
528
|
+
tooltipObserver?.disconnect();
|
|
529
|
+
// First measurement bootstraps placement (the very first hover used the
|
|
530
|
+
// 0×0 fallback). After that we just silently refresh the cached size —
|
|
531
|
+
// every hover uses whatever was measured on the previous render, so
|
|
532
|
+
// switching between hover targets never causes the tooltip to re-flip
|
|
533
|
+
// mid-hover.
|
|
534
|
+
let primed = false;
|
|
535
|
+
tooltipObserver = new ResizeObserver((entries) => {
|
|
536
|
+
const r = entries[0]?.contentRect;
|
|
537
|
+
if (!r) return;
|
|
538
|
+
lastTooltipSize.width = r.width;
|
|
539
|
+
lastTooltipSize.height = r.height;
|
|
540
|
+
if (!primed && tooltipVisible && lastPointer) {
|
|
541
|
+
primed = true;
|
|
542
|
+
applyTooltipPosition(lastPointer.x, lastPointer.y);
|
|
543
|
+
} else {
|
|
544
|
+
primed = true;
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
tooltipObserver.observe(el);
|
|
427
548
|
}
|
|
428
549
|
|
|
429
|
-
function
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
tooltipEl.className = "chart-tooltip-content";
|
|
437
|
-
tooltipEl.style.position = "fixed";
|
|
438
|
-
tooltipEl.style.transform = "translateY(-50%)";
|
|
439
|
-
document.body.appendChild(tooltipEl);
|
|
440
|
-
}
|
|
441
|
-
const name = stateName(feat);
|
|
442
|
-
const value = stateValue(feat);
|
|
443
|
-
const data = { id: String(feat.id), name, value };
|
|
444
|
-
if (props.tooltipFormat) {
|
|
445
|
-
tooltipEl.innerHTML = props.tooltipFormat(data);
|
|
446
|
-
} else if (value == null) {
|
|
447
|
-
tooltipEl.textContent = name;
|
|
448
|
-
} else {
|
|
449
|
-
tooltipEl.textContent = `${name}: ${formatTooltipValue(value)}`;
|
|
450
|
-
}
|
|
550
|
+
function applyTooltipPosition(clientX: number, clientY: number) {
|
|
551
|
+
const el = tooltipChildRef.value?.getEl();
|
|
552
|
+
if (!el) return;
|
|
553
|
+
// Use the cached size — accurate after the first ResizeObserver tick. On
|
|
554
|
+
// the very first show before the observer has fired, this falls through
|
|
555
|
+
// placeTooltip's no-flip path (size 0 → no flip), which simply pins the
|
|
556
|
+
// tooltip to the right of the cursor.
|
|
451
557
|
const chartRect = containerRef.value?.getBoundingClientRect();
|
|
452
558
|
const { left, top } = placeTooltip(
|
|
453
559
|
clientX,
|
|
454
560
|
clientY,
|
|
455
|
-
|
|
456
|
-
|
|
561
|
+
lastTooltipSize.width,
|
|
562
|
+
lastTooltipSize.height,
|
|
457
563
|
props.tooltipClamp,
|
|
458
564
|
chartRect,
|
|
459
565
|
);
|
|
460
|
-
|
|
461
|
-
|
|
566
|
+
el.style.transform = `translate3d(${left}px, ${top}px, 0) translateY(-50%)`;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function showTooltip(featId: string, clientX: number, clientY: number) {
|
|
570
|
+
const data = tooltipDataById.get(featId);
|
|
571
|
+
if (!data) return;
|
|
572
|
+
const child = tooltipChildRef.value;
|
|
573
|
+
const el = child?.getEl();
|
|
574
|
+
if (!child || !el) return;
|
|
575
|
+
child.setData(data);
|
|
576
|
+
lastPointer = { x: clientX, y: clientY };
|
|
577
|
+
tooltipVisible = true;
|
|
578
|
+
applyTooltipPosition(clientX, clientY);
|
|
579
|
+
el.style.visibility = "visible";
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function moveTooltip(clientX: number, clientY: number) {
|
|
583
|
+
if (!tooltipVisible) return;
|
|
584
|
+
pendingMoveX = clientX;
|
|
585
|
+
pendingMoveY = clientY;
|
|
586
|
+
if (pendingMoveFrame) return;
|
|
587
|
+
pendingMoveFrame = requestAnimationFrame(() => {
|
|
588
|
+
pendingMoveFrame = 0;
|
|
589
|
+
const el = tooltipChildRef.value?.getEl();
|
|
590
|
+
if (!el || !tooltipVisible) return;
|
|
591
|
+
lastPointer = { x: pendingMoveX, y: pendingMoveY };
|
|
592
|
+
// Mid-hover: don't re-run flip/clamp on every pixel; just translate.
|
|
593
|
+
el.style.transform = `translate3d(${pendingMoveX + 16}px, ${pendingMoveY}px, 0) translateY(-50%)`;
|
|
594
|
+
});
|
|
462
595
|
}
|
|
463
596
|
|
|
464
597
|
function hideTooltip() {
|
|
465
|
-
if (
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
598
|
+
if (!tooltipVisible) return;
|
|
599
|
+
tooltipVisible = false;
|
|
600
|
+
lastPointer = null;
|
|
601
|
+
const el = tooltipChildRef.value?.getEl();
|
|
602
|
+
if (el) el.style.visibility = "hidden";
|
|
469
603
|
}
|
|
470
604
|
|
|
471
|
-
function setHover(
|
|
472
|
-
pathEl
|
|
473
|
-
|
|
474
|
-
) {
|
|
475
|
-
if (hoveredEl && hoveredEl !== pathEl) {
|
|
605
|
+
function setHover(pathEl: SVGPathElement) {
|
|
606
|
+
if (hoveredEl === pathEl) return;
|
|
607
|
+
if (hoveredEl) {
|
|
476
608
|
hoveredEl.setAttribute("stroke-width", String(effectiveStrokeWidth.value));
|
|
477
609
|
hoveredEl.setAttribute("stroke", props.strokeColor);
|
|
478
610
|
}
|
|
479
611
|
hoveredEl = pathEl;
|
|
612
|
+
// Bring hovered path to top so its thicker border is not clipped by neighbors.
|
|
480
613
|
pathEl.parentNode?.appendChild(pathEl);
|
|
481
614
|
pathEl.setAttribute("stroke-width", String(effectiveStrokeWidth.value + 1));
|
|
482
615
|
pathEl.setAttribute("stroke", "#555");
|
|
@@ -492,33 +625,35 @@ function clearHover() {
|
|
|
492
625
|
hideTooltip();
|
|
493
626
|
}
|
|
494
627
|
|
|
495
|
-
// Delegated event handlers (
|
|
628
|
+
// ─── Delegated event handlers (single set of listeners on the <g>) ───────
|
|
629
|
+
|
|
630
|
+
function eventToFeatureId(target: EventTarget | null): string | null {
|
|
631
|
+
let el = target as Element | null;
|
|
632
|
+
while (el && !(el as HTMLElement).dataset?.featId) el = el.parentElement;
|
|
633
|
+
return el ? ((el as HTMLElement).dataset.featId ?? null) : null;
|
|
634
|
+
}
|
|
635
|
+
|
|
496
636
|
function onDelegatedEvent(event: Event) {
|
|
497
637
|
if (isZooming) return;
|
|
498
638
|
const me = event as MouseEvent;
|
|
499
|
-
const
|
|
500
|
-
if (!
|
|
639
|
+
const featId = eventToFeatureId(me.target);
|
|
640
|
+
if (!featId) return;
|
|
641
|
+
const data = tooltipDataById.get(featId);
|
|
642
|
+
if (!data) return;
|
|
643
|
+
const payload = { id: data.id, name: data.name, value: data.value };
|
|
501
644
|
if (event.type === "click") {
|
|
502
|
-
emit("stateClick",
|
|
503
|
-
id: String(hit.feat.id),
|
|
504
|
-
name: stateName(hit.feat),
|
|
505
|
-
value: stateValue(hit.feat),
|
|
506
|
-
});
|
|
645
|
+
emit("stateClick", payload);
|
|
507
646
|
} else if (event.type === "mouseover") {
|
|
508
|
-
setHover(
|
|
509
|
-
if (
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
name: stateName(hit.feat),
|
|
513
|
-
value: stateValue(hit.feat),
|
|
514
|
-
});
|
|
647
|
+
setHover(pathsByFeatureId.get(featId)!);
|
|
648
|
+
if (hasInteractiveTooltip.value)
|
|
649
|
+
showTooltip(featId, me.clientX, me.clientY);
|
|
650
|
+
emit("stateHover", payload);
|
|
515
651
|
}
|
|
516
652
|
}
|
|
517
653
|
|
|
518
654
|
function onDelegatedMouseMove(event: MouseEvent) {
|
|
519
|
-
if (isZooming
|
|
520
|
-
|
|
521
|
-
tooltipEl.style.top = `${event.clientY}px`;
|
|
655
|
+
if (isZooming) return;
|
|
656
|
+
moveTooltip(event.clientX, event.clientY);
|
|
522
657
|
}
|
|
523
658
|
|
|
524
659
|
function onDelegatedMouseOut(event: MouseEvent) {
|
|
@@ -527,6 +662,109 @@ function onDelegatedMouseOut(event: MouseEvent) {
|
|
|
527
662
|
clearHover();
|
|
528
663
|
}
|
|
529
664
|
|
|
665
|
+
// ─── Imperative SVG path management ──────────────────────────────────────
|
|
666
|
+
//
|
|
667
|
+
// 3,000+ counties are too many to round-trip through Vue's render scheduler
|
|
668
|
+
// on every reactive change. We build the SVG path tree once per feature set
|
|
669
|
+
// and mutate attributes directly when data/styling changes.
|
|
670
|
+
|
|
671
|
+
function makePath(d: string | null): SVGPathElement {
|
|
672
|
+
const p = document.createElementNS(SVG_NS, "path") as SVGPathElement;
|
|
673
|
+
if (d) p.setAttribute("d", d);
|
|
674
|
+
return p;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function rebuildPaths() {
|
|
678
|
+
const g = mapGroupRef.value;
|
|
679
|
+
if (!g) return;
|
|
680
|
+
while (g.firstChild) g.removeChild(g.firstChild);
|
|
681
|
+
pathsByFeatureId.clear();
|
|
682
|
+
tooltipDataById.clear();
|
|
683
|
+
bordersPathEl = null;
|
|
684
|
+
hoveredEl = null;
|
|
685
|
+
|
|
686
|
+
const path = pathGenerator.value;
|
|
687
|
+
const features = featuresGeo.value.features;
|
|
688
|
+
const stroke = props.strokeColor;
|
|
689
|
+
const sw = String(effectiveStrokeWidth.value);
|
|
690
|
+
const wantsTitleFallback = !hasInteractiveTooltip.value;
|
|
691
|
+
|
|
692
|
+
// Single DocumentFragment append → one layout flush for the whole batch.
|
|
693
|
+
const frag = document.createDocumentFragment();
|
|
694
|
+
for (const feat of features) {
|
|
695
|
+
const id = String(feat.id);
|
|
696
|
+
const name = featureName(feat);
|
|
697
|
+
const value = dataMap.value.get(id);
|
|
698
|
+
const p = makePath(path(feat));
|
|
699
|
+
p.setAttribute("class", "state-path");
|
|
700
|
+
p.setAttribute("data-feat-id", id);
|
|
701
|
+
p.setAttribute("fill", colorFor(id));
|
|
702
|
+
p.setAttribute("stroke", stroke);
|
|
703
|
+
p.setAttribute("stroke-width", sw);
|
|
704
|
+
// Keep stroke width pixel-accurate regardless of how the browser scales
|
|
705
|
+
// the viewBox to fit the container — otherwise borders appear thicker
|
|
706
|
+
// as the map is enlarged.
|
|
707
|
+
p.setAttribute("vector-effect", "non-scaling-stroke");
|
|
708
|
+
if (wantsTitleFallback) {
|
|
709
|
+
const title = document.createElementNS(SVG_NS, "title");
|
|
710
|
+
title.textContent = titleText(name, value);
|
|
711
|
+
p.appendChild(title);
|
|
712
|
+
}
|
|
713
|
+
frag.appendChild(p);
|
|
714
|
+
pathsByFeatureId.set(id, p);
|
|
715
|
+
tooltipDataById.set(id, {
|
|
716
|
+
id,
|
|
717
|
+
name,
|
|
718
|
+
value,
|
|
719
|
+
feature: feat as ChoroplethFeature,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// State-borders overlay (counties / hsas mode).
|
|
724
|
+
const borders = stateBordersPath.value;
|
|
725
|
+
if (borders) {
|
|
726
|
+
const b = makePath(path(borders));
|
|
727
|
+
b.setAttribute("fill", "none");
|
|
728
|
+
b.setAttribute("stroke", stroke);
|
|
729
|
+
b.setAttribute("stroke-width", "1");
|
|
730
|
+
b.setAttribute("stroke-linejoin", "round");
|
|
731
|
+
b.setAttribute("pointer-events", "none");
|
|
732
|
+
b.setAttribute("vector-effect", "non-scaling-stroke");
|
|
733
|
+
frag.appendChild(b);
|
|
734
|
+
bordersPathEl = b;
|
|
735
|
+
}
|
|
736
|
+
g.appendChild(frag);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function updateFills() {
|
|
740
|
+
const refreshTitle = !hasInteractiveTooltip.value;
|
|
741
|
+
for (const [id, p] of pathsByFeatureId) {
|
|
742
|
+
const value = dataMap.value.get(id);
|
|
743
|
+
const entry = tooltipDataById.get(id);
|
|
744
|
+
p.setAttribute("fill", colorFor(id));
|
|
745
|
+
// Refresh cached tooltip payload so a later hover (or the SVG <title>
|
|
746
|
+
// fallback below) reflects the new value.
|
|
747
|
+
if (entry) entry.value = value;
|
|
748
|
+
if (refreshTitle && entry) {
|
|
749
|
+
// First child is the <title> appended in rebuildPaths when fallback
|
|
750
|
+
// mode is active.
|
|
751
|
+
const title = p.firstElementChild;
|
|
752
|
+
if (title) title.textContent = titleText(entry.name, value);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function updateStrokes() {
|
|
758
|
+
const stroke = props.strokeColor;
|
|
759
|
+
const sw = String(effectiveStrokeWidth.value);
|
|
760
|
+
for (const p of pathsByFeatureId.values()) {
|
|
761
|
+
if (p === hoveredEl) continue;
|
|
762
|
+
p.setAttribute("stroke", stroke);
|
|
763
|
+
p.setAttribute("stroke-width", sw);
|
|
764
|
+
}
|
|
765
|
+
if (bordersPathEl) bordersPathEl.setAttribute("stroke", stroke);
|
|
766
|
+
}
|
|
767
|
+
|
|
530
768
|
function menuFilename() {
|
|
531
769
|
return typeof props.menu === "string" ? props.menu : "choropleth";
|
|
532
770
|
}
|
|
@@ -540,14 +778,6 @@ const sortedThresholdStops = computed(() =>
|
|
|
540
778
|
(props.colorScale as ThresholdStop[]).slice().sort((a, b) => a.min - b.min),
|
|
541
779
|
);
|
|
542
780
|
|
|
543
|
-
const titleHeight = computed(() => (props.title ? 24 : 0));
|
|
544
|
-
const legendHeight = computed(() => (showLegend.value ? 28 : 0));
|
|
545
|
-
const topOffset = computed(() => titleHeight.value + legendHeight.value);
|
|
546
|
-
|
|
547
|
-
const svgHeight = computed(() => height.value + topOffset.value);
|
|
548
|
-
|
|
549
|
-
const legendY = computed(() => titleHeight.value + 18);
|
|
550
|
-
|
|
551
781
|
const gradientStops = computed(() => {
|
|
552
782
|
const steps = 10;
|
|
553
783
|
const result: { offset: string; color: string }[] = [];
|
|
@@ -561,6 +791,13 @@ const gradientStops = computed(() => {
|
|
|
561
791
|
return result;
|
|
562
792
|
});
|
|
563
793
|
|
|
794
|
+
// Compact formatter so legend ticks for large ranges (e.g. populations in
|
|
795
|
+
// the millions) don't render wide enough to collide with each other.
|
|
796
|
+
const compactTickFormat = new Intl.NumberFormat("en-US", {
|
|
797
|
+
notation: "compact",
|
|
798
|
+
maximumFractionDigits: 1,
|
|
799
|
+
});
|
|
800
|
+
|
|
564
801
|
const continuousTicks = computed(() => {
|
|
565
802
|
const { min, max } = extent.value;
|
|
566
803
|
const range = max - min;
|
|
@@ -569,9 +806,12 @@ const continuousTicks = computed(() => {
|
|
|
569
806
|
for (let i = 1; i <= count; i++) {
|
|
570
807
|
const t = i / (count + 1);
|
|
571
808
|
const v = min + range * t;
|
|
572
|
-
const formatted =
|
|
573
|
-
|
|
574
|
-
|
|
809
|
+
const formatted =
|
|
810
|
+
Math.abs(v) >= 1000
|
|
811
|
+
? compactTickFormat.format(v)
|
|
812
|
+
: Number.isInteger(v)
|
|
813
|
+
? String(v)
|
|
814
|
+
: v.toFixed(1).replace(/\.0$/, "");
|
|
575
815
|
ticks.push({ value: formatted, pct: t * 100 });
|
|
576
816
|
}
|
|
577
817
|
return ticks;
|
|
@@ -595,32 +835,13 @@ const discreteLegendItems = computed(() => {
|
|
|
595
835
|
return items;
|
|
596
836
|
});
|
|
597
837
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
return
|
|
605
|
-
});
|
|
606
|
-
|
|
607
|
-
const discreteLegendPositions = computed(() => {
|
|
608
|
-
const titleWidth = props.legendTitle ? props.legendTitle.length * 8 + 12 : 0;
|
|
609
|
-
let x = titleWidth;
|
|
610
|
-
return discreteLegendItems.value.map((item) => {
|
|
611
|
-
const pos = x;
|
|
612
|
-
x += 16 + item.label.length * 7 + 12;
|
|
613
|
-
return pos;
|
|
614
|
-
});
|
|
615
|
-
});
|
|
616
|
-
|
|
617
|
-
const legendXOffset = computed(() => {
|
|
618
|
-
if (isCategorical.value || isThreshold.value) {
|
|
619
|
-
return (width.value - discreteLegendTotalWidth.value) / 2;
|
|
620
|
-
}
|
|
621
|
-
const barWidth = 160;
|
|
622
|
-
const titleWidth = props.legendTitle ? props.legendTitle.length * 8 + 12 : 0;
|
|
623
|
-
return (width.value - titleWidth - barWidth) / 2;
|
|
838
|
+
// Linear-gradient CSS for the continuous legend bar, derived from the same
|
|
839
|
+
// stops the SVG version used.
|
|
840
|
+
const gradientCss = computed(() => {
|
|
841
|
+
const stops = gradientStops.value
|
|
842
|
+
.map((s) => `${s.color} ${s.offset}`)
|
|
843
|
+
.join(", ");
|
|
844
|
+
return `linear-gradient(to right, ${stops})`;
|
|
624
845
|
});
|
|
625
846
|
|
|
626
847
|
const menuItems = computed<ChartMenuItem[]>(() => {
|
|
@@ -640,145 +861,133 @@ const menuItems = computed<ChartMenuItem[]>(() => {
|
|
|
640
861
|
},
|
|
641
862
|
];
|
|
642
863
|
});
|
|
864
|
+
|
|
865
|
+
// ─── Reactive triggers for the imperative SVG tree ───────────────────────
|
|
866
|
+
// Registered last so the eagerly-evaluated source getters can read every
|
|
867
|
+
// computed defined above without hitting a TDZ.
|
|
868
|
+
|
|
869
|
+
// Geometry / projection / tooltip-mode → full rebuild.
|
|
870
|
+
watch(
|
|
871
|
+
() => [pathGenerator.value, hasInteractiveTooltip.value],
|
|
872
|
+
() => rebuildPaths(),
|
|
873
|
+
);
|
|
874
|
+
|
|
875
|
+
// Data or scale → repaint fills (and refresh fallback <title>s).
|
|
876
|
+
watch(
|
|
877
|
+
() => [dataMap.value, props.colorScale, props.noDataColor],
|
|
878
|
+
() => updateFills(),
|
|
879
|
+
);
|
|
880
|
+
|
|
881
|
+
// Stroke styling → refresh stroke attrs (skipping the currently hovered path).
|
|
882
|
+
watch(
|
|
883
|
+
() => [props.strokeColor, effectiveStrokeWidth.value],
|
|
884
|
+
() => updateStrokes(),
|
|
885
|
+
);
|
|
643
886
|
</script>
|
|
644
887
|
|
|
645
888
|
<template>
|
|
646
889
|
<div ref="containerRef" :class="['choropleth-wrapper', { pannable: pan }]">
|
|
647
890
|
<ChartMenu v-if="menu" :items="menuItems" />
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
>
|
|
660
|
-
<title v-if="!tooltipTrigger">
|
|
661
|
-
{{ stateName(feat)
|
|
662
|
-
}}{{
|
|
663
|
-
stateValue(feat) != null
|
|
664
|
-
? `: ${formatTooltipValue(stateValue(feat))}`
|
|
665
|
-
: ""
|
|
666
|
-
}}
|
|
667
|
-
</title>
|
|
668
|
-
</path>
|
|
669
|
-
<path
|
|
670
|
-
v-if="stateBordersPath"
|
|
671
|
-
:d="pathGenerator(stateBordersPath) ?? undefined"
|
|
672
|
-
fill="none"
|
|
673
|
-
:stroke="strokeColor"
|
|
674
|
-
:stroke-width="1"
|
|
675
|
-
stroke-linejoin="round"
|
|
676
|
-
pointer-events="none"
|
|
677
|
-
/>
|
|
678
|
-
</g>
|
|
679
|
-
<!-- Legend -->
|
|
680
|
-
<g
|
|
681
|
-
v-if="showLegend"
|
|
682
|
-
class="choropleth-legend"
|
|
683
|
-
:transform="`translate(${legendXOffset},${legendY})`"
|
|
684
|
-
>
|
|
685
|
-
<!-- Categorical or Threshold: dots with labels -->
|
|
891
|
+
<!--
|
|
892
|
+
Title + legend live as an HTML overlay on top of the SVG so they keep
|
|
893
|
+
their intrinsic px sizes regardless of how the browser scales the
|
|
894
|
+
viewBox to fit the container.
|
|
895
|
+
-->
|
|
896
|
+
<div v-if="title || showLegend" class="choropleth-header">
|
|
897
|
+
<div v-if="title" class="choropleth-title">{{ title }}</div>
|
|
898
|
+
<div v-if="showLegend" class="choropleth-legend">
|
|
899
|
+
<span v-if="legendTitle" class="choropleth-legend-title">
|
|
900
|
+
{{ legendTitle }}
|
|
901
|
+
</span>
|
|
686
902
|
<template v-if="isCategorical || isThreshold">
|
|
687
|
-
<
|
|
688
|
-
v-
|
|
689
|
-
:
|
|
690
|
-
|
|
691
|
-
font-weight="600"
|
|
692
|
-
fill="currentColor"
|
|
903
|
+
<span
|
|
904
|
+
v-for="item in discreteLegendItems"
|
|
905
|
+
:key="item.key"
|
|
906
|
+
class="choropleth-legend-item"
|
|
693
907
|
>
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
<rect
|
|
698
|
-
:x="discreteLegendPositions[i]"
|
|
699
|
-
:y="-5"
|
|
700
|
-
width="12"
|
|
701
|
-
height="12"
|
|
702
|
-
rx="3"
|
|
703
|
-
:fill="item.color"
|
|
908
|
+
<span
|
|
909
|
+
class="choropleth-legend-swatch"
|
|
910
|
+
:style="{ background: item.color }"
|
|
704
911
|
/>
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
:y="5"
|
|
708
|
-
font-size="13"
|
|
709
|
-
fill="currentColor"
|
|
710
|
-
>
|
|
711
|
-
{{ item.label }}
|
|
712
|
-
</text>
|
|
713
|
-
</template>
|
|
912
|
+
{{ item.label }}
|
|
913
|
+
</span>
|
|
714
914
|
</template>
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
:y="5"
|
|
720
|
-
font-size="13"
|
|
721
|
-
font-weight="600"
|
|
722
|
-
fill="currentColor"
|
|
723
|
-
>
|
|
724
|
-
{{ legendTitle }}
|
|
725
|
-
</text>
|
|
726
|
-
<defs>
|
|
727
|
-
<linearGradient :id="gradientId" x1="0" x2="1" y1="0" y2="0">
|
|
728
|
-
<stop
|
|
729
|
-
v-for="s in gradientStops"
|
|
730
|
-
:key="s.offset"
|
|
731
|
-
:offset="s.offset"
|
|
732
|
-
:stop-color="s.color"
|
|
733
|
-
/>
|
|
734
|
-
</linearGradient>
|
|
735
|
-
</defs>
|
|
736
|
-
<rect
|
|
737
|
-
:x="legendTitle ? legendTitle.length * 8 + 12 : 0"
|
|
738
|
-
:y="-6"
|
|
739
|
-
:width="160"
|
|
740
|
-
:height="12"
|
|
741
|
-
rx="2"
|
|
742
|
-
:fill="`url(#${gradientId})`"
|
|
915
|
+
<div v-else class="choropleth-legend-continuous">
|
|
916
|
+
<div
|
|
917
|
+
class="choropleth-legend-gradient"
|
|
918
|
+
:style="{ background: gradientCss }"
|
|
743
919
|
/>
|
|
744
|
-
<
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
fill="currentColor"
|
|
769
|
-
>
|
|
770
|
-
{{ title }}
|
|
771
|
-
</text>
|
|
920
|
+
<div class="choropleth-legend-ticks">
|
|
921
|
+
<span
|
|
922
|
+
v-for="tick in continuousTicks"
|
|
923
|
+
:key="tick.value"
|
|
924
|
+
:style="{ left: tick.pct + '%' }"
|
|
925
|
+
>
|
|
926
|
+
{{ tick.value }}
|
|
927
|
+
</span>
|
|
928
|
+
</div>
|
|
929
|
+
</div>
|
|
930
|
+
</div>
|
|
931
|
+
</div>
|
|
932
|
+
<svg
|
|
933
|
+
ref="svgRef"
|
|
934
|
+
:viewBox="`0 0 ${width} ${height}`"
|
|
935
|
+
preserveAspectRatio="xMidYMid meet"
|
|
936
|
+
>
|
|
937
|
+
<!--
|
|
938
|
+
Path elements are created imperatively in `rebuildPaths()`; Vue never
|
|
939
|
+
diffs the per-feature subtree so reactive state changes don't walk
|
|
940
|
+
thousands of vnodes. This <g> is the mount point + event delegation
|
|
941
|
+
target.
|
|
942
|
+
-->
|
|
943
|
+
<g ref="mapGroupRef" />
|
|
772
944
|
</svg>
|
|
945
|
+
<button
|
|
946
|
+
v-if="(zoom || pan) && isZoomed"
|
|
947
|
+
type="button"
|
|
948
|
+
class="choropleth-reset"
|
|
949
|
+
aria-label="Reset zoom"
|
|
950
|
+
@click="resetZoom"
|
|
951
|
+
>
|
|
952
|
+
Reset
|
|
953
|
+
</button>
|
|
954
|
+
<ChoroplethTooltip v-if="hasInteractiveTooltip" ref="tooltipChildRef">
|
|
955
|
+
<template #default="raw">
|
|
956
|
+
<slot name="tooltip" v-bind="narrowSlotProps(raw)">
|
|
957
|
+
<span v-if="tooltipFormat" v-html="tooltipFormat(raw)" />
|
|
958
|
+
<template v-else-if="raw.value == null">{{ raw.name }}</template>
|
|
959
|
+
<template v-else>
|
|
960
|
+
{{ raw.name }}: {{ formatTooltipValue(raw.value) }}
|
|
961
|
+
</template>
|
|
962
|
+
</slot>
|
|
963
|
+
</template>
|
|
964
|
+
</ChoroplethTooltip>
|
|
773
965
|
</div>
|
|
774
966
|
</template>
|
|
775
967
|
|
|
776
968
|
<style scoped>
|
|
777
969
|
.choropleth-wrapper {
|
|
970
|
+
/*
|
|
971
|
+
* Override at the consumer level to change the legend/title panel fill:
|
|
972
|
+
* .my-map { --choropleth-legend-bg: rgba(0, 0, 0, 0.6); }
|
|
973
|
+
* Defaults to the theme's page background so the panel reads as a
|
|
974
|
+
* floating extension of the page surface.
|
|
975
|
+
*/
|
|
976
|
+
--choropleth-legend-bg: var(--color-bg-0, #fff);
|
|
977
|
+
|
|
778
978
|
position: relative;
|
|
779
979
|
width: 100%;
|
|
780
980
|
}
|
|
781
981
|
|
|
982
|
+
.choropleth-wrapper svg {
|
|
983
|
+
display: block;
|
|
984
|
+
/* Fluid scaling via viewBox: the SVG fills its container's width and the
|
|
985
|
+
* browser derives height from the viewBox aspect ratio. Overridden when
|
|
986
|
+
* `props.width` / `props.height` are explicitly set on the component. */
|
|
987
|
+
width: 100%;
|
|
988
|
+
height: auto;
|
|
989
|
+
}
|
|
990
|
+
|
|
782
991
|
.choropleth-wrapper.pannable svg {
|
|
783
992
|
cursor: grab;
|
|
784
993
|
}
|
|
@@ -794,4 +1003,101 @@ const menuItems = computed<ChartMenuItem[]>(() => {
|
|
|
794
1003
|
.state-path {
|
|
795
1004
|
cursor: pointer;
|
|
796
1005
|
}
|
|
1006
|
+
|
|
1007
|
+
.choropleth-reset {
|
|
1008
|
+
position: absolute;
|
|
1009
|
+
bottom: 8px;
|
|
1010
|
+
left: 8px;
|
|
1011
|
+
padding: 4px 10px;
|
|
1012
|
+
font: inherit;
|
|
1013
|
+
font-size: 12px;
|
|
1014
|
+
color: var(--color-text-secondary, #555);
|
|
1015
|
+
background: var(--color-bg-0, #fff);
|
|
1016
|
+
border: 1px solid var(--color-border, #e5e7eb);
|
|
1017
|
+
border-radius: 4px;
|
|
1018
|
+
cursor: pointer;
|
|
1019
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
.choropleth-reset:hover {
|
|
1023
|
+
background: var(--color-bg-1, #f8f9fa);
|
|
1024
|
+
color: var(--color-text, #212529);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/*
|
|
1028
|
+
* Title + legend overlay. Lives in HTML so its sizes are independent of
|
|
1029
|
+
* the SVG viewBox scaling — text stays at its declared px size at any
|
|
1030
|
+
* container width.
|
|
1031
|
+
*/
|
|
1032
|
+
.choropleth-header {
|
|
1033
|
+
/*
|
|
1034
|
+
* In-flow above the map — the map gets its full canvas, no overlap to
|
|
1035
|
+
* worry about. Centered via `width: fit-content` + `margin: auto`.
|
|
1036
|
+
*/
|
|
1037
|
+
display: flex;
|
|
1038
|
+
flex-direction: column;
|
|
1039
|
+
align-items: center;
|
|
1040
|
+
gap: 10px;
|
|
1041
|
+
width: fit-content;
|
|
1042
|
+
margin: 0 auto;
|
|
1043
|
+
padding: 8px 14px;
|
|
1044
|
+
border-radius: 4px;
|
|
1045
|
+
background: var(--choropleth-legend-bg);
|
|
1046
|
+
color: currentColor;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
.choropleth-title {
|
|
1050
|
+
font-size: 14px;
|
|
1051
|
+
font-weight: 600;
|
|
1052
|
+
line-height: 1.2;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
.choropleth-legend {
|
|
1056
|
+
display: flex;
|
|
1057
|
+
align-items: center;
|
|
1058
|
+
gap: 14px;
|
|
1059
|
+
font-size: 13px;
|
|
1060
|
+
line-height: 1.2;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
.choropleth-legend-title {
|
|
1064
|
+
font-weight: 600;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
.choropleth-legend-item {
|
|
1068
|
+
display: inline-flex;
|
|
1069
|
+
align-items: center;
|
|
1070
|
+
gap: 6px;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
.choropleth-legend-swatch {
|
|
1074
|
+
width: 12px;
|
|
1075
|
+
height: 12px;
|
|
1076
|
+
border-radius: 3px;
|
|
1077
|
+
display: inline-block;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
.choropleth-legend-continuous {
|
|
1081
|
+
display: flex;
|
|
1082
|
+
flex-direction: column;
|
|
1083
|
+
width: 160px;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
.choropleth-legend-gradient {
|
|
1087
|
+
height: 12px;
|
|
1088
|
+
border-radius: 2px;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
.choropleth-legend-ticks {
|
|
1092
|
+
position: relative;
|
|
1093
|
+
height: 14px;
|
|
1094
|
+
margin-top: 4px;
|
|
1095
|
+
font-size: 11px;
|
|
1096
|
+
opacity: 0.7;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
.choropleth-legend-ticks > span {
|
|
1100
|
+
position: absolute;
|
|
1101
|
+
transform: translateX(-50%);
|
|
1102
|
+
}
|
|
797
1103
|
</style>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, useTemplateRef } from "vue";
|
|
3
|
+
|
|
4
|
+
export interface ChoroplethTooltipData {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
value?: number | string;
|
|
8
|
+
feature: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
defineSlots<{
|
|
12
|
+
default?(props: ChoroplethTooltipData): unknown;
|
|
13
|
+
}>();
|
|
14
|
+
|
|
15
|
+
// Local reactive state. Held inside the child so the parent's render scope
|
|
16
|
+
// never subscribes to it — hover updates re-render only this small tree,
|
|
17
|
+
// not the parent's 3,000+ paths.
|
|
18
|
+
const data = ref<ChoroplethTooltipData | null>(null);
|
|
19
|
+
const rootRef = useTemplateRef<HTMLDivElement>("root");
|
|
20
|
+
|
|
21
|
+
defineExpose({
|
|
22
|
+
setData(next: ChoroplethTooltipData | null) {
|
|
23
|
+
data.value = next;
|
|
24
|
+
},
|
|
25
|
+
getEl(): HTMLDivElement | null {
|
|
26
|
+
return rootRef.value;
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<template>
|
|
32
|
+
<Teleport to="body">
|
|
33
|
+
<div
|
|
34
|
+
ref="root"
|
|
35
|
+
class="chart-tooltip-content"
|
|
36
|
+
style="
|
|
37
|
+
position: fixed;
|
|
38
|
+
left: 0;
|
|
39
|
+
top: 0;
|
|
40
|
+
visibility: hidden;
|
|
41
|
+
will-change: transform;
|
|
42
|
+
pointer-events: none;
|
|
43
|
+
transform: translateY(-50%);
|
|
44
|
+
"
|
|
45
|
+
>
|
|
46
|
+
<slot v-if="data" v-bind="data" />
|
|
47
|
+
</div>
|
|
48
|
+
</Teleport>
|
|
49
|
+
</template>
|
package/index.json
CHANGED