@cfasim-ui/docs 0.4.1 → 0.4.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.
|
@@ -5,12 +5,15 @@ 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
|
+
// Side-effect import: enables `selection.transition()` on d3 selections so
|
|
15
|
+
// `applyFocus` can animate the zoom transform.
|
|
16
|
+
import "d3-transition";
|
|
14
17
|
import { feature, mesh, merge } from "topojson-client";
|
|
15
18
|
import type { Topology, GeometryCollection } from "topojson-specification";
|
|
16
19
|
import { fipsToHsa, hsaNames } from "./hsaMapping.js";
|
|
@@ -18,6 +21,9 @@ import ChartMenu from "../ChartMenu/ChartMenu.vue";
|
|
|
18
21
|
import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
|
|
19
22
|
import { saveSvg, savePng } from "../ChartMenu/download.js";
|
|
20
23
|
import { placeTooltip } from "../tooltip-position.js";
|
|
24
|
+
import ChoroplethTooltip from "./ChoroplethTooltip.vue";
|
|
25
|
+
|
|
26
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
21
27
|
|
|
22
28
|
export type GeoType = "states" | "counties" | "hsas";
|
|
23
29
|
|
|
@@ -76,7 +82,12 @@ const props = withDefaults(
|
|
|
76
82
|
pan?: boolean;
|
|
77
83
|
/** Tooltip activation mode */
|
|
78
84
|
tooltipTrigger?: "hover" | "click";
|
|
79
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* @deprecated Use the `#tooltip` slot instead, which gives you full Vue
|
|
87
|
+
* rendering (components, scoped styles, reactivity). This HTML-string
|
|
88
|
+
* formatter is kept for backwards compatibility and will be removed in a
|
|
89
|
+
* future release.
|
|
90
|
+
*/
|
|
80
91
|
tooltipFormat?: (data: {
|
|
81
92
|
id: string;
|
|
82
93
|
name: string;
|
|
@@ -94,6 +105,18 @@ const props = withDefaults(
|
|
|
94
105
|
* container's bounding box. `"window"` uses the viewport.
|
|
95
106
|
*/
|
|
96
107
|
tooltipClamp?: "none" | "chart" | "window";
|
|
108
|
+
/**
|
|
109
|
+
* Feature id(s) (FIPS code, HSA code, or feature name) to pan/zoom to.
|
|
110
|
+
* Pass `null` or an empty array to clear. Works with `v-model:focus`:
|
|
111
|
+
* clicking an unfocused feature emits its id; clicking a focused
|
|
112
|
+
* feature emits `null` (toggle off). Users can pan/zoom away from the
|
|
113
|
+
* focused area even when `zoom` and `pan` are disabled, and the
|
|
114
|
+
* built-in Reset button also clears focus. If a tooltip is configured,
|
|
115
|
+
* focusing a feature shows its tooltip.
|
|
116
|
+
*/
|
|
117
|
+
focus?: string | string[] | null;
|
|
118
|
+
/** Scale factor applied when `focus` is set. Default: 4 */
|
|
119
|
+
focusZoomLevel?: number;
|
|
97
120
|
}>(),
|
|
98
121
|
{
|
|
99
122
|
geoType: "states",
|
|
@@ -105,6 +128,7 @@ const props = withDefaults(
|
|
|
105
128
|
zoom: false,
|
|
106
129
|
pan: false,
|
|
107
130
|
tooltipClamp: "chart",
|
|
131
|
+
focusZoomLevel: 4,
|
|
108
132
|
},
|
|
109
133
|
);
|
|
110
134
|
|
|
@@ -117,23 +141,74 @@ const emit = defineEmits<{
|
|
|
117
141
|
e: "stateHover",
|
|
118
142
|
state: { id: string; name: string; value?: number | string } | null,
|
|
119
143
|
): void;
|
|
144
|
+
(e: "update:focus", focus: string | null): void;
|
|
145
|
+
}>();
|
|
146
|
+
|
|
147
|
+
type ChoroplethFeature = GeoJSON.Feature<
|
|
148
|
+
GeoJSON.Geometry | null,
|
|
149
|
+
{ name?: string }
|
|
150
|
+
>;
|
|
151
|
+
|
|
152
|
+
/** Public payload shape — slot props, hover/click emits, tooltip cache. */
|
|
153
|
+
interface TooltipPayload {
|
|
154
|
+
id: string;
|
|
155
|
+
name: string;
|
|
156
|
+
value?: number | string;
|
|
157
|
+
feature: ChoroplethFeature;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
defineSlots<{
|
|
161
|
+
tooltip?(props: TooltipPayload): unknown;
|
|
120
162
|
}>();
|
|
121
163
|
|
|
122
|
-
|
|
123
|
-
|
|
164
|
+
// The child types `feature` as `unknown` (it has no map-specific knowledge);
|
|
165
|
+
// we always store a ChoroplethFeature, so narrow it back at the single point
|
|
166
|
+
// where we forward the slot.
|
|
167
|
+
const narrowSlotProps = (
|
|
168
|
+
raw: { feature: unknown } & Omit<TooltipPayload, "feature">,
|
|
169
|
+
): TooltipPayload => raw as TooltipPayload;
|
|
170
|
+
|
|
124
171
|
const containerRef = ref<HTMLElement | null>(null);
|
|
125
172
|
const svgRef = ref<SVGSVGElement | null>(null);
|
|
126
173
|
const mapGroupRef = ref<SVGGElement | null>(null);
|
|
127
|
-
const
|
|
174
|
+
const tooltipChildRef = ref<InstanceType<typeof ChoroplethTooltip> | null>(
|
|
175
|
+
null,
|
|
176
|
+
);
|
|
177
|
+
const slots = useSlots();
|
|
178
|
+
// Slot/prop presence doesn't change at runtime, so this is effectively
|
|
179
|
+
// computed once. Used to gate the teleported tooltip and the SVG <title>
|
|
180
|
+
// fallback.
|
|
181
|
+
const hasInteractiveTooltip = computed(
|
|
182
|
+
() => !!props.tooltipTrigger || !!props.tooltipFormat || !!slots.tooltip,
|
|
183
|
+
);
|
|
184
|
+
// Imperative path bookkeeping. Plain Maps rather than refs — Vue never reads
|
|
185
|
+
// these from a render scope, so mutating them does not trigger re-renders.
|
|
186
|
+
const pathsByFeatureId = new Map<string, SVGPathElement>();
|
|
187
|
+
const tooltipDataById = new Map<string, TooltipPayload>();
|
|
188
|
+
let bordersPathEl: SVGPathElement | null = null;
|
|
128
189
|
let hoveredEl: SVGPathElement | null = null;
|
|
129
|
-
|
|
190
|
+
// Paths currently styled as focused. Tracked separately from hover so the
|
|
191
|
+
// two states compose: hovering a focused path keeps the highlight on
|
|
192
|
+
// un-hover, and clearing focus while still hovering keeps the hover style.
|
|
193
|
+
const focusedPathEls = new Set<SVGPathElement>();
|
|
130
194
|
let isZooming = false;
|
|
131
195
|
// TODO: map hover/tooltip causes performance issues on mobile (SVG stroke-width
|
|
132
196
|
// changes + compositing layers degrade zoom/pan). Disabled on touch devices.
|
|
133
197
|
const isTouchDevice = typeof window !== "undefined" && "ontouchstart" in window;
|
|
134
|
-
let
|
|
198
|
+
let tooltipObserver: ResizeObserver | null = null;
|
|
199
|
+
const lastTooltipSize = { width: 0, height: 0 };
|
|
200
|
+
let lastPointer: { x: number; y: number } | null = null;
|
|
201
|
+
let tooltipVisible = false;
|
|
135
202
|
let zoomBehavior: ReturnType<typeof d3Zoom<SVGSVGElement, unknown>> | null =
|
|
136
203
|
null;
|
|
204
|
+
// True once the user has zoomed or panned away from the identity transform.
|
|
205
|
+
// Drives the visibility of the reset button.
|
|
206
|
+
const isZoomed = ref(false);
|
|
207
|
+
// rAF-throttled cursor coords for moveTooltip; we coalesce many mousemove
|
|
208
|
+
// events into one transform write per animation frame.
|
|
209
|
+
let pendingMoveX = 0;
|
|
210
|
+
let pendingMoveY = 0;
|
|
211
|
+
let pendingMoveFrame = 0;
|
|
137
212
|
|
|
138
213
|
function setupInteraction() {
|
|
139
214
|
if (isTouchDevice) return;
|
|
@@ -154,33 +229,47 @@ function teardownInteraction() {
|
|
|
154
229
|
g.removeEventListener("mouseout", onDelegatedMouseOut);
|
|
155
230
|
}
|
|
156
231
|
|
|
232
|
+
// Scroll / resize don't reliably emit mouseout on the underlying path even
|
|
233
|
+
// though the cursor's relationship to the map has changed — the tooltip
|
|
234
|
+
// would otherwise get stuck at its old `position: fixed` coordinates.
|
|
235
|
+
function dismissOnViewportChange() {
|
|
236
|
+
clearHover();
|
|
237
|
+
}
|
|
238
|
+
|
|
157
239
|
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
240
|
setupZoom();
|
|
167
241
|
setupInteraction();
|
|
242
|
+
rebuildPaths();
|
|
243
|
+
applyFocus();
|
|
244
|
+
attachTooltipObserver();
|
|
245
|
+
window.addEventListener("scroll", dismissOnViewportChange, {
|
|
246
|
+
passive: true,
|
|
247
|
+
capture: true,
|
|
248
|
+
});
|
|
249
|
+
window.addEventListener("resize", dismissOnViewportChange, { passive: true });
|
|
168
250
|
});
|
|
169
251
|
|
|
170
252
|
onUnmounted(() => {
|
|
171
|
-
|
|
253
|
+
tooltipObserver?.disconnect();
|
|
254
|
+
if (pendingMoveFrame) cancelAnimationFrame(pendingMoveFrame);
|
|
172
255
|
teardownZoom();
|
|
173
256
|
teardownInteraction();
|
|
174
|
-
|
|
257
|
+
window.removeEventListener("scroll", dismissOnViewportChange, {
|
|
258
|
+
capture: true,
|
|
259
|
+
});
|
|
260
|
+
window.removeEventListener("resize", dismissOnViewportChange);
|
|
175
261
|
});
|
|
176
262
|
|
|
177
263
|
function setupZoom() {
|
|
178
264
|
if (!svgRef.value || !mapGroupRef.value) return;
|
|
179
|
-
if (!props.zoom && !props.pan) return;
|
|
180
265
|
|
|
181
266
|
const svg = select(svgRef.value);
|
|
267
|
+
// Always span focusZoomLevel and at least the standard 12× ceiling so the
|
|
268
|
+
// user can wheel further in/out of a focused view. Programmatic
|
|
269
|
+
// `.transform()` calls are clamped to this range too.
|
|
270
|
+
const maxScale = Math.max(12, props.focusZoomLevel);
|
|
182
271
|
zoomBehavior = d3Zoom<SVGSVGElement, unknown>()
|
|
183
|
-
.scaleExtent(
|
|
272
|
+
.scaleExtent([1, maxScale])
|
|
184
273
|
.on("start", () => {
|
|
185
274
|
isZooming = true;
|
|
186
275
|
clearHover();
|
|
@@ -189,16 +278,32 @@ function setupZoom() {
|
|
|
189
278
|
if (mapGroupRef.value) {
|
|
190
279
|
mapGroupRef.value.setAttribute("transform", event.transform);
|
|
191
280
|
}
|
|
281
|
+
const t = event.transform;
|
|
282
|
+
isZoomed.value = t.k !== 1 || t.x !== 0 || t.y !== 0;
|
|
192
283
|
})
|
|
193
284
|
.on("end", () => {
|
|
194
285
|
isZooming = false;
|
|
195
286
|
});
|
|
196
287
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
288
|
+
// Dynamic filter: re-evaluated per event, so toggling `focus`,
|
|
289
|
+
// `zoom`, or `pan` doesn't require tearing down the zoom behavior.
|
|
290
|
+
// When focus is active we always allow drag + wheel so users can
|
|
291
|
+
// explore away from the focused area regardless of `zoom`/`pan`.
|
|
292
|
+
// Programmatic `.transform()` calls bypass this filter entirely.
|
|
293
|
+
zoomBehavior.filter((event) => {
|
|
294
|
+
const focused = normalizedFocus.value.length > 0;
|
|
295
|
+
const allowZoom = !!props.zoom || focused;
|
|
296
|
+
const allowPan = !!props.pan || focused;
|
|
297
|
+
if (event.type === "wheel" || event.type === "dblclick") {
|
|
298
|
+
if (!allowZoom) return false;
|
|
299
|
+
} else if (event.type === "mousedown" || event.type === "touchstart") {
|
|
300
|
+
if (!allowPan) return false;
|
|
301
|
+
} else if (!allowZoom && !allowPan) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
// Mirror d3-zoom's default rejections (ctrl-click, non-primary buttons).
|
|
305
|
+
return (!event.ctrlKey || event.type === "wheel") && !event.button;
|
|
306
|
+
});
|
|
202
307
|
|
|
203
308
|
svg.call(zoomBehavior);
|
|
204
309
|
}
|
|
@@ -210,22 +315,168 @@ function teardownZoom() {
|
|
|
210
315
|
}
|
|
211
316
|
}
|
|
212
317
|
|
|
318
|
+
// Resolve user-facing focus identifiers (FIPS, HSA codes, or feature names)
|
|
319
|
+
// to canonical feature ids. Used both for highlighting/zoom and for the
|
|
320
|
+
// click-to-toggle "is this feature currently focused?" check.
|
|
321
|
+
function resolveFocusIds(rawIds: string[]): Set<string> {
|
|
322
|
+
const byId = featuresById.value;
|
|
323
|
+
const nameIdx = nameToFeatureId.value;
|
|
324
|
+
const out = new Set<string>();
|
|
325
|
+
for (const raw of rawIds) {
|
|
326
|
+
const id = byId.has(raw) ? raw : nameIdx.get(raw);
|
|
327
|
+
if (id != null) out.add(id);
|
|
328
|
+
}
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function resolveFocusFeatures(rawIds: string[]): ChoroplethFeature[] {
|
|
333
|
+
const byId = featuresById.value;
|
|
334
|
+
const out: ChoroplethFeature[] = [];
|
|
335
|
+
for (const id of resolveFocusIds(rawIds)) {
|
|
336
|
+
const f = byId.get(id);
|
|
337
|
+
if (f) out.push(f);
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Duration of the focus zoom transition (ms). Initial mount and explicit
|
|
343
|
+
// "clear focus" still snap instantly; only focus-prop changes animate.
|
|
344
|
+
const FOCUS_ANIM_MS = 450;
|
|
345
|
+
// Tracks whether applyFocus has been called once — initial mount apply
|
|
346
|
+
// is instant, subsequent updates animate.
|
|
347
|
+
let focusApplied = false;
|
|
348
|
+
|
|
349
|
+
function applyFocus() {
|
|
350
|
+
if (!svgRef.value || !zoomBehavior) return;
|
|
351
|
+
const ids = normalizedFocus.value;
|
|
352
|
+
const features = ids.length > 0 ? resolveFocusFeatures(ids) : [];
|
|
353
|
+
|
|
354
|
+
// Compute the new highlight set first so we can diff against the
|
|
355
|
+
// previous one without re-resolving twice.
|
|
356
|
+
const nextFocused = new Set<SVGPathElement>();
|
|
357
|
+
for (const f of features) {
|
|
358
|
+
const p = pathsByFeatureId.get(String(f.id));
|
|
359
|
+
if (p) nextFocused.add(p);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Restore strokes on paths that are no longer focused. Skip those still
|
|
363
|
+
// hovered — hover keeps its own highlight.
|
|
364
|
+
for (const p of focusedPathEls) {
|
|
365
|
+
if (nextFocused.has(p) || p === hoveredEl) continue;
|
|
366
|
+
restoreDefaultStroke(p);
|
|
367
|
+
}
|
|
368
|
+
// Apply highlight to newly-focused paths (skip those already hovered:
|
|
369
|
+
// hover style is visually identical, no DOM churn needed).
|
|
370
|
+
for (const p of nextFocused) {
|
|
371
|
+
if (!focusedPathEls.has(p) && p !== hoveredEl) applyHighlightStroke(p);
|
|
372
|
+
}
|
|
373
|
+
focusedPathEls.clear();
|
|
374
|
+
for (const p of nextFocused) focusedPathEls.add(p);
|
|
375
|
+
|
|
376
|
+
const svg = select(svgRef.value);
|
|
377
|
+
// Always cancel any in-flight transition first — d3-transition queues
|
|
378
|
+
// same-named transitions rather than replacing them, so rapid focus
|
|
379
|
+
// changes would otherwise chain animations end-to-end. Also lets a
|
|
380
|
+
// straight-snap path actually take effect mid-animation.
|
|
381
|
+
svg.interrupt();
|
|
382
|
+
// First apply (initial mount) is instant. Only zoom-IN animates;
|
|
383
|
+
// clearing snaps back. Matches resetZoom's instant feel.
|
|
384
|
+
const animate = focusApplied && features.length > 0;
|
|
385
|
+
focusApplied = true;
|
|
386
|
+
|
|
387
|
+
if (features.length === 0) {
|
|
388
|
+
zoomBehavior.transform(svg, zoomIdentity);
|
|
389
|
+
clearHover();
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Compute pan + scale onto the focused features' bounding box, in
|
|
394
|
+
// viewBox (canonical) coordinates.
|
|
395
|
+
const [[x0, y0], [x1, y1]] = pathGenerator.value.bounds({
|
|
396
|
+
type: "FeatureCollection",
|
|
397
|
+
features,
|
|
398
|
+
});
|
|
399
|
+
const cx = (x0 + x1) / 2;
|
|
400
|
+
const cy = (y0 + y1) / 2;
|
|
401
|
+
const k = props.focusZoomLevel;
|
|
402
|
+
const target = zoomIdentity
|
|
403
|
+
.translate(width.value / 2 - k * cx, height.value / 2 - k * cy)
|
|
404
|
+
.scale(k);
|
|
405
|
+
|
|
406
|
+
const showFocusTooltip = () => {
|
|
407
|
+
if (!hasInteractiveTooltip.value) return;
|
|
408
|
+
const firstId = String(features[0].id);
|
|
409
|
+
const pathEl = pathsByFeatureId.get(firstId);
|
|
410
|
+
if (!pathEl) return;
|
|
411
|
+
// Read the rect *after* the transform commits so the tooltip lands at
|
|
412
|
+
// the focused feature's on-screen position.
|
|
413
|
+
const rect = pathEl.getBoundingClientRect();
|
|
414
|
+
showTooltip(
|
|
415
|
+
firstId,
|
|
416
|
+
rect.left + rect.width / 2,
|
|
417
|
+
rect.top + rect.height / 2,
|
|
418
|
+
);
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
if (animate) {
|
|
422
|
+
// d3-zoom + d3-transition: `transition.call(zoomBehavior.transform,
|
|
423
|
+
// target)` interpolates the transform smoothly, firing the zoom
|
|
424
|
+
// callback per frame so pan + scale animate together. Hide any prior
|
|
425
|
+
// tooltip up front so it doesn't track the moving viewport; re-show
|
|
426
|
+
// once the new target is reached.
|
|
427
|
+
hideTooltip();
|
|
428
|
+
svg
|
|
429
|
+
.transition()
|
|
430
|
+
.duration(FOCUS_ANIM_MS)
|
|
431
|
+
.call(zoomBehavior.transform, target)
|
|
432
|
+
.on("end", showFocusTooltip);
|
|
433
|
+
} else {
|
|
434
|
+
zoomBehavior.transform(svg, target);
|
|
435
|
+
showFocusTooltip();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function resetZoom() {
|
|
440
|
+
if (!svgRef.value || !zoomBehavior) return;
|
|
441
|
+
const svg = select(svgRef.value);
|
|
442
|
+
// Cancel any in-flight focus animation before snapping so the transition
|
|
443
|
+
// can't keep writing transforms after we set identity.
|
|
444
|
+
svg.interrupt();
|
|
445
|
+
zoomBehavior.transform(svg, zoomIdentity);
|
|
446
|
+
// Keep v-model:focus in sync when the user resets a focused view.
|
|
447
|
+
if (normalizedFocus.value.length > 0) emit("update:focus", null);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// `focusZoomLevel` only affects scaleExtent + the next focus apply. The
|
|
451
|
+
// d3-zoom filter reads `props.zoom` / `props.pan` dynamically, so we don't
|
|
452
|
+
// need to tear down zoom on those changes.
|
|
213
453
|
watch(
|
|
214
|
-
() =>
|
|
454
|
+
() => props.focusZoomLevel,
|
|
215
455
|
() => {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
456
|
+
if (zoomBehavior) {
|
|
457
|
+
zoomBehavior.scaleExtent([1, Math.max(12, props.focusZoomLevel)]);
|
|
458
|
+
}
|
|
459
|
+
applyFocus();
|
|
220
460
|
},
|
|
221
461
|
);
|
|
222
462
|
|
|
223
|
-
|
|
463
|
+
// Canonical internal coordinate system. All layout (projection, legend,
|
|
464
|
+
// title) is computed at this size; the SVG's viewBox makes the browser
|
|
465
|
+
// scale the entire canvas to whatever the container provides, so there's no
|
|
466
|
+
// JS work on container resize. `props.width` / `props.height`, when set,
|
|
467
|
+
// drive the rendered SVG element size but not these canonical coords.
|
|
468
|
+
const CANONICAL_WIDTH = 1000;
|
|
224
469
|
const aspectRatio = computed(() => {
|
|
225
470
|
if (props.width && props.height) return props.height / props.width;
|
|
226
471
|
return 0.625;
|
|
227
472
|
});
|
|
228
|
-
const
|
|
473
|
+
const width = computed(() => CANONICAL_WIDTH);
|
|
474
|
+
const height = computed(() => CANONICAL_WIDTH * aspectRatio.value);
|
|
475
|
+
|
|
476
|
+
// Layout is fluid: the wrapper fills its parent's width and the SVG fills
|
|
477
|
+
// the wrapper via CSS. `props.width` / `props.height`, when both are
|
|
478
|
+
// passed, only shape the viewBox aspect ratio — they don't pin a display
|
|
479
|
+
// size, so the map always scales to the available width without overflow.
|
|
229
480
|
|
|
230
481
|
type NamedGeometry = GeometryCollection<{ name: string }>;
|
|
231
482
|
type StatesTopo = Topology<{ states: NamedGeometry }>;
|
|
@@ -279,8 +530,8 @@ const stateBordersPath = computed(() => {
|
|
|
279
530
|
const projection = computed(() =>
|
|
280
531
|
geoAlbersUsa().fitExtent(
|
|
281
532
|
[
|
|
282
|
-
[0,
|
|
283
|
-
[width.value, height.value
|
|
533
|
+
[0, 0],
|
|
534
|
+
[width.value, height.value],
|
|
284
535
|
],
|
|
285
536
|
featuresGeo.value,
|
|
286
537
|
),
|
|
@@ -294,15 +545,46 @@ const effectiveStrokeWidth = computed(() =>
|
|
|
294
545
|
: props.strokeWidth,
|
|
295
546
|
);
|
|
296
547
|
|
|
548
|
+
// O(features + data) name→id index, so `dataMap` doesn't fall back to a
|
|
549
|
+
// linear scan per data point (previously O(features × data)).
|
|
550
|
+
const nameToFeatureId = computed(() => {
|
|
551
|
+
const m = new Map<string, string>();
|
|
552
|
+
for (const f of featuresGeo.value.features) {
|
|
553
|
+
if (f.properties?.name != null && f.id != null) {
|
|
554
|
+
m.set(f.properties.name, String(f.id));
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return m;
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// id → feature lookup used by `applyFocus`. Cached so focus changes don't
|
|
561
|
+
// trigger a linear scan through 3k+ features per apply.
|
|
562
|
+
const featuresById = computed(() => {
|
|
563
|
+
const m = new Map<string, ChoroplethFeature>();
|
|
564
|
+
for (const f of featuresGeo.value.features) {
|
|
565
|
+
if (f.id != null) m.set(String(f.id), f as ChoroplethFeature);
|
|
566
|
+
}
|
|
567
|
+
return m;
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// Stable, deduped array form of `props.focus`. Drives the focus watcher;
|
|
571
|
+
// scalar `string` and `string[]` collapse to the same shape so the watcher
|
|
572
|
+
// only fires on a real change.
|
|
573
|
+
const normalizedFocus = computed<string[]>(() => {
|
|
574
|
+
const f = props.focus;
|
|
575
|
+
if (f == null) return [];
|
|
576
|
+
if (Array.isArray(f)) return f;
|
|
577
|
+
return [f];
|
|
578
|
+
});
|
|
579
|
+
|
|
297
580
|
const dataMap = computed(() => {
|
|
298
581
|
const map = new Map<string, number | string>();
|
|
299
582
|
if (!props.data) return map;
|
|
583
|
+
const nameIdx = nameToFeatureId.value;
|
|
300
584
|
for (const d of props.data) {
|
|
301
585
|
map.set(d.id, d.value);
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
);
|
|
305
|
-
if (geo?.id != null) map.set(String(geo.id), d.value);
|
|
586
|
+
const fid = nameIdx.get(d.id);
|
|
587
|
+
if (fid) map.set(fid, d.value);
|
|
306
588
|
}
|
|
307
589
|
return map;
|
|
308
590
|
});
|
|
@@ -362,41 +644,44 @@ function interpolateColor(t: number): string {
|
|
|
362
644
|
return `rgb(${r},${g},${b})`;
|
|
363
645
|
}
|
|
364
646
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
647
|
+
// Sorted high-to-low so the first match wins (highest threshold ≤ value).
|
|
648
|
+
// Cached so we don't re-sort 3k+ times during a rebuild.
|
|
649
|
+
const thresholdStopsDesc = computed(() =>
|
|
650
|
+
isThreshold.value
|
|
651
|
+
? (props.colorScale as ThresholdStop[])
|
|
652
|
+
.slice()
|
|
653
|
+
.sort((a, b) => b.min - a.min)
|
|
654
|
+
: null,
|
|
655
|
+
);
|
|
374
656
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
657
|
+
const categoricalByValue = computed(() => {
|
|
658
|
+
if (!isCategorical.value) return null;
|
|
659
|
+
const m = new Map<string, string>();
|
|
660
|
+
for (const s of props.colorScale as CategoricalStop[])
|
|
661
|
+
m.set(s.value, s.color);
|
|
662
|
+
return m;
|
|
663
|
+
});
|
|
380
664
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
if (
|
|
665
|
+
/** Single color-resolution path. Returns the noData color for missing rows. */
|
|
666
|
+
function colorFor(id: string): string {
|
|
667
|
+
const value = dataMap.value.get(id);
|
|
668
|
+
const noData = props.noDataColor!;
|
|
669
|
+
if (value == null) return noData;
|
|
670
|
+
const cat = categoricalByValue.value;
|
|
671
|
+
if (cat) return cat.get(String(value)) ?? noData;
|
|
672
|
+
const thresholds = thresholdStopsDesc.value;
|
|
673
|
+
if (thresholds) {
|
|
674
|
+
const n = value as number;
|
|
675
|
+
for (const stop of thresholds) if (n >= stop.min) return stop.color;
|
|
676
|
+
return noData;
|
|
677
|
+
}
|
|
386
678
|
const { min, max } = extent.value;
|
|
387
|
-
|
|
388
|
-
return interpolateColor(t);
|
|
679
|
+
return interpolateColor(((value as number) - min) / (max - min));
|
|
389
680
|
}
|
|
390
681
|
|
|
391
|
-
|
|
392
|
-
return feat.properties?.name ?? String(feat.id);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
function stateValue(
|
|
682
|
+
const featureName = (
|
|
396
683
|
feat: (typeof featuresGeo.value.features)[number],
|
|
397
|
-
):
|
|
398
|
-
return dataMap.value.get(String(feat.id));
|
|
399
|
-
}
|
|
684
|
+
): string => feat.properties?.name ?? String(feat.id);
|
|
400
685
|
|
|
401
686
|
function formatTooltipValue(value: number | string | undefined): string {
|
|
402
687
|
if (value == null) return "";
|
|
@@ -406,119 +691,172 @@ function formatTooltipValue(value: number | string | undefined): string {
|
|
|
406
691
|
return String(value);
|
|
407
692
|
}
|
|
408
693
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
});
|
|
694
|
+
/** "Name" or "Name: formatted-value" — used for the SVG <title> fallback. */
|
|
695
|
+
function titleText(name: string, value: number | string | undefined): string {
|
|
696
|
+
return value == null ? name : `${name}: ${formatTooltipValue(value)}`;
|
|
697
|
+
}
|
|
414
698
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
699
|
+
// ─── Tooltip (fully synchronous; positioning uses cached size) ───────────
|
|
700
|
+
//
|
|
701
|
+
// The flow is:
|
|
702
|
+
// 1. mouseover → setData (Vue patches slot props on the *child*) → position
|
|
703
|
+
// using lastTooltipSize (possibly stale by one frame) → visibility:visible
|
|
704
|
+
// 2. tooltipObserver fires when the slot DOM has actually committed → we
|
|
705
|
+
// refresh lastTooltipSize and re-apply the position if still visible.
|
|
706
|
+
// 3. mousemove → rAF-throttled direct DOM write of transform; no reactivity.
|
|
707
|
+
// 4. mouseout (leaving the map) → visibility:hidden.
|
|
708
|
+
//
|
|
709
|
+
// There is no `await` and no token: out-of-order completion is impossible
|
|
710
|
+
// because every step is synchronous from the event handler's perspective.
|
|
711
|
+
|
|
712
|
+
function attachTooltipObserver() {
|
|
713
|
+
const el = tooltipChildRef.value?.getEl();
|
|
714
|
+
if (!el) return;
|
|
715
|
+
tooltipObserver?.disconnect();
|
|
716
|
+
// First measurement bootstraps placement (the very first hover used the
|
|
717
|
+
// 0×0 fallback). After that we just silently refresh the cached size —
|
|
718
|
+
// every hover uses whatever was measured on the previous render, so
|
|
719
|
+
// switching between hover targets never causes the tooltip to re-flip
|
|
720
|
+
// mid-hover.
|
|
721
|
+
let primed = false;
|
|
722
|
+
tooltipObserver = new ResizeObserver((entries) => {
|
|
723
|
+
const r = entries[0]?.contentRect;
|
|
724
|
+
if (!r) return;
|
|
725
|
+
lastTooltipSize.width = r.width;
|
|
726
|
+
lastTooltipSize.height = r.height;
|
|
727
|
+
if (!primed && tooltipVisible && lastPointer) {
|
|
728
|
+
primed = true;
|
|
729
|
+
applyTooltipPosition(lastPointer.x, lastPointer.y);
|
|
730
|
+
} else {
|
|
731
|
+
primed = true;
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
tooltipObserver.observe(el);
|
|
427
735
|
}
|
|
428
736
|
|
|
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
|
-
}
|
|
737
|
+
function applyTooltipPosition(clientX: number, clientY: number) {
|
|
738
|
+
const el = tooltipChildRef.value?.getEl();
|
|
739
|
+
if (!el) return;
|
|
740
|
+
// Use the cached size — accurate after the first ResizeObserver tick. On
|
|
741
|
+
// the very first show before the observer has fired, this falls through
|
|
742
|
+
// placeTooltip's no-flip path (size 0 → no flip), which simply pins the
|
|
743
|
+
// tooltip to the right of the cursor.
|
|
451
744
|
const chartRect = containerRef.value?.getBoundingClientRect();
|
|
452
745
|
const { left, top } = placeTooltip(
|
|
453
746
|
clientX,
|
|
454
747
|
clientY,
|
|
455
|
-
|
|
456
|
-
|
|
748
|
+
lastTooltipSize.width,
|
|
749
|
+
lastTooltipSize.height,
|
|
457
750
|
props.tooltipClamp,
|
|
458
751
|
chartRect,
|
|
459
752
|
);
|
|
460
|
-
|
|
461
|
-
|
|
753
|
+
el.style.transform = `translate3d(${left}px, ${top}px, 0) translateY(-50%)`;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function showTooltip(featId: string, clientX: number, clientY: number) {
|
|
757
|
+
const data = tooltipDataById.get(featId);
|
|
758
|
+
if (!data) return;
|
|
759
|
+
const child = tooltipChildRef.value;
|
|
760
|
+
const el = child?.getEl();
|
|
761
|
+
if (!child || !el) return;
|
|
762
|
+
child.setData(data);
|
|
763
|
+
lastPointer = { x: clientX, y: clientY };
|
|
764
|
+
tooltipVisible = true;
|
|
765
|
+
applyTooltipPosition(clientX, clientY);
|
|
766
|
+
el.style.visibility = "visible";
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function moveTooltip(clientX: number, clientY: number) {
|
|
770
|
+
if (!tooltipVisible) return;
|
|
771
|
+
pendingMoveX = clientX;
|
|
772
|
+
pendingMoveY = clientY;
|
|
773
|
+
if (pendingMoveFrame) return;
|
|
774
|
+
pendingMoveFrame = requestAnimationFrame(() => {
|
|
775
|
+
pendingMoveFrame = 0;
|
|
776
|
+
const el = tooltipChildRef.value?.getEl();
|
|
777
|
+
if (!el || !tooltipVisible) return;
|
|
778
|
+
lastPointer = { x: pendingMoveX, y: pendingMoveY };
|
|
779
|
+
// Mid-hover: don't re-run flip/clamp on every pixel; just translate.
|
|
780
|
+
el.style.transform = `translate3d(${pendingMoveX + 16}px, ${pendingMoveY}px, 0) translateY(-50%)`;
|
|
781
|
+
});
|
|
462
782
|
}
|
|
463
783
|
|
|
464
784
|
function hideTooltip() {
|
|
465
|
-
if (
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
785
|
+
if (!tooltipVisible) return;
|
|
786
|
+
tooltipVisible = false;
|
|
787
|
+
lastPointer = null;
|
|
788
|
+
const el = tooltipChildRef.value?.getEl();
|
|
789
|
+
if (el) el.style.visibility = "hidden";
|
|
469
790
|
}
|
|
470
791
|
|
|
471
|
-
function
|
|
472
|
-
|
|
473
|
-
feat: (typeof featuresGeo.value.features)[number],
|
|
474
|
-
) {
|
|
475
|
-
if (hoveredEl && hoveredEl !== pathEl) {
|
|
476
|
-
hoveredEl.setAttribute("stroke-width", String(effectiveStrokeWidth.value));
|
|
477
|
-
hoveredEl.setAttribute("stroke", props.strokeColor);
|
|
478
|
-
}
|
|
479
|
-
hoveredEl = pathEl;
|
|
792
|
+
function applyHighlightStroke(pathEl: SVGPathElement) {
|
|
793
|
+
// Bring path to top so its thicker border isn't clipped by neighbors.
|
|
480
794
|
pathEl.parentNode?.appendChild(pathEl);
|
|
481
795
|
pathEl.setAttribute("stroke-width", String(effectiveStrokeWidth.value + 1));
|
|
482
796
|
pathEl.setAttribute("stroke", "#555");
|
|
483
797
|
}
|
|
484
798
|
|
|
799
|
+
function restoreDefaultStroke(pathEl: SVGPathElement) {
|
|
800
|
+
pathEl.setAttribute("stroke-width", String(effectiveStrokeWidth.value));
|
|
801
|
+
pathEl.setAttribute("stroke", props.strokeColor);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function setHover(pathEl: SVGPathElement) {
|
|
805
|
+
if (hoveredEl === pathEl) return;
|
|
806
|
+
if (hoveredEl && !focusedPathEls.has(hoveredEl)) {
|
|
807
|
+
// Restore previous hover unless it's also focused — focus keeps the
|
|
808
|
+
// highlight on its own.
|
|
809
|
+
restoreDefaultStroke(hoveredEl);
|
|
810
|
+
}
|
|
811
|
+
hoveredEl = pathEl;
|
|
812
|
+
applyHighlightStroke(pathEl);
|
|
813
|
+
}
|
|
814
|
+
|
|
485
815
|
function clearHover() {
|
|
486
816
|
if (hoveredEl) {
|
|
487
|
-
|
|
488
|
-
hoveredEl.setAttribute("stroke", props.strokeColor);
|
|
817
|
+
if (!focusedPathEls.has(hoveredEl)) restoreDefaultStroke(hoveredEl);
|
|
489
818
|
hoveredEl = null;
|
|
490
819
|
emit("stateHover", null);
|
|
491
820
|
}
|
|
492
821
|
hideTooltip();
|
|
493
822
|
}
|
|
494
823
|
|
|
495
|
-
// Delegated event handlers (
|
|
824
|
+
// ─── Delegated event handlers (single set of listeners on the <g>) ───────
|
|
825
|
+
|
|
826
|
+
function eventToFeatureId(target: EventTarget | null): string | null {
|
|
827
|
+
let el = target as Element | null;
|
|
828
|
+
while (el && !(el as HTMLElement).dataset?.featId) el = el.parentElement;
|
|
829
|
+
return el ? ((el as HTMLElement).dataset.featId ?? null) : null;
|
|
830
|
+
}
|
|
831
|
+
|
|
496
832
|
function onDelegatedEvent(event: Event) {
|
|
497
833
|
if (isZooming) return;
|
|
498
834
|
const me = event as MouseEvent;
|
|
499
|
-
const
|
|
500
|
-
if (!
|
|
835
|
+
const featId = eventToFeatureId(me.target);
|
|
836
|
+
if (!featId) return;
|
|
837
|
+
const data = tooltipDataById.get(featId);
|
|
838
|
+
if (!data) return;
|
|
839
|
+
const payload = { id: data.id, name: data.name, value: data.value };
|
|
501
840
|
if (event.type === "click") {
|
|
502
|
-
emit("stateClick",
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
841
|
+
emit("stateClick", payload);
|
|
842
|
+
// Click-to-focus toggle, baked in so `v-model:focus="ref"` Just Works:
|
|
843
|
+
// clicking the currently focused feature clears focus (emits null);
|
|
844
|
+
// clicking any other feature emits its id. With a `focus` array, any
|
|
845
|
+
// click on a member clears everything — parents wanting fine-grained
|
|
846
|
+
// multi-select handle merging themselves via `@update:focus`.
|
|
847
|
+
const wasFocused = resolveFocusIds(normalizedFocus.value).has(data.id);
|
|
848
|
+
emit("update:focus", wasFocused ? null : data.id);
|
|
507
849
|
} else if (event.type === "mouseover") {
|
|
508
|
-
setHover(
|
|
509
|
-
if (
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
name: stateName(hit.feat),
|
|
513
|
-
value: stateValue(hit.feat),
|
|
514
|
-
});
|
|
850
|
+
setHover(pathsByFeatureId.get(featId)!);
|
|
851
|
+
if (hasInteractiveTooltip.value)
|
|
852
|
+
showTooltip(featId, me.clientX, me.clientY);
|
|
853
|
+
emit("stateHover", payload);
|
|
515
854
|
}
|
|
516
855
|
}
|
|
517
856
|
|
|
518
857
|
function onDelegatedMouseMove(event: MouseEvent) {
|
|
519
|
-
if (isZooming
|
|
520
|
-
|
|
521
|
-
tooltipEl.style.top = `${event.clientY}px`;
|
|
858
|
+
if (isZooming) return;
|
|
859
|
+
moveTooltip(event.clientX, event.clientY);
|
|
522
860
|
}
|
|
523
861
|
|
|
524
862
|
function onDelegatedMouseOut(event: MouseEvent) {
|
|
@@ -527,6 +865,110 @@ function onDelegatedMouseOut(event: MouseEvent) {
|
|
|
527
865
|
clearHover();
|
|
528
866
|
}
|
|
529
867
|
|
|
868
|
+
// ─── Imperative SVG path management ──────────────────────────────────────
|
|
869
|
+
//
|
|
870
|
+
// 3,000+ counties are too many to round-trip through Vue's render scheduler
|
|
871
|
+
// on every reactive change. We build the SVG path tree once per feature set
|
|
872
|
+
// and mutate attributes directly when data/styling changes.
|
|
873
|
+
|
|
874
|
+
function makePath(d: string | null): SVGPathElement {
|
|
875
|
+
const p = document.createElementNS(SVG_NS, "path") as SVGPathElement;
|
|
876
|
+
if (d) p.setAttribute("d", d);
|
|
877
|
+
return p;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function rebuildPaths() {
|
|
881
|
+
const g = mapGroupRef.value;
|
|
882
|
+
if (!g) return;
|
|
883
|
+
while (g.firstChild) g.removeChild(g.firstChild);
|
|
884
|
+
pathsByFeatureId.clear();
|
|
885
|
+
tooltipDataById.clear();
|
|
886
|
+
bordersPathEl = null;
|
|
887
|
+
hoveredEl = null;
|
|
888
|
+
// Old focused paths are about to be detached — drop refs so applyFocus
|
|
889
|
+
// can re-resolve against the new path tree.
|
|
890
|
+
focusedPathEls.clear();
|
|
891
|
+
|
|
892
|
+
const path = pathGenerator.value;
|
|
893
|
+
const features = featuresGeo.value.features;
|
|
894
|
+
const stroke = props.strokeColor;
|
|
895
|
+
const sw = String(effectiveStrokeWidth.value);
|
|
896
|
+
const wantsTitleFallback = !hasInteractiveTooltip.value;
|
|
897
|
+
|
|
898
|
+
// Single DocumentFragment append → one layout flush for the whole batch.
|
|
899
|
+
const frag = document.createDocumentFragment();
|
|
900
|
+
for (const feat of features) {
|
|
901
|
+
const id = String(feat.id);
|
|
902
|
+
const name = featureName(feat);
|
|
903
|
+
const value = dataMap.value.get(id);
|
|
904
|
+
const p = makePath(path(feat));
|
|
905
|
+
p.setAttribute("class", "state-path");
|
|
906
|
+
p.setAttribute("data-feat-id", id);
|
|
907
|
+
p.setAttribute("fill", colorFor(id));
|
|
908
|
+
p.setAttribute("stroke", stroke);
|
|
909
|
+
p.setAttribute("stroke-width", sw);
|
|
910
|
+
// Keep stroke width pixel-accurate regardless of how the browser scales
|
|
911
|
+
// the viewBox to fit the container — otherwise borders appear thicker
|
|
912
|
+
// as the map is enlarged.
|
|
913
|
+
p.setAttribute("vector-effect", "non-scaling-stroke");
|
|
914
|
+
if (wantsTitleFallback) {
|
|
915
|
+
const title = document.createElementNS(SVG_NS, "title");
|
|
916
|
+
title.textContent = titleText(name, value);
|
|
917
|
+
p.appendChild(title);
|
|
918
|
+
}
|
|
919
|
+
frag.appendChild(p);
|
|
920
|
+
pathsByFeatureId.set(id, p);
|
|
921
|
+
tooltipDataById.set(id, {
|
|
922
|
+
id,
|
|
923
|
+
name,
|
|
924
|
+
value,
|
|
925
|
+
feature: feat as ChoroplethFeature,
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// State-borders overlay (counties / hsas mode).
|
|
930
|
+
const borders = stateBordersPath.value;
|
|
931
|
+
if (borders) {
|
|
932
|
+
const b = makePath(path(borders));
|
|
933
|
+
b.setAttribute("fill", "none");
|
|
934
|
+
b.setAttribute("stroke", stroke);
|
|
935
|
+
b.setAttribute("stroke-width", "1");
|
|
936
|
+
b.setAttribute("stroke-linejoin", "round");
|
|
937
|
+
b.setAttribute("pointer-events", "none");
|
|
938
|
+
b.setAttribute("vector-effect", "non-scaling-stroke");
|
|
939
|
+
frag.appendChild(b);
|
|
940
|
+
bordersPathEl = b;
|
|
941
|
+
}
|
|
942
|
+
g.appendChild(frag);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function updateFills() {
|
|
946
|
+
const refreshTitle = !hasInteractiveTooltip.value;
|
|
947
|
+
for (const [id, p] of pathsByFeatureId) {
|
|
948
|
+
const value = dataMap.value.get(id);
|
|
949
|
+
const entry = tooltipDataById.get(id);
|
|
950
|
+
p.setAttribute("fill", colorFor(id));
|
|
951
|
+
// Refresh cached tooltip payload so a later hover (or the SVG <title>
|
|
952
|
+
// fallback below) reflects the new value.
|
|
953
|
+
if (entry) entry.value = value;
|
|
954
|
+
if (refreshTitle && entry) {
|
|
955
|
+
// First child is the <title> appended in rebuildPaths when fallback
|
|
956
|
+
// mode is active.
|
|
957
|
+
const title = p.firstElementChild;
|
|
958
|
+
if (title) title.textContent = titleText(entry.name, value);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function updateStrokes() {
|
|
964
|
+
for (const p of pathsByFeatureId.values()) {
|
|
965
|
+
// Highlighted paths (hover / focus) keep their #555 + thicker stroke.
|
|
966
|
+
if (p === hoveredEl || focusedPathEls.has(p)) continue;
|
|
967
|
+
restoreDefaultStroke(p);
|
|
968
|
+
}
|
|
969
|
+
if (bordersPathEl) bordersPathEl.setAttribute("stroke", props.strokeColor);
|
|
970
|
+
}
|
|
971
|
+
|
|
530
972
|
function menuFilename() {
|
|
531
973
|
return typeof props.menu === "string" ? props.menu : "choropleth";
|
|
532
974
|
}
|
|
@@ -540,14 +982,6 @@ const sortedThresholdStops = computed(() =>
|
|
|
540
982
|
(props.colorScale as ThresholdStop[]).slice().sort((a, b) => a.min - b.min),
|
|
541
983
|
);
|
|
542
984
|
|
|
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
985
|
const gradientStops = computed(() => {
|
|
552
986
|
const steps = 10;
|
|
553
987
|
const result: { offset: string; color: string }[] = [];
|
|
@@ -561,6 +995,13 @@ const gradientStops = computed(() => {
|
|
|
561
995
|
return result;
|
|
562
996
|
});
|
|
563
997
|
|
|
998
|
+
// Compact formatter so legend ticks for large ranges (e.g. populations in
|
|
999
|
+
// the millions) don't render wide enough to collide with each other.
|
|
1000
|
+
const compactTickFormat = new Intl.NumberFormat("en-US", {
|
|
1001
|
+
notation: "compact",
|
|
1002
|
+
maximumFractionDigits: 1,
|
|
1003
|
+
});
|
|
1004
|
+
|
|
564
1005
|
const continuousTicks = computed(() => {
|
|
565
1006
|
const { min, max } = extent.value;
|
|
566
1007
|
const range = max - min;
|
|
@@ -569,9 +1010,12 @@ const continuousTicks = computed(() => {
|
|
|
569
1010
|
for (let i = 1; i <= count; i++) {
|
|
570
1011
|
const t = i / (count + 1);
|
|
571
1012
|
const v = min + range * t;
|
|
572
|
-
const formatted =
|
|
573
|
-
|
|
574
|
-
|
|
1013
|
+
const formatted =
|
|
1014
|
+
Math.abs(v) >= 1000
|
|
1015
|
+
? compactTickFormat.format(v)
|
|
1016
|
+
: Number.isInteger(v)
|
|
1017
|
+
? String(v)
|
|
1018
|
+
: v.toFixed(1).replace(/\.0$/, "");
|
|
575
1019
|
ticks.push({ value: formatted, pct: t * 100 });
|
|
576
1020
|
}
|
|
577
1021
|
return ticks;
|
|
@@ -595,32 +1039,13 @@ const discreteLegendItems = computed(() => {
|
|
|
595
1039
|
return items;
|
|
596
1040
|
});
|
|
597
1041
|
|
|
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;
|
|
1042
|
+
// Linear-gradient CSS for the continuous legend bar, derived from the same
|
|
1043
|
+
// stops the SVG version used.
|
|
1044
|
+
const gradientCss = computed(() => {
|
|
1045
|
+
const stops = gradientStops.value
|
|
1046
|
+
.map((s) => `${s.color} ${s.offset}`)
|
|
1047
|
+
.join(", ");
|
|
1048
|
+
return `linear-gradient(to right, ${stops})`;
|
|
624
1049
|
});
|
|
625
1050
|
|
|
626
1051
|
const menuItems = computed<ChartMenuItem[]>(() => {
|
|
@@ -640,145 +1065,144 @@ const menuItems = computed<ChartMenuItem[]>(() => {
|
|
|
640
1065
|
},
|
|
641
1066
|
];
|
|
642
1067
|
});
|
|
1068
|
+
|
|
1069
|
+
// ─── Reactive triggers for the imperative SVG tree ───────────────────────
|
|
1070
|
+
// Registered last so the eagerly-evaluated source getters can read every
|
|
1071
|
+
// computed defined above without hitting a TDZ.
|
|
1072
|
+
|
|
1073
|
+
// Geometry / projection / tooltip-mode → full rebuild.
|
|
1074
|
+
watch(
|
|
1075
|
+
() => [pathGenerator.value, hasInteractiveTooltip.value],
|
|
1076
|
+
() => rebuildPaths(),
|
|
1077
|
+
);
|
|
1078
|
+
|
|
1079
|
+
// Data or scale → repaint fills (and refresh fallback <title>s).
|
|
1080
|
+
watch(
|
|
1081
|
+
() => [dataMap.value, props.colorScale, props.noDataColor],
|
|
1082
|
+
() => updateFills(),
|
|
1083
|
+
);
|
|
1084
|
+
|
|
1085
|
+
// Stroke styling → refresh stroke attrs (skipping the currently hovered path).
|
|
1086
|
+
watch(
|
|
1087
|
+
() => [props.strokeColor, effectiveStrokeWidth.value],
|
|
1088
|
+
() => updateStrokes(),
|
|
1089
|
+
);
|
|
1090
|
+
|
|
1091
|
+
// Focus or projection changed → re-apply the focus transform imperatively.
|
|
1092
|
+
// `flush: "post"` so any pending path rebuild from the watcher above has
|
|
1093
|
+
// already run; we still use the GeoJSON pathGenerator directly so the SVG
|
|
1094
|
+
// path tree isn't actually required, but keeping the order avoids stacking
|
|
1095
|
+
// two zoom transforms in the same tick.
|
|
1096
|
+
watch(
|
|
1097
|
+
() => [normalizedFocus.value, pathGenerator.value],
|
|
1098
|
+
() => applyFocus(),
|
|
1099
|
+
{ flush: "post" },
|
|
1100
|
+
);
|
|
643
1101
|
</script>
|
|
644
1102
|
|
|
645
1103
|
<template>
|
|
646
1104
|
<div ref="containerRef" :class="['choropleth-wrapper', { pannable: pan }]">
|
|
647
1105
|
<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 -->
|
|
1106
|
+
<!--
|
|
1107
|
+
Title + legend live as an HTML overlay on top of the SVG so they keep
|
|
1108
|
+
their intrinsic px sizes regardless of how the browser scales the
|
|
1109
|
+
viewBox to fit the container.
|
|
1110
|
+
-->
|
|
1111
|
+
<div v-if="title || showLegend" class="choropleth-header">
|
|
1112
|
+
<div v-if="title" class="choropleth-title">{{ title }}</div>
|
|
1113
|
+
<div v-if="showLegend" class="choropleth-legend">
|
|
1114
|
+
<span v-if="legendTitle" class="choropleth-legend-title">
|
|
1115
|
+
{{ legendTitle }}
|
|
1116
|
+
</span>
|
|
686
1117
|
<template v-if="isCategorical || isThreshold">
|
|
687
|
-
<
|
|
688
|
-
v-
|
|
689
|
-
:
|
|
690
|
-
|
|
691
|
-
font-weight="600"
|
|
692
|
-
fill="currentColor"
|
|
1118
|
+
<span
|
|
1119
|
+
v-for="item in discreteLegendItems"
|
|
1120
|
+
:key="item.key"
|
|
1121
|
+
class="choropleth-legend-item"
|
|
693
1122
|
>
|
|
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"
|
|
1123
|
+
<span
|
|
1124
|
+
class="choropleth-legend-swatch"
|
|
1125
|
+
:style="{ background: item.color }"
|
|
704
1126
|
/>
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
:y="5"
|
|
708
|
-
font-size="13"
|
|
709
|
-
fill="currentColor"
|
|
710
|
-
>
|
|
711
|
-
{{ item.label }}
|
|
712
|
-
</text>
|
|
713
|
-
</template>
|
|
1127
|
+
{{ item.label }}
|
|
1128
|
+
</span>
|
|
714
1129
|
</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})`"
|
|
1130
|
+
<div v-else class="choropleth-legend-continuous">
|
|
1131
|
+
<div
|
|
1132
|
+
class="choropleth-legend-gradient"
|
|
1133
|
+
:style="{ background: gradientCss }"
|
|
743
1134
|
/>
|
|
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>
|
|
1135
|
+
<div class="choropleth-legend-ticks">
|
|
1136
|
+
<span
|
|
1137
|
+
v-for="tick in continuousTicks"
|
|
1138
|
+
:key="tick.value"
|
|
1139
|
+
:style="{ left: tick.pct + '%' }"
|
|
1140
|
+
>
|
|
1141
|
+
{{ tick.value }}
|
|
1142
|
+
</span>
|
|
1143
|
+
</div>
|
|
1144
|
+
</div>
|
|
1145
|
+
</div>
|
|
1146
|
+
</div>
|
|
1147
|
+
<svg
|
|
1148
|
+
ref="svgRef"
|
|
1149
|
+
:viewBox="`0 0 ${width} ${height}`"
|
|
1150
|
+
preserveAspectRatio="xMidYMid meet"
|
|
1151
|
+
>
|
|
1152
|
+
<!--
|
|
1153
|
+
Path elements are created imperatively in `rebuildPaths()`; Vue never
|
|
1154
|
+
diffs the per-feature subtree so reactive state changes don't walk
|
|
1155
|
+
thousands of vnodes. This <g> is the mount point + event delegation
|
|
1156
|
+
target.
|
|
1157
|
+
-->
|
|
1158
|
+
<g ref="mapGroupRef" />
|
|
772
1159
|
</svg>
|
|
1160
|
+
<button
|
|
1161
|
+
v-if="isZoomed"
|
|
1162
|
+
type="button"
|
|
1163
|
+
class="choropleth-reset"
|
|
1164
|
+
aria-label="Reset zoom"
|
|
1165
|
+
@click="resetZoom"
|
|
1166
|
+
>
|
|
1167
|
+
Reset
|
|
1168
|
+
</button>
|
|
1169
|
+
<ChoroplethTooltip v-if="hasInteractiveTooltip" ref="tooltipChildRef">
|
|
1170
|
+
<template #default="raw">
|
|
1171
|
+
<slot name="tooltip" v-bind="narrowSlotProps(raw)">
|
|
1172
|
+
<span v-if="tooltipFormat" v-html="tooltipFormat(raw)" />
|
|
1173
|
+
<template v-else-if="raw.value == null">{{ raw.name }}</template>
|
|
1174
|
+
<template v-else>
|
|
1175
|
+
{{ raw.name }}: {{ formatTooltipValue(raw.value) }}
|
|
1176
|
+
</template>
|
|
1177
|
+
</slot>
|
|
1178
|
+
</template>
|
|
1179
|
+
</ChoroplethTooltip>
|
|
773
1180
|
</div>
|
|
774
1181
|
</template>
|
|
775
1182
|
|
|
776
1183
|
<style scoped>
|
|
777
1184
|
.choropleth-wrapper {
|
|
1185
|
+
/*
|
|
1186
|
+
* Override at the consumer level to change the legend/title panel fill:
|
|
1187
|
+
* .my-map { --choropleth-legend-bg: rgba(0, 0, 0, 0.6); }
|
|
1188
|
+
* Defaults to the theme's page background so the panel reads as a
|
|
1189
|
+
* floating extension of the page surface.
|
|
1190
|
+
*/
|
|
1191
|
+
--choropleth-legend-bg: var(--color-bg-0, #fff);
|
|
1192
|
+
|
|
778
1193
|
position: relative;
|
|
779
1194
|
width: 100%;
|
|
780
1195
|
}
|
|
781
1196
|
|
|
1197
|
+
.choropleth-wrapper svg {
|
|
1198
|
+
display: block;
|
|
1199
|
+
/* Fluid scaling via viewBox: the SVG fills its container's width and the
|
|
1200
|
+
* browser derives height from the viewBox aspect ratio. Overridden when
|
|
1201
|
+
* `props.width` / `props.height` are explicitly set on the component. */
|
|
1202
|
+
width: 100%;
|
|
1203
|
+
height: auto;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
782
1206
|
.choropleth-wrapper.pannable svg {
|
|
783
1207
|
cursor: grab;
|
|
784
1208
|
}
|
|
@@ -794,4 +1218,101 @@ const menuItems = computed<ChartMenuItem[]>(() => {
|
|
|
794
1218
|
.state-path {
|
|
795
1219
|
cursor: pointer;
|
|
796
1220
|
}
|
|
1221
|
+
|
|
1222
|
+
.choropleth-reset {
|
|
1223
|
+
position: absolute;
|
|
1224
|
+
bottom: 8px;
|
|
1225
|
+
left: 8px;
|
|
1226
|
+
padding: 4px 10px;
|
|
1227
|
+
font: inherit;
|
|
1228
|
+
font-size: 12px;
|
|
1229
|
+
color: var(--color-text-secondary, #555);
|
|
1230
|
+
background: var(--color-bg-0, #fff);
|
|
1231
|
+
border: 1px solid var(--color-border, #e5e7eb);
|
|
1232
|
+
border-radius: 4px;
|
|
1233
|
+
cursor: pointer;
|
|
1234
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
.choropleth-reset:hover {
|
|
1238
|
+
background: var(--color-bg-1, #f8f9fa);
|
|
1239
|
+
color: var(--color-text, #212529);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/*
|
|
1243
|
+
* Title + legend overlay. Lives in HTML so its sizes are independent of
|
|
1244
|
+
* the SVG viewBox scaling — text stays at its declared px size at any
|
|
1245
|
+
* container width.
|
|
1246
|
+
*/
|
|
1247
|
+
.choropleth-header {
|
|
1248
|
+
/*
|
|
1249
|
+
* In-flow above the map — the map gets its full canvas, no overlap to
|
|
1250
|
+
* worry about. Centered via `width: fit-content` + `margin: auto`.
|
|
1251
|
+
*/
|
|
1252
|
+
display: flex;
|
|
1253
|
+
flex-direction: column;
|
|
1254
|
+
align-items: center;
|
|
1255
|
+
gap: 10px;
|
|
1256
|
+
width: fit-content;
|
|
1257
|
+
margin: 0 auto;
|
|
1258
|
+
padding: 8px 14px;
|
|
1259
|
+
border-radius: 4px;
|
|
1260
|
+
background: var(--choropleth-legend-bg);
|
|
1261
|
+
color: currentColor;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
.choropleth-title {
|
|
1265
|
+
font-size: 14px;
|
|
1266
|
+
font-weight: 600;
|
|
1267
|
+
line-height: 1.2;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
.choropleth-legend {
|
|
1271
|
+
display: flex;
|
|
1272
|
+
align-items: center;
|
|
1273
|
+
gap: 14px;
|
|
1274
|
+
font-size: 13px;
|
|
1275
|
+
line-height: 1.2;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
.choropleth-legend-title {
|
|
1279
|
+
font-weight: 600;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
.choropleth-legend-item {
|
|
1283
|
+
display: inline-flex;
|
|
1284
|
+
align-items: center;
|
|
1285
|
+
gap: 6px;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
.choropleth-legend-swatch {
|
|
1289
|
+
width: 12px;
|
|
1290
|
+
height: 12px;
|
|
1291
|
+
border-radius: 3px;
|
|
1292
|
+
display: inline-block;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
.choropleth-legend-continuous {
|
|
1296
|
+
display: flex;
|
|
1297
|
+
flex-direction: column;
|
|
1298
|
+
width: 160px;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
.choropleth-legend-gradient {
|
|
1302
|
+
height: 12px;
|
|
1303
|
+
border-radius: 2px;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
.choropleth-legend-ticks {
|
|
1307
|
+
position: relative;
|
|
1308
|
+
height: 14px;
|
|
1309
|
+
margin-top: 4px;
|
|
1310
|
+
font-size: 11px;
|
|
1311
|
+
opacity: 0.7;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
.choropleth-legend-ticks > span {
|
|
1315
|
+
position: absolute;
|
|
1316
|
+
transform: translateX(-50%);
|
|
1317
|
+
}
|
|
797
1318
|
</style>
|