@cfasim-ui/charts 0.7.8 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/BarChart/BarChart.d.ts +9 -26
  2. package/dist/ChartMenu/ChartMenu.d.ts +3 -2
  3. package/dist/ChartTooltip/ChartTooltip.d.ts +9 -12
  4. package/dist/ChoroplethMap/ChoroplethMap.d.ts +42 -190
  5. package/dist/ChoroplethMap/ChoroplethTooltip.d.ts +20 -27
  6. package/dist/ChoroplethMap/canvasLayer.d.ts +23 -6
  7. package/dist/ChoroplethMap/cityLayout.d.ts +3 -2
  8. package/dist/ChoroplethMap/mixedGeo.d.ts +74 -0
  9. package/dist/ChoroplethMap/mixedGeo.test.d.ts +1 -0
  10. package/dist/DataTable/DataTable.d.ts +3 -2
  11. package/dist/LineChart/LineChart.d.ts +9 -26
  12. package/dist/_shared/ChartAnnotations.d.ts +3 -2
  13. package/dist/_shared/ChartZoomControls.d.ts +3 -2
  14. package/dist/_shared/index.d.ts +2 -1
  15. package/dist/_shared/mapTheme.d.ts +159 -0
  16. package/dist/_shared/mapTheme.test.d.ts +1 -0
  17. package/dist/index.css +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +1821 -1462
  20. package/docs/BarChart.md +776 -0
  21. package/docs/ChoroplethMap.md +1394 -0
  22. package/docs/DataTable.md +386 -0
  23. package/docs/LineChart.md +1267 -0
  24. package/docs/index.json +56 -0
  25. package/package.json +25 -21
  26. package/src/BarChart/BarChart.md +743 -0
  27. package/src/BarChart/BarChart.vue +1901 -0
  28. package/src/ChartMenu/ChartMenu.vue +220 -0
  29. package/src/ChartMenu/download.ts +120 -0
  30. package/src/ChartTooltip/ChartTooltip.vue +97 -0
  31. package/src/ChoroplethMap/ChoroplethMap.md +1354 -0
  32. package/src/ChoroplethMap/ChoroplethMap.vue +3778 -0
  33. package/src/ChoroplethMap/ChoroplethTooltip.vue +103 -0
  34. package/src/ChoroplethMap/canvasLayer.ts +373 -0
  35. package/src/ChoroplethMap/cityLayout.ts +262 -0
  36. package/src/ChoroplethMap/hsaMapping.ts +4116 -0
  37. package/src/ChoroplethMap/mixedGeo.ts +201 -0
  38. package/src/DataTable/DataTable.md +372 -0
  39. package/src/DataTable/DataTable.vue +406 -0
  40. package/src/LineChart/LineChart.md +1225 -0
  41. package/src/LineChart/LineChart.vue +1555 -0
  42. package/src/_shared/ChartAnnotations.vue +420 -0
  43. package/src/_shared/ChartZoomControls.vue +138 -0
  44. package/src/_shared/annotations.ts +106 -0
  45. package/src/_shared/axes.ts +69 -0
  46. package/src/_shared/chartProps.ts +201 -0
  47. package/src/_shared/computeTicks.ts +42 -0
  48. package/src/_shared/contrast.ts +100 -0
  49. package/src/_shared/dateAxis.ts +501 -0
  50. package/src/_shared/index.ts +78 -0
  51. package/src/_shared/mapTheme.ts +551 -0
  52. package/src/_shared/scale.ts +86 -0
  53. package/src/_shared/seriesCsv.ts +68 -0
  54. package/src/_shared/touch.ts +8 -0
  55. package/src/_shared/useChartFoundation.ts +175 -0
  56. package/src/_shared/useChartFullscreen.ts +254 -0
  57. package/src/_shared/useChartMenu.ts +111 -0
  58. package/src/_shared/useChartPadding.ts +235 -0
  59. package/src/_shared/useChartSize.ts +58 -0
  60. package/src/_shared/useChartTooltip.ts +205 -0
  61. package/src/env.d.ts +4 -0
  62. package/src/hsa-mapping.ts +1 -0
  63. package/src/index.ts +41 -0
  64. package/src/tooltip-position.ts +55 -0
  65. package/src/us-cities/data.ts +1371 -0
  66. package/src/us-cities/index.ts +122 -0
  67. package/src/us-cities.ts +7 -0
@@ -0,0 +1,3778 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ computed,
4
+ nextTick,
5
+ ref,
6
+ watch,
7
+ onMounted,
8
+ onUnmounted,
9
+ toRaw,
10
+ useSlots,
11
+ } from "vue";
12
+ import { geoPath, geoAlbersUsa, geoMercator } from "d3-geo";
13
+ import {
14
+ zoom as d3Zoom,
15
+ zoomIdentity,
16
+ zoomTransform,
17
+ type ZoomTransform,
18
+ } from "d3-zoom";
19
+ import { select } from "d3-selection";
20
+ // Side-effect import: enables `selection.transition()` on d3 selections so
21
+ // `applyFocus` can animate the zoom transform.
22
+ import "d3-transition";
23
+ import { feature, mesh, merge } from "topojson-client";
24
+ import type { Topology, GeometryCollection } from "topojson-specification";
25
+ import { formatNumber, type NumberFormat } from "@cfasim-ui/shared";
26
+ import ChartMenu from "../ChartMenu/ChartMenu.vue";
27
+ import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
28
+ import { saveSvg, savePng, saveCanvasPng } from "../ChartMenu/download.js";
29
+ import {
30
+ buildScene,
31
+ buildPicking,
32
+ drawBase,
33
+ drawHoverHighlight,
34
+ pickIndexAt,
35
+ type CanvasScene,
36
+ type CanvasDrawState,
37
+ type CanvasOverlayItem,
38
+ type CanvasHighlightItem,
39
+ } from "./canvasLayer.js";
40
+ import {
41
+ useChartFullscreen,
42
+ isTouchDevice,
43
+ ChartZoomControls,
44
+ parseRgb,
45
+ useMapTheme,
46
+ mapThemeDefaults,
47
+ LIGHT_HIGHLIGHT,
48
+ TITLE_FONT_SIZE,
49
+ TITLE_LINE_HEIGHT,
50
+ TITLE_FONT_WEIGHT,
51
+ type MapTheme,
52
+ type TitleStyle,
53
+ type LabelStyle,
54
+ } from "../_shared/index.js";
55
+ import { placeTooltip } from "../tooltip-position.js";
56
+ import ChoroplethTooltip from "./ChoroplethTooltip.vue";
57
+ import { layoutCities } from "./cityLayout.js";
58
+ import type { CityMarker } from "./cityLayout.js";
59
+ import {
60
+ resolveGeoOverrides,
61
+ serializeOverrides,
62
+ parseOverrides,
63
+ mixFeatures,
64
+ stateOfId,
65
+ type LevelLookup,
66
+ } from "./mixedGeo.js";
67
+
68
+ const SVG_NS = "http://www.w3.org/2000/svg";
69
+
70
+ export type GeoType = "states" | "counties" | "hsas";
71
+
72
+ export interface StateData {
73
+ /** FIPS code (e.g. "06" for California, "04015" for a county) or name */
74
+ id: string;
75
+ value: number | string;
76
+ /**
77
+ * Geographic level of *this row*, when it differs from the map's `geoType`.
78
+ * The state the row belongs to is then re-tiled at this level: on a county
79
+ * map, `{ id: "06", geoType: "states" }` draws California as one merged
80
+ * shape carrying this value; on a state map, `{ id: "36061", geoType:
81
+ * "counties" }` breaks New York out into its counties. Rows at the base
82
+ * level (the common case) leave `geoType` unset. Ignores `dataGeoType` —
83
+ * an off-level row is looked up by its own id.
84
+ */
85
+ geoType?: GeoType;
86
+ }
87
+
88
+ export interface ChoroplethColorScale {
89
+ /**
90
+ * Minimum color. Any CSS color, including `var()` and `light-dark()`,
91
+ * resolved against the map's container. Default: "#e5f0fa"
92
+ */
93
+ min?: string;
94
+ /** Maximum color (any CSS color, as `min`). Default: "#08519c" */
95
+ max?: string;
96
+ }
97
+
98
+ export interface ThresholdStop {
99
+ /** Lower bound (inclusive). Values at or above this threshold get this color. */
100
+ min: number;
101
+ /** Any CSS color, including `var()` and `light-dark()`. */
102
+ color: string;
103
+ /** Optional label for the legend (defaults to the min value) */
104
+ label?: string;
105
+ }
106
+
107
+ export interface CategoricalStop {
108
+ /** The categorical value to match */
109
+ value: string;
110
+ /** Any CSS color, including `var()` and `light-dark()`. */
111
+ color: string;
112
+ }
113
+
114
+ /**
115
+ * A focused feature. Pass a plain string to focus a feature in the map's
116
+ * current `geoType` with the default solid highlight, or an object to
117
+ * specify a different `geoType` (drawn as an overlay on top of the base
118
+ * map) and/or a `style`.
119
+ */
120
+ export type FocusStyle = "solid" | "dashed" | "dotted";
121
+
122
+ export interface FocusItem {
123
+ /** Feature id (FIPS code, HSA code) or name. */
124
+ id: string;
125
+ /** Defaults to the map's `geoType`. Cross-geoType items render as
126
+ * non-interactive outlines on top of the base map. */
127
+ geoType?: GeoType;
128
+ /** Outline style. `"solid"` (default) matches the hover highlight;
129
+ * `"dashed"` uses long dashes; `"dotted"` uses small round dots —
130
+ * useful when stacking multiple outlines of different geoTypes. */
131
+ style?: FocusStyle;
132
+ /** Stroke color for the outline. In-place highlights (items in the
133
+ * base geoType) default to the theme's `highlight` (pure black/white
134
+ * following the color scheme); cross-geoType overlay paths default to
135
+ * `"#fff"`. */
136
+ stroke?: string;
137
+ /** Outline width in CSS px. Defaults to the map's stroke width + 1
138
+ * for in-place highlights and + 1.5 for cross-geoType overlays. */
139
+ strokeWidth?: number;
140
+ }
141
+
142
+ export type FocusValue = string | FocusItem | Array<string | FocusItem> | null;
143
+
144
+ const props = withDefaults(
145
+ defineProps<{
146
+ /** TopoJSON topology object (e.g. from us-atlas/states-10m.json or us-atlas/counties-10m.json).
147
+ * Must contain a "states" object for geoType="states", or both "states" and "counties" objects
148
+ * for geoType="counties" or geoType="hsas". */
149
+ topology: Topology;
150
+ data?: StateData[];
151
+ /** Geographic type: "states" (default), "counties", or "hsas" (Health Service Areas) */
152
+ geoType?: GeoType;
153
+ /**
154
+ * GeoType of the entries in `data`, if different from `geoType`. Lets
155
+ * you color a county-level base map by HSA values (each county fills
156
+ * with its parent HSA's value) or by state values, without changing
157
+ * the rendered/interactive geometry. Supported combinations:
158
+ * `counties` ← `hsas`, `counties` ← `states`, `hsas` ← `states`.
159
+ * When unset, data ids must match the base `geoType`. Re-keys the whole
160
+ * dataset — to mix levels on one map (a county map where California
161
+ * reports one statewide value), set `geoType` on the individual rows
162
+ * instead.
163
+ */
164
+ dataGeoType?: GeoType;
165
+ /**
166
+ * Scope the map to a single state: render only that state's outline with
167
+ * its `counties` or `hsas` inside it (no surrounding states), and refit
168
+ * the projection to zoom to it. Accepts a state name ("California") or a
169
+ * 2-digit FIPS code ("06"). Requires a counties topology when `geoType`
170
+ * is `"counties"` or `"hsas"`. If the value matches no state, the full
171
+ * national map is rendered and a warning is logged.
172
+ */
173
+ state?: string;
174
+ width?: number;
175
+ height?: number;
176
+ /**
177
+ * Tighten the national (multi-state) fit by cropping the Alaska/Hawaii
178
+ * overhang so the contiguous US fills more of the frame. `false`/`0`
179
+ * (default) fits every region into view; `true`/`1` fits the lower-48 to
180
+ * the frame and lets Alaska's western tail (and Hawaii) clip into the
181
+ * lower-left corner. A number in between (e.g. `0.5`) crops partway.
182
+ * Applies to national states, counties, and HSA maps; no effect in
183
+ * single-state mode.
184
+ */
185
+ tightFit?: boolean | number;
186
+ colorScale?: ChoroplethColorScale | ThresholdStop[] | CategoricalStop[];
187
+ /**
188
+ * Map title. `\n` in the string creates additional lines, each
189
+ * adding `titleStyle.lineHeight` (default 18px) of vertical space.
190
+ */
191
+ title?: string;
192
+ /** Styling for the map title. See `TitleStyle`. */
193
+ titleStyle?: TitleStyle;
194
+ /**
195
+ * ARIA role for the map's root element. Defaults to `"figure"` when an
196
+ * accessible name is present (from `ariaLabel` or `title`), so screen
197
+ * readers announce the map as a labeled figure while its controls (menu,
198
+ * reset) stay reachable. Pass `"img"` to expose it as a single image
199
+ * (which hides the inner controls from assistive tech), or your own role.
200
+ */
201
+ role?: string;
202
+ /**
203
+ * Accessible name for the map, announced by screen readers via the root
204
+ * element's `aria-label`. Defaults to the `title` prop. The individual
205
+ * regions aren't exposed to assistive tech, so set this to a short summary
206
+ * of what the map shows.
207
+ */
208
+ ariaLabel?: string;
209
+ /** Styling for the legend (title, swatch labels, and continuous-scale ticks). */
210
+ legendStyle?: LabelStyle;
211
+ /**
212
+ * Map paint styling: base fill for features without data, feature
213
+ * strokes, the state-borders mesh, an exterior outline, a background
214
+ * wash, and the hover/focus highlight. Every color accepts any CSS
215
+ * color (`var()`, `light-dark()`, `color-mix()`), resolved against
216
+ * the map's container and re-applied automatically when the page
217
+ * theme changes. Defaults route through `--choropleth-*` custom
218
+ * properties (e.g. `--choropleth-outline` enables the exterior
219
+ * outline), so a stylesheet alone can theme every map. See `MapTheme`.
220
+ */
221
+ theme?: MapTheme;
222
+ menu?: boolean | string;
223
+ /** Show legend. Default: true */
224
+ legend?: boolean;
225
+ /** Title displayed next to the legend */
226
+ legendTitle?: string;
227
+ /**
228
+ * Enable the activate-to-zoom interaction. Default `false` — the map
229
+ * is fully static (tooltips and click-select still work, and
230
+ * programmatic `focus` zoom still applies). When enabled, clicks and
231
+ * taps still only select; the first *zoom* is what switches panning
232
+ * on: desktop double-click (or the always-visible +/−/reset controls,
233
+ * or a programmatic focus zoom) zooms in place, and on touch a double
234
+ * tap expands the map to fill the window, where one finger pans,
235
+ * pinch zooms, and a tap selects. The scroll wheel never zooms
236
+ * inline, so the map can't hijack page scrolling.
237
+ */
238
+ zoom?: boolean;
239
+ /**
240
+ * Interaction style when `zoom` is enabled. `"activate"` (default)
241
+ * keeps the map static until a double-click / tap / feature click, so
242
+ * the page scrolls freely past it. `"scroll"` makes the map immediately
243
+ * interactive — the wheel zooms, dragging pans, and touch gestures work
244
+ * inline with no tap-to-expand step. Use it for full-page experiences
245
+ * where the map is the main surface and there's no page scroll to
246
+ * hijack.
247
+ */
248
+ zoomMode?: "activate" | "scroll";
249
+ /**
250
+ * On touch devices, expand the map to fill the window on a double
251
+ * tap. Default `true`. Set `false` to zoom in place instead: a double
252
+ * tap (or pinch) zooms the inline map on that point, after which
253
+ * one-finger pan / pinch work and the +/−/reset controls render
254
+ * inline. Single taps select in both modes. Note the zoomed in-place
255
+ * map captures touch scrolling over it. No effect on desktop or when
256
+ * `zoom` is off; `zoom-mode="scroll"` already implies inline
257
+ * interaction.
258
+ */
259
+ touchExpand?: boolean;
260
+ /**
261
+ * Show the grey "Double click to zoom" / "Double tap to zoom"
262
+ * affordance over the top of the map while the zoom gesture hasn't
263
+ * been used yet. Default `true`; set `false` to hide it. No effect
264
+ * when `zoom` is off.
265
+ */
266
+ zoomHint?: boolean;
267
+ /** Tooltip activation mode */
268
+ tooltipTrigger?: "hover" | "click";
269
+ /**
270
+ * @deprecated Use the `#tooltip` slot instead, which gives you full Vue
271
+ * rendering (components, scoped styles, reactivity). This HTML-string
272
+ * formatter is kept for backwards compatibility and will be removed in a
273
+ * future release.
274
+ */
275
+ tooltipFormat?: (data: {
276
+ id: string;
277
+ name: string;
278
+ value?: number | string;
279
+ }) => string;
280
+ /**
281
+ * Formatter for numeric values shown in the default tooltip. Accepts a
282
+ * preset name, a printf-style format string, or a function. Ignored when
283
+ * `tooltipFormat` is provided (the caller controls the entire tooltip in
284
+ * that case). See `formatNumber` in `@cfasim-ui/shared`.
285
+ */
286
+ tooltipValueFormat?: NumberFormat;
287
+ /**
288
+ * Boundary for tooltip flip/clamp. `"none"` always places to the right of
289
+ * the pointer with no clamping. `"chart"` uses the map container's
290
+ * bounding box. `"window"` (default) uses the viewport.
291
+ */
292
+ tooltipClamp?: "none" | "chart" | "window";
293
+ /**
294
+ * Feature(s) to pan/zoom to. Accepts a feature id (FIPS code, HSA
295
+ * code, or feature name), a `FocusItem` object, or an array of
296
+ * either. `FocusItem` lets you pin features from a different
297
+ * `geoType` than the base map (drawn as a non-interactive outline) or
298
+ * pick a `style` ("solid" / "dashed"). All items contribute to the
299
+ * zoom bounds. Pass `null` or an empty array to clear focus — the
300
+ * current pan/zoom transform is preserved; only the highlight is
301
+ * removed. Works with `v-model:focus`: clicking an unfocused feature
302
+ * (in the base geoType) emits its id; clicking the focused feature
303
+ * emits `null`. The built-in reset button clears focus *and* resets
304
+ * the zoom. If a tooltip is configured, focusing a feature in the
305
+ * base geoType shows its tooltip.
306
+ */
307
+ focus?: FocusValue;
308
+ /** Scale factor applied when `focus` is set. Default: 4 */
309
+ focusZoomLevel?: number;
310
+ /**
311
+ * Whether setting `focus` pans/zooms to fit the focused feature.
312
+ * Default `true`. Set `false` to highlight (and draw cross-geoType
313
+ * overlays) without changing the current pan/zoom — useful for a
314
+ * click-to-select interaction where the map should stay put. The
315
+ * built-in reset button is unaffected.
316
+ */
317
+ focusZoom?: boolean;
318
+ /**
319
+ * Rendering backend, fixed at mount. `"svg"` (default) keeps one DOM
320
+ * path per feature — full assistive-tech fallback (per-feature
321
+ * `<title>`) and SVG export. `"canvas"` paints every feature into a
322
+ * single canvas: much faster for dense maps (counties, HSAs) and on
323
+ * mobile WebKit, with identical interactions. In canvas mode the menu
324
+ * offers PNG export only, and there is no per-feature fallback for
325
+ * assistive tech — configure an interactive tooltip.
326
+ */
327
+ renderer?: "svg" | "canvas";
328
+ /**
329
+ * Where to teleport the map while expanded (the Expand menu item). A
330
+ * CSS selector or element; defaults to `body`. Moving it to the
331
+ * document root keeps `position: fixed` resolving against the viewport
332
+ * instead of being trapped by an ancestor's `transform`/`filter`/
333
+ * `contain`/`perspective` or stacking context. Set this when your app
334
+ * doesn't mount at the document root.
335
+ */
336
+ fullscreenTarget?: string | HTMLElement;
337
+ /**
338
+ * Optional decorative overlay of city markers, each `{ name,
339
+ * coordinates: [lng, lat], capital? }`. Every city is a dot; `capital`
340
+ * cities take first pick when labels would collide and are never dropped
341
+ * (their label is lightly emphasized). Any non-capital label that can't be
342
+ * placed without overlapping is dropped (its dot stays). Purely visual:
343
+ * the markers don't capture pointer events, so the choropleth's own
344
+ * hover/click is unaffected. The `@cfasim-ui/charts/us-cities` subpath
345
+ * ships a ready-made US dataset with `nationalCityMarkers()` /
346
+ * `stateCityMarkers()` helpers.
347
+ */
348
+ cities?: CityMarker[];
349
+ /**
350
+ * Default minimum zoom scale for `cities` that don't set their own
351
+ * `minZoom`, so they act as a zoom-in detail layer. Default `2` (one zoom
352
+ * level = 2×): such markers stay hidden until the user zooms in one level.
353
+ * Set to `1` to show them from the start. Per-city `minZoom` overrides this
354
+ * (level-of-detail: reveal more cities as you zoom in). Only applies when
355
+ * `zoom` is enabled — a static map has no way to zoom, so its `cities`
356
+ * always show.
357
+ */
358
+ citiesMinZoom?: number;
359
+ }>(),
360
+ {
361
+ geoType: "states",
362
+ menu: true,
363
+ legend: true,
364
+ zoom: false,
365
+ renderer: "svg",
366
+ zoomMode: "activate",
367
+ touchExpand: true,
368
+ zoomHint: true,
369
+ tooltipClamp: "window",
370
+ focusZoomLevel: 4,
371
+ focusZoom: true,
372
+ tightFit: false,
373
+ citiesMinZoom: 2,
374
+ },
375
+ );
376
+
377
+ // Accessible name for the whole map; falls back to the visible title.
378
+ const chartAriaLabel = computed(() => props.ariaLabel ?? props.title);
379
+ // Label the map as a figure when it has a name (keeps inner controls
380
+ // reachable, unlike role="img"). An explicit `role` prop always wins.
381
+ const chartRole = computed(
382
+ () => props.role ?? (chartAriaLabel.value ? "figure" : undefined),
383
+ );
384
+
385
+ // The template root is a <Teleport>, so fallthrough attrs (class, style,
386
+ // data-*, id…) can't auto-inherit — forward them onto the wrapper manually.
387
+ defineOptions({ inheritAttrs: false });
388
+
389
+ const emit = defineEmits<{
390
+ (
391
+ e: "stateClick",
392
+ state: { id: string; name: string; value?: number | string },
393
+ ): void;
394
+ (
395
+ e: "stateHover",
396
+ state: { id: string; name: string; value?: number | string } | null,
397
+ ): void;
398
+ (e: "update:focus", focus: string | null): void;
399
+ }>();
400
+
401
+ type ChoroplethFeature = GeoJSON.Feature<
402
+ GeoJSON.Geometry | null,
403
+ { name?: string }
404
+ >;
405
+
406
+ /** Public payload shape — slot props, hover/click emits, tooltip cache. */
407
+ interface TooltipPayload {
408
+ id: string;
409
+ name: string;
410
+ value?: number | string;
411
+ feature: ChoroplethFeature;
412
+ }
413
+
414
+ defineSlots<{
415
+ tooltip?(props: TooltipPayload): unknown;
416
+ }>();
417
+
418
+ // The child types `feature` as `unknown` (it has no map-specific knowledge);
419
+ // we always store a ChoroplethFeature, so narrow it back at the single point
420
+ // where we forward the slot.
421
+ const narrowSlotProps = (
422
+ raw: { feature: unknown } & Omit<TooltipPayload, "feature">,
423
+ ): TooltipPayload => raw as TooltipPayload;
424
+
425
+ const containerRef = ref<HTMLElement | null>(null);
426
+
427
+ // ─── Map theme ────────────────────────────────────────────────────────────
428
+ // All paint styling resolves through a hidden probe in the wrapper (see
429
+ // _shared/mapTheme.ts): any CSS color works on both backends, and a page
430
+ // theme flip re-resolves + repaints via the reactive `resolvedTheme`.
431
+ const mapTheme = useMapTheme(containerRef, () => props.theme);
432
+ const resolvedTheme = mapTheme.resolved;
433
+
434
+ const svgRef = ref<SVGSVGElement | null>(null);
435
+ // `mapGroupRef` is the zoom target. Inside it we split into two layers:
436
+ // `baseGroupRef` holds feature paths + the state-borders mesh and absorbs
437
+ // click/hover events, while `overlayGroupRef` holds focus overlay paths
438
+ // and always sits above so cross-geoType outlines never get covered by a
439
+ // hover-raised base path.
440
+ const mapGroupRef = ref<SVGGElement | null>(null);
441
+ const baseGroupRef = ref<SVGGElement | null>(null);
442
+ const overlayGroupRef = ref<SVGGElement | null>(null);
443
+ // City overlay lives OUTSIDE mapGroupRef: it's positioned by applying the zoom
444
+ // transform in JS (renderCityLayer), so it works identically in svg and canvas
445
+ // mode (where mapGroupRef stays at identity) and keeps a constant on-screen
446
+ // marker/label size under zoom.
447
+ const cityLayerRef = ref<SVGGElement | null>(null);
448
+ const cityOverlayRef = ref<SVGSVGElement | null>(null);
449
+ const tooltipChildRef = ref<InstanceType<typeof ChoroplethTooltip> | null>(
450
+ null,
451
+ );
452
+ const slots = useSlots();
453
+ // Slot/prop presence doesn't change at runtime, so this is effectively
454
+ // computed once. Used to gate the teleported tooltip and the SVG <title>
455
+ // fallback.
456
+ const hasInteractiveTooltip = computed(
457
+ () => !!props.tooltipTrigger || !!props.tooltipFormat || !!slots.tooltip,
458
+ );
459
+
460
+ /**
461
+ * Inline style for the legend container. Font properties cascade to
462
+ * children (legend title, swatch labels, continuous-scale ticks).
463
+ */
464
+ const legendInlineStyle = computed(() => {
465
+ const s = props.legendStyle;
466
+ const style: Record<string, string> = {};
467
+ if (s?.fontSize != null) style["font-size"] = `${s.fontSize}px`;
468
+ if (s?.fontWeight != null) style["font-weight"] = String(s.fontWeight);
469
+ if (s?.color != null) style.color = s.color;
470
+ return style;
471
+ });
472
+
473
+ /** Inline style for the title element, applying TitleStyle overrides. */
474
+ const titleInlineStyle = computed(() => {
475
+ const s = props.titleStyle;
476
+ const style: Record<string, string> = {
477
+ "font-size": `${s?.fontSize ?? TITLE_FONT_SIZE}px`,
478
+ "line-height": `${s?.lineHeight ?? TITLE_LINE_HEIGHT}px`,
479
+ "font-weight": String(s?.fontWeight ?? TITLE_FONT_WEIGHT),
480
+ "text-align": s?.align ?? "left",
481
+ width: "100%",
482
+ };
483
+ if (s?.color) style.color = s.color;
484
+ return style;
485
+ });
486
+ // Imperative path bookkeeping. Plain Maps rather than refs — Vue never reads
487
+ // these from a render scope, so mutating them does not trigger re-renders.
488
+ const pathsByFeatureId = new Map<string, SVGPathElement>();
489
+ const tooltipDataById = new Map<string, TooltipPayload>();
490
+ let bordersPathEl: SVGPathElement | null = null;
491
+ // Exterior outline path (theme.outline), managed by syncOutlinePath.
492
+ let outlinePathEl: SVGPathElement | null = null;
493
+ let hoveredEl: SVGPathElement | null = null;
494
+ // Paths currently styled as focused. Tracked separately from hover so the
495
+ // two states compose: hovering a focused path keeps the highlight on
496
+ // un-hover, and clearing focus while still hovering keeps the hover style.
497
+ // Maps each focused base-geoType path to the FocusItem that styled it so
498
+ // a repeat focus with different styling can re-apply without diffing the
499
+ // attribute set manually.
500
+ const focusedPathStyles = new Map<SVGPathElement, FocusItem>();
501
+ // Cross-geoType focus items render as standalone outline paths layered on
502
+ // top of the base map. Keyed by `${geoType}:${id}` so we can diff add /
503
+ // remove / restyle on each applyFocus. `strokeWidth` (visual CSS px) is
504
+ // kept alongside so applyStrokeScale can re-compensate custom widths.
505
+ const overlayPathEls = new Map<
506
+ string,
507
+ { el: SVGPathElement; strokeWidth?: number }
508
+ >();
509
+
510
+ // ─── Canvas renderer state (renderer="canvas") ───────────────────────────
511
+ // The svg stays as the transparent interaction/zoom surface; the canvas
512
+ // underneath paints the scene. Plain module state, mutated imperatively —
513
+ // same rationale as the path maps above.
514
+ const isCanvas = computed(() => props.renderer === "canvas");
515
+ const canvasRef = ref<HTMLCanvasElement | null>(null);
516
+ let scene: CanvasScene | null = null;
517
+ let pickingCanvas: HTMLCanvasElement | null = null;
518
+ let pickingCtx: CanvasRenderingContext2D | null = null;
519
+ let canvasHoveredId: string | null = null;
520
+ const canvasFocused = new Map<string, CanvasHighlightItem>();
521
+ let canvasOverlays: CanvasOverlayItem[] = [];
522
+ let redrawFrame = 0;
523
+ let cityLayoutFrame = 0;
524
+ // xMidYMid-meet letterbox offsets (CSS px) inside the svg/canvas box,
525
+ // tracked by the resize observer alongside viewScale.
526
+ let meetOffsetX = 0;
527
+ let meetOffsetY = 0;
528
+
529
+ interface CanvasViewState {
530
+ dpr: number;
531
+ meetScale: number;
532
+ offsetX: number;
533
+ offsetY: number;
534
+ zoom: { k: number; x: number; y: number };
535
+ }
536
+ let isZooming = false;
537
+ let tooltipObserver: ResizeObserver | null = null;
538
+ const lastTooltipSize = { width: 0, height: 0 };
539
+ let lastPointer: { x: number; y: number } | null = null;
540
+ let tooltipVisible = false;
541
+ let zoomBehavior: ReturnType<typeof d3Zoom<SVGSVGElement, unknown>> | null =
542
+ null;
543
+ // True while the transform is away from identity.
544
+ const isZoomed = ref(false);
545
+ // Current zoom scale — drives the +/− buttons' disabled states and the
546
+ // stroke-width compensation in applyStrokeScale.
547
+ const scaleK = ref(1);
548
+ // How much the browser scales the canonical viewBox to fit the container
549
+ // (rendered CSS width / CANONICAL_WIDTH). Tracked by a ResizeObserver so
550
+ // stroke widths can stay visually constant without `vector-effect:
551
+ // non-scaling-stroke` (see applyStrokeScale).
552
+ const viewScale = ref(1);
553
+ // The first *zoom* is what activates the pan/zoom interaction — plain
554
+ // clicks/taps only select. Latched true the first time the transform
555
+ // leaves identity (double-click, double-tap/pinch zoom, +/− press, or a
556
+ // programmatic focus zoom) and sticky except in the inline touch flow,
557
+ // where reset restores the pre-zoom static mode by clearing it.
558
+ const hasZoomed = ref(false);
559
+ // `zoom-mode="scroll"`: no activation step at all — the map owns wheel,
560
+ // drag, and touch gestures from the start (for full-page experiences).
561
+ const isScrollMode = computed(() => props.zoomMode === "scroll");
562
+ // Inline touch gestures (one-finger pan, pinch-to-continue) go to the map
563
+ // instead of the page: always in scroll mode, and — with tap-to-expand
564
+ // opted out — once the first zoom has activated the interaction.
565
+ const touchGesturesInline = computed(
566
+ () =>
567
+ props.zoom &&
568
+ (isScrollMode.value || (!props.touchExpand && hasZoomed.value)),
569
+ );
570
+ // rAF-throttled cursor coords for moveTooltip; we coalesce many mousemove
571
+ // events into one transform write per animation frame.
572
+ let pendingMoveX = 0;
573
+ let pendingMoveY = 0;
574
+ let pendingMoveFrame = 0;
575
+
576
+ // Touch tap-to-select. Touch devices get no synthesized-click guarantee
577
+ // (iOS treats the first tap on a hover target as a hover, and the d3-zoom
578
+ // touch listeners can swallow the synthetic click), so we resolve taps from
579
+ // the raw touch events ourselves. A gesture counts as a tap when a single
580
+ // finger lifts close to where it landed, soon after — anything longer or
581
+ // draggier is a pan/long-press and is left to d3-zoom.
582
+ const TAP_SLOP = 10; // px of movement allowed between touchstart and touchend
583
+ const TAP_MAX_MS = 600; // longer presses aren't taps
584
+ let tapStart: {
585
+ x: number;
586
+ y: number;
587
+ time: number;
588
+ featId: string | null;
589
+ } | null = null;
590
+
591
+ // Click/tap-vs-double: with the zoom interaction on, a click or tap can be
592
+ // the first half of a double-click/double-tap zoom, so selection defers by
593
+ // this window and the second click/tap cancels it.
594
+ const CLICK_SELECT_DELAY_MS = 250;
595
+ let pendingSelectTimer = 0;
596
+
597
+ // Double-tap detection for the inline touch map (the zoom gesture there).
598
+ // Two taps landing this close together within the select-defer window
599
+ // count as one double tap.
600
+ const DOUBLE_TAP_SLOP = 30;
601
+ let lastTap: { x: number; y: number; time: number } | null = null;
602
+
603
+ function setupInteraction() {
604
+ const svg = svgRef.value;
605
+ const g = mapGroupRef.value;
606
+ if (!svg || !g) return;
607
+ // Tap handling is wired on every device, on the svg itself so taps on the
608
+ // map background (not just feature paths) can expand the touch map.
609
+ // `touchend` is non-passive so a confirmed tap can preventDefault and
610
+ // suppress the compatibility click/hover the browser would otherwise
611
+ // synthesize (the double-fire and iOS first-tap-hover sources).
612
+ // Must run before setupZoom(): d3-zoom stopImmediatePropagation()s
613
+ // touch events once its filter passes, which would starve these.
614
+ svg.addEventListener("touchstart", onTouchStart, { passive: true });
615
+ svg.addEventListener("touchend", onTouchEnd);
616
+ svg.addEventListener("touchcancel", onTouchCancel, { passive: true });
617
+ // Continuous hover tracking stays off on touch (stroke-width churn
618
+ // degrades zoom/pan); taps provide the one-shot hover + tooltip instead
619
+ // (see touchHover).
620
+ if (isTouchDevice()) return;
621
+ if (isCanvas.value) {
622
+ // No per-feature elements to delegate to — clicks resolve through the
623
+ // picking canvas and hover through per-mousemove picking on the svg.
624
+ svg.addEventListener("click", onDelegatedEvent);
625
+ svg.addEventListener("mousemove", onCanvasMouseMove);
626
+ svg.addEventListener("mouseleave", onCanvasMouseLeave);
627
+ return;
628
+ }
629
+ g.addEventListener("click", onDelegatedEvent);
630
+ g.addEventListener("mouseover", onDelegatedEvent);
631
+ g.addEventListener("mousemove", onDelegatedMouseMove);
632
+ g.addEventListener("mouseout", onDelegatedMouseOut);
633
+ }
634
+
635
+ function teardownInteraction() {
636
+ const svg = svgRef.value;
637
+ const g = mapGroupRef.value;
638
+ if (svg) {
639
+ svg.removeEventListener("touchstart", onTouchStart);
640
+ svg.removeEventListener("touchend", onTouchEnd);
641
+ svg.removeEventListener("touchcancel", onTouchCancel);
642
+ svg.removeEventListener("click", onDelegatedEvent);
643
+ svg.removeEventListener("mousemove", onCanvasMouseMove);
644
+ svg.removeEventListener("mouseleave", onCanvasMouseLeave);
645
+ }
646
+ if (!g) return;
647
+ g.removeEventListener("click", onDelegatedEvent);
648
+ g.removeEventListener("mouseover", onDelegatedEvent);
649
+ g.removeEventListener("mousemove", onDelegatedMouseMove);
650
+ g.removeEventListener("mouseout", onDelegatedMouseOut);
651
+ }
652
+
653
+ // Scroll / resize don't reliably emit mouseout on the underlying path even
654
+ // though the cursor's relationship to the map has changed — the tooltip
655
+ // would otherwise get stuck at its old `position: fixed` coordinates.
656
+ function dismissOnViewportChange() {
657
+ clearHover();
658
+ }
659
+
660
+ // Tracks the svg's rendered width so applyStrokeScale can compensate
661
+ // stroke widths for the viewBox-to-CSS scale. Fires on container resizes
662
+ // only — cheap, and the map itself never relayouts on zoom/pan.
663
+ let svgResizeObserver: ResizeObserver | null = null;
664
+
665
+ onMounted(() => {
666
+ // Order is load-bearing: once its filter passes (zoom activated /
667
+ // expanded), d3-zoom calls stopImmediatePropagation() on touchstart and
668
+ // touchend. Same-element listeners run in registration order, so our
669
+ // tap listeners must be registered BEFORE d3-zoom's or taps (selection,
670
+ // tooltips) go dead the moment the map owns touch gestures.
671
+ setupInteraction();
672
+ setupZoom();
673
+ rebuildPaths();
674
+ applyFocus();
675
+ scheduleCityLayout();
676
+ attachTooltipObserver();
677
+ if (svgRef.value && typeof ResizeObserver !== "undefined") {
678
+ svgResizeObserver = new ResizeObserver((entries) => {
679
+ const rect = entries[0]?.contentRect;
680
+ if (!rect?.width) return;
681
+ // Replicate preserveAspectRatio="xMidYMid meet": a uniform scale to
682
+ // fit, centered — the letterbox offsets matter in fullscreen, where
683
+ // the svg box no longer matches the viewBox aspect ratio.
684
+ const s = rect.height
685
+ ? Math.min(rect.width / width.value, rect.height / height.value)
686
+ : rect.width / width.value;
687
+ viewScale.value = s;
688
+ meetOffsetX = (rect.width - s * width.value) / 2;
689
+ meetOffsetY = rect.height ? (rect.height - s * height.value) / 2 : 0;
690
+ // A map mounted without layout (hidden tab/panel) resolved its theme
691
+ // to constants; the first real layout is the moment to retry.
692
+ mapTheme.ensureResolved();
693
+ if (isCanvas.value) {
694
+ resizeCanvasSurface(rect.width, rect.height);
695
+ } else {
696
+ applyStrokeScale();
697
+ }
698
+ });
699
+ svgResizeObserver.observe(svgRef.value);
700
+ }
701
+ if (isCanvas.value) armDprListener();
702
+ window.addEventListener("scroll", dismissOnViewportChange, {
703
+ passive: true,
704
+ capture: true,
705
+ });
706
+ window.addEventListener("resize", dismissOnViewportChange, { passive: true });
707
+ });
708
+
709
+ onUnmounted(() => {
710
+ tooltipObserver?.disconnect();
711
+ svgResizeObserver?.disconnect();
712
+ dprQuery?.removeEventListener("change", onDprChange);
713
+ if (pendingMoveFrame) cancelAnimationFrame(pendingMoveFrame);
714
+ if (redrawFrame) cancelAnimationFrame(redrawFrame);
715
+ if (cityLayoutFrame) cancelAnimationFrame(cityLayoutFrame);
716
+ window.clearTimeout(pendingSelectTimer);
717
+ if (crispResizeTimer) window.clearTimeout(crispResizeTimer);
718
+ teardownZoom();
719
+ teardownInteraction();
720
+ window.removeEventListener("scroll", dismissOnViewportChange, {
721
+ capture: true,
722
+ });
723
+ window.removeEventListener("resize", dismissOnViewportChange);
724
+ });
725
+
726
+ function setupZoom() {
727
+ if (!svgRef.value || !mapGroupRef.value) return;
728
+
729
+ const svg = select(svgRef.value);
730
+ zoomBehavior = d3Zoom<SVGSVGElement, unknown>()
731
+ .scaleExtent([1, maxScale.value])
732
+ // d3-zoom swallows the click after any mousedown→mouseup movement
733
+ // beyond this distance. The default of 0 makes clicks unreliable the
734
+ // moment drag-pan is live (a pixel of hand jitter reads as a drag),
735
+ // so allow a few pixels of slack.
736
+ .clickDistance(6)
737
+ .on("start", () => {
738
+ isZooming = true;
739
+ })
740
+ .on("zoom", (event) => {
741
+ // Cleared here, not on gesture start: a plain mousedown opens a
742
+ // gesture once drag-pan is live, and hiding the tooltip on every
743
+ // click press felt broken. The tooltip only needs to go once the
744
+ // map actually moves under the cursor.
745
+ clearHover();
746
+ if (isCanvas.value) {
747
+ // Frames blit + progressively sharpen via the render pipeline.
748
+ requestRedraw();
749
+ } else if (mapGroupRef.value) {
750
+ mapGroupRef.value.setAttribute("transform", event.transform);
751
+ }
752
+ const t = event.transform;
753
+ scaleK.value = t.k;
754
+ applyStrokeScale();
755
+ // City markers live outside the zoomed group, so re-place them against
756
+ // the new transform (constant on-screen size, labels re-decluttered).
757
+ if (props.cities?.length) scheduleCityLayout();
758
+ isZoomed.value = t.k !== 1 || t.x !== 0 || t.y !== 0;
759
+ if (isZoomed.value) hasZoomed.value = true;
760
+ })
761
+ .on("end", () => {
762
+ isZooming = false;
763
+ // Sharpen after gestures: an idle-view frame starts a base refresh.
764
+ if (isCanvas.value) requestRedraw();
765
+ });
766
+
767
+ // Dynamic filter deciding which pointer gestures d3-zoom may handle —
768
+ // re-evaluated per event, so mode/activation changes never require
769
+ // tearing down the zoom behavior. Programmatic `.transform()` calls
770
+ // (focus zoom, the +/− controls, reset) bypass it entirely. Scroll mode
771
+ // skips every gate; otherwise, per gesture:
772
+ // - wheel: only while the map fills the window (body scroll is locked
773
+ // there, so there's no page scroll to hijack);
774
+ // - dblclick: desktop's activation + zoom-in gesture (touch uses taps);
775
+ // - mousedown (drag-pan): once activated or while filling the window;
776
+ // - touchstart (pan/pinch): while filling the window, or inline once
777
+ // `touchGesturesInline` says the map owns touch gestures.
778
+ zoomBehavior.filter((event) => {
779
+ if (!props.zoom) return false;
780
+ if (!isScrollMode.value) {
781
+ const expanded = fullscreen.isFullscreen.value;
782
+ switch (event.type) {
783
+ case "wheel":
784
+ if (!expanded) return false;
785
+ break;
786
+ case "dblclick":
787
+ if (isTouchDevice()) return false;
788
+ break;
789
+ case "mousedown":
790
+ if (!expanded && !hasZoomed.value) return false;
791
+ break;
792
+ case "touchstart":
793
+ if (!expanded && !touchGesturesInline.value) {
794
+ // In-place touch mode: a pinch (second finger) is itself the
795
+ // zoom entry gesture; single-finger drags stay with the page
796
+ // until the first zoom.
797
+ const pinchEntry =
798
+ !props.touchExpand &&
799
+ props.zoomMode !== "scroll" &&
800
+ (event as TouchEvent).touches.length >= 2;
801
+ if (!pinchEntry) return false;
802
+ }
803
+ break;
804
+ }
805
+ }
806
+ // Mirror d3-zoom's default rejections (ctrl-click, non-primary
807
+ // buttons); ctrl+wheel is trackpad pinch, so it stays allowed.
808
+ return (!event.ctrlKey || event.type === "wheel") && !event.button;
809
+ });
810
+
811
+ svg.call(zoomBehavior);
812
+ }
813
+
814
+ function teardownZoom() {
815
+ if (svgRef.value && zoomBehavior) {
816
+ select(svgRef.value).on(".zoom", null);
817
+ zoomBehavior = null;
818
+ }
819
+ }
820
+
821
+ // Resolved focus item: ties a user-supplied FocusItem to the actual
822
+ // GeoJSON feature it refers to plus a stable cross-geoType cache key.
823
+ interface ResolvedFocus {
824
+ item: FocusItem;
825
+ geoType: GeoType;
826
+ feature: ChoroplethFeature;
827
+ /** Stable key for overlay-path lifecycle: `${geoType}:${featureId}` */
828
+ key: string;
829
+ }
830
+
831
+ function resolveFocusItems(items: FocusItem[]): ResolvedFocus[] {
832
+ const lookups = featuresByGeoType.value;
833
+ const nameLookups = nameToIdByGeoType.value;
834
+ const out: ResolvedFocus[] = [];
835
+ for (const item of items) {
836
+ const geoType = item.geoType ?? props.geoType;
837
+ const lookup = lookups.get(geoType);
838
+ if (!lookup) continue;
839
+ let f = lookup.get(item.id);
840
+ if (!f) {
841
+ // Name fallback in the item's own geoType.
842
+ const id = nameLookups.get(geoType)?.get(item.id);
843
+ if (id) f = lookup.get(id);
844
+ }
845
+ if (!f) continue;
846
+ out.push({ item, geoType, feature: f, key: `${geoType}:${String(f.id)}` });
847
+ }
848
+ return out;
849
+ }
850
+
851
+ // Click-to-toggle uses only the base-geoType focus ids — clicks on
852
+ // overlay paths are blocked by pointer-events: none.
853
+ function focusedBaseIds(items: FocusItem[]): Set<string> {
854
+ const out = new Set<string>();
855
+ for (const r of resolveFocusItems(items)) {
856
+ if (r.geoType === props.geoType) out.add(String(r.feature.id));
857
+ }
858
+ return out;
859
+ }
860
+
861
+ // Duration (ms) of focus zoom-in and Reset-button zoom-out transitions.
862
+ // Initial mount applies instantly; clearing focus is a no-op on the
863
+ // transform (the reset button is the only path back to identity).
864
+ const FOCUS_ANIM_MS = 450;
865
+ // Tracks whether applyFocus has been called once — initial mount apply
866
+ // is instant, subsequent focus-in calls animate.
867
+ let focusApplied = false;
868
+
869
+ function applyFocus() {
870
+ if (!svgRef.value || !zoomBehavior) return;
871
+ const resolved = resolveFocusItems(normalizedFocus.value);
872
+
873
+ // Split into items that live in the base geoType (decorate the
874
+ // existing path) and items that need their own overlay path.
875
+ const baseResolved = resolved.filter((r) => r.geoType === props.geoType);
876
+ const overlayResolved = resolved.filter((r) => r.geoType !== props.geoType);
877
+
878
+ if (isCanvas.value) {
879
+ // Canvas backend: highlights and overlays are just draw state.
880
+ canvasFocused.clear();
881
+ for (const r of baseResolved) {
882
+ canvasFocused.set(String(r.feature.id), r.item);
883
+ }
884
+ const generator = pathGenerator.value;
885
+ canvasOverlays = overlayResolved.flatMap((r) => {
886
+ const d = generator(r.feature);
887
+ if (!d) return [];
888
+ return [
889
+ {
890
+ path: new Path2D(d),
891
+ stroke: r.item.stroke ?? "#fff",
892
+ strokeWidth: r.item.strokeWidth,
893
+ style: r.item.style,
894
+ },
895
+ ];
896
+ });
897
+ markBaseDirty();
898
+ requestRedraw();
899
+ } else {
900
+ // Diff base-geoType highlights, keyed by path element so we can
901
+ // restyle on style change without churning unrelated paths.
902
+ const nextBaseStyles = new Map<SVGPathElement, FocusItem>();
903
+ for (const r of baseResolved) {
904
+ const p = pathsByFeatureId.get(String(r.feature.id));
905
+ if (p) nextBaseStyles.set(p, r.item);
906
+ }
907
+ for (const [p] of focusedPathStyles) {
908
+ if (nextBaseStyles.has(p) || p === hoveredEl) continue;
909
+ restoreDefaultStroke(p);
910
+ }
911
+ for (const [p, item] of nextBaseStyles) {
912
+ const prev = focusedPathStyles.get(p);
913
+ const unchanged =
914
+ prev != null &&
915
+ prev.style === item.style &&
916
+ prev.stroke === item.stroke &&
917
+ prev.strokeWidth === item.strokeWidth;
918
+ if (unchanged && p !== hoveredEl) continue; // already styled
919
+ if (p !== hoveredEl) applyHighlightStroke(p, item);
920
+ }
921
+ focusedPathStyles.clear();
922
+ for (const [p, item] of nextBaseStyles) focusedPathStyles.set(p, item);
923
+
924
+ // Cross-geoType outlines render as non-interactive paths on top of
925
+ // the base layer.
926
+ syncOverlayPaths(overlayResolved);
927
+ }
928
+
929
+ // Clearing focus doesn't touch the zoom transform — the user keeps
930
+ // whatever pan/zoom they had. Only the reset button snaps back to
931
+ // identity. Drop the highlight + tooltip and we're done.
932
+ if (resolved.length === 0) {
933
+ focusApplied = true;
934
+ clearHover();
935
+ return;
936
+ }
937
+
938
+ // Highlight-only mode: the strokes + overlays above are applied; skip the
939
+ // pan/zoom transform so the current view stays put (click-to-select).
940
+ if (props.focusZoom === false) {
941
+ focusApplied = true;
942
+ return;
943
+ }
944
+
945
+ const svg = select(svgRef.value);
946
+ // Always cancel any in-flight transition first — d3-transition queues
947
+ // same-named transitions rather than replacing them, so rapid focus
948
+ // changes would otherwise chain animations end-to-end.
949
+ svg.interrupt();
950
+ // First apply (initial mount) is instant; subsequent focus-in animates.
951
+ const animate = focusApplied;
952
+ focusApplied = true;
953
+
954
+ // Combined bounding box over every resolved feature (regardless of
955
+ // geoType) so multi-layer focus zooms to fit them all.
956
+ const [[x0, y0], [x1, y1]] = pathGenerator.value.bounds({
957
+ type: "FeatureCollection",
958
+ features: resolved.map((r) => r.feature),
959
+ });
960
+ const cx = (x0 + x1) / 2;
961
+ const cy = (y0 + y1) / 2;
962
+ const k = props.focusZoomLevel;
963
+ const target = zoomIdentity
964
+ .translate(width.value / 2 - k * cx, height.value / 2 - k * cy)
965
+ .scale(k);
966
+
967
+ // Tooltip target: prefer the first base-geoType item (overlay paths
968
+ // are non-interactive and don't carry tooltip data). Falls back to
969
+ // skipping the tooltip entirely when only overlays are focused.
970
+ const tooltipTarget = baseResolved[0]?.feature ?? null;
971
+ const showFocusTooltip = () => {
972
+ if (!hasInteractiveTooltip.value || !tooltipTarget) return;
973
+ const firstId = String(tooltipTarget.id);
974
+ // Read positions *after* the transform commits so the tooltip lands
975
+ // at the focused feature's on-screen position.
976
+ if (isCanvas.value) {
977
+ const anchor = featureClientAnchor(firstId);
978
+ if (anchor) showTooltip(firstId, anchor.x, anchor.y);
979
+ return;
980
+ }
981
+ const pathEl = pathsByFeatureId.get(firstId);
982
+ if (!pathEl) return;
983
+ const rect = pathEl.getBoundingClientRect();
984
+ showTooltip(
985
+ firstId,
986
+ rect.left + rect.width / 2,
987
+ rect.top + rect.height / 2,
988
+ );
989
+ };
990
+
991
+ if (animate) {
992
+ // d3-zoom + d3-transition: `transition.call(zoomBehavior.transform,
993
+ // target)` interpolates the transform smoothly, firing the zoom
994
+ // callback per frame so pan + scale animate together. Hide any prior
995
+ // tooltip up front so it doesn't track the moving viewport; re-show
996
+ // once the new target is reached.
997
+ hideTooltip();
998
+ svg
999
+ .transition()
1000
+ .duration(FOCUS_ANIM_MS)
1001
+ .call(zoomBehavior.transform, target)
1002
+ .on("end", showFocusTooltip);
1003
+ } else {
1004
+ zoomBehavior.transform(svg, target);
1005
+ showFocusTooltip();
1006
+ }
1007
+ }
1008
+
1009
+ function syncOverlayPaths(items: ResolvedFocus[]) {
1010
+ const g = overlayGroupRef.value;
1011
+ if (!g) return;
1012
+
1013
+ const nextKeys = new Set(items.map((i) => i.key));
1014
+ for (const [key, entry] of overlayPathEls) {
1015
+ if (!nextKeys.has(key)) {
1016
+ entry.el.remove();
1017
+ overlayPathEls.delete(key);
1018
+ }
1019
+ }
1020
+
1021
+ const generator = pathGenerator.value;
1022
+ // Overlay strokes need extra weight: they paint on top of the base map
1023
+ // and the (optional) state-borders mesh, so a stroke that matches the
1024
+ // in-place focused base path's weight would visually merge with the
1025
+ // layers underneath. The default width lives on the overlay *group*;
1026
+ // per-item `strokeWidth` overrides are written per path — both
1027
+ // compensated by applyStrokeScale.
1028
+ for (const { item, feature: f, key } of items) {
1029
+ let entry = overlayPathEls.get(key);
1030
+ if (!entry) {
1031
+ const el = document.createElementNS(SVG_NS, "path") as SVGPathElement;
1032
+ el.setAttribute("d", generator(f) ?? "");
1033
+ el.setAttribute("fill", "none");
1034
+ el.setAttribute("pointer-events", "none");
1035
+ el.setAttribute("stroke-linejoin", "round");
1036
+ el.setAttribute("class", "focus-overlay");
1037
+ g.appendChild(el);
1038
+ entry = { el };
1039
+ overlayPathEls.set(key, entry);
1040
+ }
1041
+ entry.strokeWidth = item.strokeWidth;
1042
+ // White contrasts cleanly against the (typically dark) data-colored
1043
+ // fill; callers can override per-item via `FocusItem.stroke`.
1044
+ entry.el.setAttribute("stroke", item.stroke ?? "#fff");
1045
+ applyDasharray(entry.el, item.style);
1046
+ }
1047
+ applyStrokeScale();
1048
+ }
1049
+
1050
+ function resetZoom() {
1051
+ if (!svgRef.value || !zoomBehavior) return;
1052
+ // Reset both the zoom transform AND any active focus. The watcher's
1053
+ // applyFocus call (post-flush) only tears down the highlight strokes
1054
+ // now; this transition handles the actual zoom-out animation.
1055
+ if (normalizedFocus.value.length > 0) emit("update:focus", null);
1056
+ const svg = select(svgRef.value);
1057
+ svg.interrupt();
1058
+ hideTooltip();
1059
+ // In the inline touch flow (no tap-to-expand step), reset restores the
1060
+ // pre-zoom mode: once the zoom-out lands, deactivate so the page gets
1061
+ // touch scrolling back and the zoom hint returns. Desktop stays
1062
+ // activated (sticky), and inside fullscreen the ✕ handles leaving.
1063
+ const deactivate =
1064
+ isTouchDevice() && !fullscreen.isFullscreen.value && !isScrollMode.value;
1065
+ const transition = svg
1066
+ .transition()
1067
+ .duration(FOCUS_ANIM_MS)
1068
+ .call(zoomBehavior.transform, zoomIdentity);
1069
+ if (deactivate) {
1070
+ transition.on("end", () => {
1071
+ hasZoomed.value = false;
1072
+ });
1073
+ }
1074
+ }
1075
+
1076
+ // Ceiling always spans focusZoomLevel and at least the standard 12× so the
1077
+ // user can zoom further in/out of a focused view. Programmatic
1078
+ // `.transform()` calls are clamped to this range too.
1079
+ const maxScale = computed(() => Math.max(12, props.focusZoomLevel));
1080
+
1081
+ // Scale factor per +/− press and per desktop double-click (d3's built-in).
1082
+ const ZOOM_STEP = 2;
1083
+
1084
+ // The +/−/reset controls — only ever shown while zooming is enabled. On
1085
+ // desktop they're always present (pressing + is itself an activation
1086
+ // path). On touch they render inline only when the inline map is (or can
1087
+ // become) interactive (scroll mode / in-place tap zoom), plus always in
1088
+ // the expanded view. With `zoom: false` the map never shows them; a
1089
+ // parent driving `focus` owns its own way back.
1090
+ const showZoomControls = computed(
1091
+ () =>
1092
+ props.zoom &&
1093
+ (isTouchDevice()
1094
+ ? fullscreen.isFullscreen.value ||
1095
+ isScrollMode.value ||
1096
+ !props.touchExpand
1097
+ : true),
1098
+ );
1099
+
1100
+ // Drag-pan cursor affordance — desktop only, once drag-pan is available.
1101
+ const isPannable = computed(
1102
+ () =>
1103
+ props.zoom &&
1104
+ !isTouchDevice() &&
1105
+ (isScrollMode.value || hasZoomed.value || fullscreen.isFullscreen.value),
1106
+ );
1107
+
1108
+ // Grey affordance line shown while the zoom gesture is still the way in:
1109
+ // on desktop until the first zoom (the controls take over from there), on
1110
+ // touch whenever the inline map is showing (with tap-to-expand) or until
1111
+ // the first in-place zoom (without). Never while fullscreen or in scroll
1112
+ // mode — those are already interactive.
1113
+ const showZoomHint = computed(
1114
+ () =>
1115
+ props.zoom &&
1116
+ props.zoomHint &&
1117
+ !isScrollMode.value &&
1118
+ !fullscreen.isFullscreen.value &&
1119
+ ((isTouchDevice() && props.touchExpand) || !hasZoomed.value),
1120
+ );
1121
+ const zoomHintText = computed(() =>
1122
+ isTouchDevice() ? "Double tap to zoom" : "Double click to zoom",
1123
+ );
1124
+
1125
+ // Inline style for the map svg. `touch-action: none` hands pan/pinch to
1126
+ // d3-zoom (and blocks scroll chaining) wherever touch gestures belong to
1127
+ // the map. `will-change: transform` gives the svg its own compositor
1128
+ // layer while the interaction is live — without it WebKit repaints the
1129
+ // surrounding page layer on every zoom/pan frame (~10× slower on iOS).
1130
+ // Scoped to active maps so a page of static maps doesn't pay a raster
1131
+ // layer apiece.
1132
+ const svgStyle = computed(() => {
1133
+ const style: Record<string, string> = {};
1134
+ if (fullscreen.isFullscreen.value || touchGesturesInline.value) {
1135
+ style["touch-action"] = "none";
1136
+ } else if (props.zoom && !isScrollMode.value && isTouchDevice()) {
1137
+ // Inline, pre-zoom: keep page panning but claim the zoom gestures.
1138
+ // `manipulation` suppresses the browser's double-tap zoom (the expand
1139
+ // gesture); in-place mode also blocks browser pinch-zoom so a pinch
1140
+ // reaches d3-zoom as the entry gesture.
1141
+ style["touch-action"] = props.touchExpand ? "manipulation" : "pan-x pan-y";
1142
+ }
1143
+ if (
1144
+ hasZoomed.value ||
1145
+ fullscreen.isFullscreen.value ||
1146
+ (props.zoom && (isScrollMode.value || isTouchDevice()))
1147
+ ) {
1148
+ // Touch maps get the layer up front, not just once zoomed: a tap
1149
+ // restyles a feature path, and without the layer WebKit repaints the
1150
+ // surrounding page layer (~200ms per mutation on the HSA map — taps
1151
+ // felt sluggish inline while fullscreen, already layered, was fast).
1152
+ style["will-change"] = "transform";
1153
+ }
1154
+ return Object.keys(style).length ? style : undefined;
1155
+ });
1156
+
1157
+ function zoomBy(factor: number) {
1158
+ if (!svgRef.value || !zoomBehavior) return;
1159
+ const svg = select(svgRef.value);
1160
+ svg.interrupt();
1161
+ hideTooltip();
1162
+ // scaleBy centers on the viewBox extent's midpoint; scaleExtent clamps.
1163
+ svg.transition().duration(250).call(zoomBehavior.scaleBy, factor);
1164
+ }
1165
+
1166
+ // Zoom level a tap zooms to (expanding or in place).
1167
+ const TAP_ZOOM_SCALE = 2;
1168
+
1169
+ // client → canonical viewBox coordinates via the svg's CTM (which covers
1170
+ // viewBox scaling, letterboxing, and pinch-zoom quirks). Falls back to a
1171
+ // rect-proportional mapping when the CTM is unavailable (non-rendering
1172
+ // test DOMs).
1173
+ function clientToViewBox(
1174
+ clientX: number,
1175
+ clientY: number,
1176
+ ): [number, number] | null {
1177
+ const svgEl = svgRef.value;
1178
+ if (!svgEl) return null;
1179
+ let ctm: DOMMatrix | null = null;
1180
+ try {
1181
+ ctm = svgEl.getScreenCTM();
1182
+ } catch {
1183
+ ctm = null;
1184
+ }
1185
+ if (ctm) {
1186
+ const inv = ctm.inverse();
1187
+ return [
1188
+ inv.a * clientX + inv.c * clientY + inv.e,
1189
+ inv.b * clientX + inv.d * clientY + inv.f,
1190
+ ];
1191
+ }
1192
+ const rect = svgEl.getBoundingClientRect();
1193
+ const sx = rect.width ? width.value / rect.width : 1;
1194
+ const sy = rect.height ? height.value / rect.height : 1;
1195
+ return [(clientX - rect.left) * sx, (clientY - rect.top) * sy];
1196
+ }
1197
+
1198
+ // Target transform for a tap: TAP_ZOOM_SCALE× centered on the tapped
1199
+ // point. Falls back to the map center when the point can't be resolved.
1200
+ function tapZoomTransform(clientX: number, clientY: number): ZoomTransform {
1201
+ const svgEl = svgRef.value!;
1202
+ const k = TAP_ZOOM_SCALE;
1203
+ let mx = width.value / 2;
1204
+ let my = height.value / 2;
1205
+ const p = clientToViewBox(clientX, clientY);
1206
+ if (p) [mx, my] = zoomTransform(svgEl).invert(p);
1207
+ return zoomIdentity
1208
+ .translate(width.value / 2 - k * mx, height.value / 2 - k * my)
1209
+ .scale(k);
1210
+ }
1211
+
1212
+ function animateZoomTo(target: ZoomTransform) {
1213
+ if (!svgRef.value || !zoomBehavior) return;
1214
+ const svg = select(svgRef.value);
1215
+ svg.interrupt();
1216
+ svg.transition().duration(FOCUS_ANIM_MS).call(zoomBehavior.transform, target);
1217
+ }
1218
+
1219
+ // Tap on the inline touch map: expand to fill the window, then zoom in
1220
+ // centered on the tapped point once the expanded layout has committed.
1221
+ function enterTouchZoom(clientX: number, clientY: number) {
1222
+ if (!svgRef.value || !zoomBehavior) return;
1223
+ // Resolve the tapped point to map coords before the layout changes.
1224
+ const target = tapZoomTransform(clientX, clientY);
1225
+ fullscreen.enter();
1226
+ nextTick(() => animateZoomTo(target));
1227
+ }
1228
+
1229
+ // `touchExpand: false`: the first tap zooms the inline map in place
1230
+ // instead of expanding it (the touch analogue of desktop double-click).
1231
+ function zoomInPlaceAt(clientX: number, clientY: number) {
1232
+ if (!svgRef.value || !zoomBehavior) return;
1233
+ animateZoomTo(tapZoomTransform(clientX, clientY));
1234
+ }
1235
+
1236
+ // `focusZoomLevel` only affects scaleExtent + the next focus apply. The
1237
+ // d3-zoom filter reads `props.zoom` and the activation state dynamically,
1238
+ // so we don't need to tear down zoom on those changes.
1239
+ watch(
1240
+ () => props.focusZoomLevel,
1241
+ () => {
1242
+ if (zoomBehavior) {
1243
+ zoomBehavior.scaleExtent([1, maxScale.value]);
1244
+ }
1245
+ applyFocus();
1246
+ },
1247
+ );
1248
+
1249
+ // Switching the scoped `state` refits the projection to a different region,
1250
+ // so any leftover pan/zoom transform would leave the new map off-center.
1251
+ // Reset it instantly (no animation) when the region changes.
1252
+ watch(
1253
+ () => props.state,
1254
+ () => {
1255
+ if (!svgRef.value || !zoomBehavior) return;
1256
+ const svg = select(svgRef.value);
1257
+ svg.interrupt();
1258
+ zoomBehavior.transform(svg, zoomIdentity);
1259
+ },
1260
+ );
1261
+
1262
+ // Canonical internal coordinate system. All layout (projection, legend,
1263
+ // title) is computed at this size; the SVG's viewBox makes the browser
1264
+ // scale the entire canvas to whatever the container provides, so there's no
1265
+ // JS work on container resize. `props.width` / `props.height`, when set,
1266
+ // drive the rendered SVG element size but not these canonical coords.
1267
+ const CANONICAL_WIDTH = 1000;
1268
+ const aspectRatio = computed(() => {
1269
+ if (props.width && props.height) return props.height / props.width;
1270
+ return 0.625;
1271
+ });
1272
+ const width = computed(() => CANONICAL_WIDTH);
1273
+ const height = computed(() => CANONICAL_WIDTH * aspectRatio.value);
1274
+
1275
+ // Layout is fluid: the wrapper fills its parent's width and the SVG fills
1276
+ // the wrapper via CSS. `props.width` / `props.height`, when both are
1277
+ // passed, only shape the viewBox aspect ratio — they don't pin a display
1278
+ // size, so the map always scales to the available width without overflow.
1279
+
1280
+ type NamedGeometry = GeometryCollection<{ name: string }>;
1281
+ type StatesTopo = Topology<{ states: NamedGeometry }>;
1282
+ type CountiesTopo = Topology<{
1283
+ counties: NamedGeometry;
1284
+ states: NamedGeometry;
1285
+ }>;
1286
+
1287
+ type StateFeature = GeoJSON.Feature<GeoJSON.Geometry | null, { name?: string }>;
1288
+
1289
+ // ─── Single-state scoping (`state` prop) ─────────────────────────────────
1290
+ //
1291
+ // Resolved directly from the topology's `states` object (not from
1292
+ // featuresByGeoType) so it stays free of the featuresGeo → featuresById →
1293
+ // featuresByGeoType chain — that chain reads `stateFips`, so depending on it
1294
+ // here would form a reactive cycle.
1295
+ const statesFeatures = computed<StateFeature[]>(() => {
1296
+ const topo = toRaw(props.topology) as unknown as {
1297
+ objects?: { states?: NamedGeometry };
1298
+ };
1299
+ const statesObj = topo?.objects?.states;
1300
+ if (!statesObj) return [];
1301
+ const fc = feature(topo as unknown as Topology, statesObj) as
1302
+ | GeoJSON.FeatureCollection<GeoJSON.Geometry | null, { name?: string }>
1303
+ | StateFeature;
1304
+ return fc.type === "FeatureCollection" ? fc.features : [fc];
1305
+ });
1306
+
1307
+ // 2-digit FIPS for the active `state` prop, or null when unset/unresolved.
1308
+ const stateFips = computed<string | null>(() => {
1309
+ const s = props.state?.trim();
1310
+ if (!s) return null;
1311
+ if (/^\d{1,2}$/.test(s)) return s.padStart(2, "0");
1312
+ const match = statesFeatures.value.find((f) => f.properties?.name === s);
1313
+ return match?.id != null ? String(match.id).padStart(2, "0") : null;
1314
+ });
1315
+
1316
+ // The single state's GeoJSON feature — drives the outline path and the
1317
+ // projection fit in single-state mode.
1318
+ const stateOutlineFeature = computed<StateFeature | null>(() => {
1319
+ const fips = stateFips.value;
1320
+ if (!fips) return null;
1321
+ return (
1322
+ statesFeatures.value.find((f) => String(f.id).padStart(2, "0") === fips) ??
1323
+ null
1324
+ );
1325
+ });
1326
+
1327
+ watch(
1328
+ () => [props.state, stateFips.value] as const,
1329
+ ([state, fips]) => {
1330
+ if (state && state.trim() && !fips) {
1331
+ console.warn(
1332
+ `[ChoroplethMap] state="${state}" matched no state name or FIPS code; rendering the full map.`,
1333
+ );
1334
+ }
1335
+ },
1336
+ { immediate: true },
1337
+ );
1338
+
1339
+ // HSA mapping is loaded lazily — it's ~25KB gzipped and only needed when
1340
+ // geoType or dataGeoType is "hsas". Keeps the main bundle small for users
1341
+ // who only need states/counties maps.
1342
+ type HsaModule = typeof import("./hsaMapping.js");
1343
+ const hsaModule = ref<HsaModule | null>(null);
1344
+ let hsaModulePromise: Promise<HsaModule> | null = null;
1345
+ function loadHsaModule() {
1346
+ if (!hsaModulePromise) {
1347
+ hsaModulePromise = import("./hsaMapping.js").then((m) => {
1348
+ hsaModule.value = m;
1349
+ return m;
1350
+ });
1351
+ }
1352
+ return hsaModulePromise;
1353
+ }
1354
+ watch(
1355
+ () => {
1356
+ if (props.geoType === "hsas" || props.dataGeoType === "hsas") return true;
1357
+ // A mixed-level row can pull HSAs in (`data[].geoType`), and any state
1358
+ // override on an HSA base map needs the table to place its features.
1359
+ if (props.data?.some((d) => d.geoType === "hsas")) return true;
1360
+ const focus = props.focus;
1361
+ if (!focus) return false;
1362
+ const items = Array.isArray(focus) ? focus : [focus];
1363
+ return items.some((f) => typeof f !== "string" && f.geoType === "hsas");
1364
+ },
1365
+ (needsHsa) => {
1366
+ if (needsHsa) loadHsaModule();
1367
+ },
1368
+ { immediate: true },
1369
+ );
1370
+
1371
+ const hsaFeaturesGeo = computed(() => {
1372
+ const mod = hsaModule.value;
1373
+ if (!mod) return { type: "FeatureCollection" as const, features: [] };
1374
+ const { fipsToHsa, hsaNames } = mod;
1375
+ const topo = toRaw(props.topology) as unknown as CountiesTopo;
1376
+ // HSAs are unions of counties: a states-only topology can't build them.
1377
+ const countyGeometries = topo.objects?.counties?.geometries;
1378
+ if (!countyGeometries)
1379
+ return { type: "FeatureCollection" as const, features: [] };
1380
+ const scopeFips = stateFips.value;
1381
+ const groups = new Map<string, typeof countyGeometries>();
1382
+
1383
+ for (const geom of countyGeometries) {
1384
+ const fips = String(geom.id).padStart(5, "0");
1385
+ // Single-state mode: drop counties outside the state before grouping, so
1386
+ // the resulting HSAs only cover the selected state.
1387
+ if (scopeFips && fips.slice(0, 2) !== scopeFips) continue;
1388
+ const hsaCode = fipsToHsa[fips];
1389
+ if (!hsaCode) continue;
1390
+ if (!groups.has(hsaCode)) groups.set(hsaCode, []);
1391
+ groups.get(hsaCode)!.push(geom);
1392
+ }
1393
+
1394
+ const features: GeoJSON.Feature[] = [];
1395
+ for (const [hsaCode, geoms] of groups) {
1396
+ features.push({
1397
+ type: "Feature",
1398
+ id: hsaCode,
1399
+ properties: { name: hsaNames[hsaCode] ?? hsaCode },
1400
+ geometry: merge(topo as unknown as Topology, geoms as any),
1401
+ });
1402
+ }
1403
+
1404
+ return { type: "FeatureCollection" as const, features };
1405
+ });
1406
+
1407
+ // Every county in the topology, unscoped. Split out of `baseFeaturesGeo` so
1408
+ // the mixed-level code can index counties without depending on the base
1409
+ // geoType (and without re-running topojson's `feature()` per consumer).
1410
+ const countiesFeatures = computed<ChoroplethFeature[]>(() => {
1411
+ const topo = toRaw(props.topology) as unknown as {
1412
+ objects?: { counties?: NamedGeometry };
1413
+ };
1414
+ const obj = topo?.objects?.counties;
1415
+ if (!obj) return [];
1416
+ const fc = feature(topo as unknown as Topology, obj);
1417
+ return (
1418
+ fc.type === "FeatureCollection" ? fc.features : [fc]
1419
+ ) as ChoroplethFeature[];
1420
+ });
1421
+
1422
+ // Features at the base `geoType`, scoped by the `state` prop — the map before
1423
+ // any per-state level overrides (`data[].geoType`) are mixed in.
1424
+ const baseFeaturesGeo = computed<ChoroplethFeature[]>(() => {
1425
+ // hsaFeaturesGeo already honors `state` (it scopes the source counties).
1426
+ if (props.geoType === "hsas")
1427
+ return hsaFeaturesGeo.value.features as ChoroplethFeature[];
1428
+ const scopeFips = stateFips.value;
1429
+ if (props.geoType === "counties") {
1430
+ const fc = countiesFeatures.value;
1431
+ if (!scopeFips) return fc;
1432
+ return fc.filter(
1433
+ (f) => String(f.id).padStart(5, "0").slice(0, 2) === scopeFips,
1434
+ );
1435
+ }
1436
+ const fc = statesFeatures.value as ChoroplethFeature[];
1437
+ if (!scopeFips) return fc;
1438
+ return fc.filter((f) => String(f.id).padStart(2, "0") === scopeFips);
1439
+ });
1440
+
1441
+ // ─── Mixed geographic levels (`data[].geoType`) ──────────────────────────
1442
+ //
1443
+ // A data row that declares a level other than the base `geoType` re-tiles its
1444
+ // whole state at that level: a state row merges a county map's counties into
1445
+ // one shape, a county row splits a state map's state into counties. See
1446
+ // `mixedGeo.ts` for the substitution itself.
1447
+
1448
+ // HSA code → 2-digit state FIPS, inverted from the lazy FIPS→HSA table. HSA
1449
+ // codes aren't state-prefixed, so this is the only way to place one.
1450
+ const hsaToState = computed<Map<string, string> | null>(() => {
1451
+ const mod = hsaModule.value;
1452
+ if (!mod) return null;
1453
+ const m = new Map<string, string>();
1454
+ for (const fips in mod.fipsToHsa)
1455
+ m.set(mod.fipsToHsa[fips], fips.slice(0, 2));
1456
+ return m;
1457
+ });
1458
+
1459
+ const levelLookups = computed(() => {
1460
+ const build = (
1461
+ features: ChoroplethFeature[],
1462
+ ): LevelLookup<ChoroplethFeature> => {
1463
+ const byId = new Map<string, ChoroplethFeature>();
1464
+ const byName = new Map<string, string>();
1465
+ for (const f of features) {
1466
+ if (f.id == null) continue;
1467
+ const id = String(f.id);
1468
+ byId.set(id, f);
1469
+ if (f.properties?.name != null) byName.set(f.properties.name, id);
1470
+ }
1471
+ return { byId, byName };
1472
+ };
1473
+ // Getters, so indexing a level only happens if a row actually references it.
1474
+ return {
1475
+ states: () => build(statesFeatures.value as ChoroplethFeature[]),
1476
+ counties: () => build(countiesFeatures.value),
1477
+ hsas: () => build(hsaFeaturesGeo.value.features as ChoroplethFeature[]),
1478
+ };
1479
+ });
1480
+
1481
+ function levelLookup(level: GeoType): LevelLookup<ChoroplethFeature> | null {
1482
+ const lookup = levelLookups.value[level]();
1483
+ return lookup.byId.size ? lookup : null;
1484
+ }
1485
+
1486
+ const overrideResolution = computed(() =>
1487
+ resolveGeoOverrides(props.data, props.geoType, levelLookup, hsaToState.value),
1488
+ );
1489
+
1490
+ // Serialized first so the override map's *identity* only changes when the set
1491
+ // of overrides does. `featuresGeo` hangs off it, and rebuilding every path on
1492
+ // each `data` update (the animation case) would be a real cost.
1493
+ const geoOverrideKey = computed(() =>
1494
+ serializeOverrides(overrideResolution.value.overrides),
1495
+ );
1496
+ const geoOverrides = computed(() => parseOverrides(geoOverrideKey.value));
1497
+
1498
+ const mixedFeatures = computed(() =>
1499
+ mixFeatures(
1500
+ baseFeaturesGeo.value,
1501
+ props.geoType,
1502
+ geoOverrides.value,
1503
+ levelLookup,
1504
+ hsaToState.value,
1505
+ stateFips.value,
1506
+ ),
1507
+ );
1508
+
1509
+ /** Feature id → level, for features substituted by a `data[].geoType` row. */
1510
+ const featureLevelById = computed(() => mixedFeatures.value.levelById);
1511
+
1512
+ /** The level a rendered feature is drawn at. */
1513
+ function levelOf(featureId: string): GeoType {
1514
+ return featureLevelById.value.get(featureId) ?? props.geoType;
1515
+ }
1516
+
1517
+ const featuresGeo = computed(() => ({
1518
+ type: "FeatureCollection" as const,
1519
+ features: mixedFeatures.value.features,
1520
+ }));
1521
+
1522
+ watch(
1523
+ () => overrideResolution.value,
1524
+ ({ unresolved, conflicts }) => {
1525
+ if (unresolved.length) {
1526
+ console.warn(
1527
+ `[ChoroplethMap] data rows with a geoType that resolved to no state: ${unresolved.join(", ")}. ` +
1528
+ `Use a FIPS/HSA code or feature name available in that geoType.`,
1529
+ );
1530
+ }
1531
+ if (conflicts.length) {
1532
+ console.warn(
1533
+ `[ChoroplethMap] data rows claiming an already-mixed state at a different geoType: ${conflicts.join(", ")}. ` +
1534
+ `A state renders at one level; the first row wins.`,
1535
+ );
1536
+ }
1537
+ },
1538
+ { immediate: true },
1539
+ );
1540
+
1541
+ const stateBordersPath = computed(() => {
1542
+ if (props.geoType !== "counties" && props.geoType !== "hsas") return null;
1543
+ // Single-state mode: trace just the selected state's outline instead of
1544
+ // the full national state-border mesh.
1545
+ if (stateFips.value) return stateOutlineFeature.value;
1546
+ const topo = toRaw(props.topology) as unknown as CountiesTopo;
1547
+ return mesh(topo, topo.objects.states, (a, b) => a !== b);
1548
+ });
1549
+
1550
+ // Exterior boundary of the rendered geography (theme.outline): the nation
1551
+ // outline on national maps, the selected state's boundary in single-state
1552
+ // mode. Meshed from arcs used by exactly one feature (a === b), over the
1553
+ // counties object for county/HSA maps (an HSA union shares the counties'
1554
+ // exterior). Lazy: the arc pass only runs once a visible outline color
1555
+ // resolves, so maps without an outline never pay for it.
1556
+ const outlineMesh = computed<GeoJSON.MultiLineString | null>(() => {
1557
+ if (!resolvedTheme.value.outline) return null;
1558
+ const topo = toRaw(props.topology) as unknown as {
1559
+ objects?: { states?: NamedGeometry; counties?: NamedGeometry };
1560
+ };
1561
+ const useCounties = props.geoType === "counties" || props.geoType === "hsas";
1562
+ const obj = useCounties ? topo.objects?.counties : topo.objects?.states;
1563
+ if (!obj) return null;
1564
+ const scope = stateFips.value;
1565
+ if (!scope) {
1566
+ return mesh(topo as unknown as Topology, obj, (a, b) => a === b);
1567
+ }
1568
+ const pad = useCounties ? 5 : 2;
1569
+ const geometries = obj.geometries.filter(
1570
+ (g) => String(g.id).padStart(pad, "0").slice(0, 2) === scope,
1571
+ );
1572
+ return mesh(
1573
+ topo as unknown as Topology,
1574
+ { type: "GeometryCollection", geometries } as NamedGeometry,
1575
+ (a, b) => a === b,
1576
+ );
1577
+ });
1578
+
1579
+ // Breathing room (canonical px) around a single state so its outline isn't
1580
+ // flush against the SVG edge. Only applied in single-state mode.
1581
+ const STATE_FIT_INSET = 12;
1582
+
1583
+ // Resolved `tightFit` amount in [0,1]: false/0 → 0 (full fit), true → 1.
1584
+ const tightFitAmount = computed(() => {
1585
+ const v = props.tightFit;
1586
+ if (v === true) return 1;
1587
+ if (!v) return 0;
1588
+ return Math.max(0, Math.min(1, Number(v)));
1589
+ });
1590
+
1591
+ // Predicate marking a feature id as Alaska (FIPS 02) or Hawaii (15), so the
1592
+ // projection can re-fit to just the contiguous US. States/counties key off
1593
+ // the FIPS prefix directly; HSA ids are HSA codes, so an HSA has to be placed
1594
+ // through the `fipsToHsa` table (null until that lazy chunk loads, so the crop
1595
+ // kicks in once it's ready). Each id is read at the level it renders at, which
1596
+ // mixed-level maps make per-feature.
1597
+ const isAkHiFeature = computed<((id: string) => boolean) | null>(() => {
1598
+ const hsaStates = hsaToState.value;
1599
+ const usesHsas =
1600
+ props.geoType === "hsas" ||
1601
+ [...featureLevelById.value.values()].includes("hsas");
1602
+ if (usesHsas && !hsaStates) return null;
1603
+ return (id) => {
1604
+ const st = stateOfId(levelOf(id), id, hsaStates);
1605
+ return st === "02" || st === "15";
1606
+ };
1607
+ });
1608
+
1609
+ // The national fit target with Alaska and Hawaii removed, so the projection
1610
+ // can be re-fit to just the contiguous US. Null when the crop is off, in
1611
+ // single-state mode, before the AK/HI predicate is ready, or when nothing was
1612
+ // removed — the projection then uses the plain full fit.
1613
+ const conusFeaturesGeo = computed(() => {
1614
+ if (tightFitAmount.value <= 0 || stateFips.value) return null;
1615
+ const isAkHi = isAkHiFeature.value;
1616
+ if (!isAkHi) return null;
1617
+ const fc = featuresGeo.value;
1618
+ const kept = fc.features.filter((f) => !isAkHi(String(f.id)));
1619
+ if (kept.length === fc.features.length) return null;
1620
+ return { type: "FeatureCollection" as const, features: kept };
1621
+ });
1622
+
1623
+ const projection = computed(() => {
1624
+ const outline = stateOutlineFeature.value;
1625
+ if (stateFips.value && outline) {
1626
+ const extent: [[number, number], [number, number]] = [
1627
+ [STATE_FIT_INSET, STATE_FIT_INSET],
1628
+ [width.value - STATE_FIT_INSET, height.value - STATE_FIT_INSET],
1629
+ ];
1630
+ const albers = geoAlbersUsa().fitExtent(extent, outline);
1631
+ // geoAlbersUsa only covers the 50 states + DC (it handles Alaska and
1632
+ // Hawaii via insets). The island territories — Puerto Rico, Guam, the
1633
+ // US Virgin Islands, American Samoa, the N. Mariana Islands — fall
1634
+ // outside it and project to null, so fitExtent yields a NaN transform
1635
+ // and every path renders as "MNaN,NaN…". Detect that by projecting the
1636
+ // outline's centroid and fall back to a plain Mercator that can render
1637
+ // any region.
1638
+ const c = geoPath(albers).centroid(outline);
1639
+ if (Number.isFinite(c[0]) && Number.isFinite(c[1])) return albers;
1640
+ return geoMercator().fitExtent(extent, outline);
1641
+ }
1642
+ const extent: [[number, number], [number, number]] = [
1643
+ [0, 0],
1644
+ [width.value, height.value],
1645
+ ];
1646
+ const full = geoAlbersUsa().fitExtent(extent, featuresGeo.value);
1647
+ const conus = conusFeaturesGeo.value;
1648
+ if (!conus) return full;
1649
+ // Tighten the fit by interpolating the projection's scale + translate from
1650
+ // the full fit toward the contiguous-US fit. At amount 1 CONUS fills the
1651
+ // frame and Alaska's western tail (and Hawaii) clip into the lower-left
1652
+ // corner, cropped by the SVG/canvas viewport. Everything downstream (paths,
1653
+ // picking, tooltip anchors, focus zoom) derives from this projection, so
1654
+ // both renderers stay consistent.
1655
+ const tight = geoAlbersUsa().fitExtent(extent, conus);
1656
+ const t = tightFitAmount.value;
1657
+ const lerp = (a: number, b: number) => a + (b - a) * t;
1658
+ const [fx, fy] = full.translate();
1659
+ const [tx, ty] = tight.translate();
1660
+ return geoAlbersUsa()
1661
+ .scale(lerp(full.scale(), tight.scale()))
1662
+ .translate([lerp(fx, tx), lerp(fy, ty)]);
1663
+ });
1664
+
1665
+ const pathGenerator = computed(() => geoPath(projection.value));
1666
+
1667
+ // Default feature stroke width is halved on dense maps (counties/hsas);
1668
+ // an explicit theme.strokeWidth applies as-is on every geoType.
1669
+ const effectiveStrokeWidth = computed(() => {
1670
+ const w = resolvedTheme.value.strokeWidth;
1671
+ if (w != null) return w;
1672
+ return props.geoType === "counties" || props.geoType === "hsas" ? 0.25 : 0.5;
1673
+ });
1674
+
1675
+ const effectiveBordersWidth = computed(
1676
+ () => resolvedTheme.value.bordersWidth ?? 1,
1677
+ );
1678
+ const effectiveOutlineWidth = computed(
1679
+ () => resolvedTheme.value.outlineWidth ?? 1,
1680
+ );
1681
+
1682
+ // Per-geoType name → id index, mirroring featuresByGeoType. Drives the
1683
+ // "pass a feature name instead of an id" fallback for both `data` (with
1684
+ // dataGeoType applied) and `focus` (where each FocusItem can specify its
1685
+ // own geoType). O(features) per geoType, computed once per topology change.
1686
+ const nameToIdByGeoType = computed(() => {
1687
+ const map = new Map<GeoType, Map<string, string>>();
1688
+ for (const [gt, lookup] of featuresByGeoType.value) {
1689
+ const m = new Map<string, string>();
1690
+ for (const [id, f] of lookup) {
1691
+ if (f.properties?.name != null) m.set(f.properties.name, id);
1692
+ }
1693
+ map.set(gt, m);
1694
+ }
1695
+ return map;
1696
+ });
1697
+
1698
+ // Maps a base feature id to the id it should look up in `dataMap` under
1699
+ // the active `dataGeoType`. Supports county→hsa (via fipsToHsa), county→
1700
+ // state (FIPS prefix), and hsa→state (HSA-code prefix). Returns the id
1701
+ // unchanged when dataGeoType is unset or equal to the base geoType.
1702
+ function baseToDataId(baseId: string): string | undefined {
1703
+ const dataGt = props.dataGeoType;
1704
+ if (!dataGt || dataGt === props.geoType) return baseId;
1705
+ if (props.geoType === "counties" && dataGt === "hsas") {
1706
+ return hsaModule.value?.fipsToHsa[baseId];
1707
+ }
1708
+ if (props.geoType === "counties" && dataGt === "states") {
1709
+ return baseId.slice(0, 2);
1710
+ }
1711
+ if (props.geoType === "hsas" && dataGt === "states") {
1712
+ return baseId.slice(0, 2);
1713
+ }
1714
+ // Other combinations (e.g. coloring HSAs by per-county data) require
1715
+ // aggregation rules we haven't specified — silently treat as no data.
1716
+ return undefined;
1717
+ }
1718
+
1719
+ // id → feature lookup used by `applyFocus`. Cached so focus changes don't
1720
+ // trigger a linear scan through 3k+ features per apply.
1721
+ const featuresById = computed(() => {
1722
+ const m = new Map<string, ChoroplethFeature>();
1723
+ for (const f of featuresGeo.value.features) {
1724
+ if (f.id != null) m.set(String(f.id), f as ChoroplethFeature);
1725
+ }
1726
+ return m;
1727
+ });
1728
+
1729
+ // Normalized array form of `props.focus`. Bare strings collapse into
1730
+ // `{ id }` so downstream code only has to deal with FocusItem objects.
1731
+ const normalizedFocus = computed<FocusItem[]>(() => {
1732
+ const f = props.focus;
1733
+ if (f == null) return [];
1734
+ const arr = Array.isArray(f) ? f : [f];
1735
+ return arr.map((x) => (typeof x === "string" ? { id: x } : x));
1736
+ });
1737
+
1738
+ // Per-geoType feature lookups. Populated for any geoType the topology
1739
+ // supports (states-only topology → just "states"; counties topology →
1740
+ // all three, with HSAs derived via the FIPS→HSA mapping). Used by
1741
+ // resolveFocusItems so a single focus call can mix geoTypes.
1742
+ const featuresByGeoType = computed(() => {
1743
+ const map = new Map<GeoType, Map<string, ChoroplethFeature>>();
1744
+ // The base geoType is always represented by what's actually rendered (which
1745
+ // on a mixed-level map includes the substituted features).
1746
+ map.set(props.geoType, featuresById.value);
1747
+ for (const gt of ["states", "counties", "hsas"] as GeoType[]) {
1748
+ if (map.has(gt)) continue;
1749
+ const lookup = levelLookup(gt);
1750
+ if (lookup) map.set(gt, lookup.byId);
1751
+ }
1752
+ return map;
1753
+ });
1754
+
1755
+ const dataMap = computed(() => {
1756
+ const map = new Map<string, number | string>();
1757
+ if (!props.data) return map;
1758
+ // Name fallback resolves in whichever geoType the data is keyed by — the
1759
+ // row's own `geoType` when it declares one (mixed-level maps), else the
1760
+ // map-wide `dataGeoType`.
1761
+ const dataGt = props.dataGeoType ?? props.geoType;
1762
+ for (const d of props.data) {
1763
+ map.set(d.id, d.value);
1764
+ const fid = nameToIdByGeoType.value.get(d.geoType ?? dataGt)?.get(d.id);
1765
+ if (fid) map.set(fid, d.value);
1766
+ }
1767
+ return map;
1768
+ });
1769
+
1770
+ const extent = computed(() => {
1771
+ if (!props.data || props.data.length === 0) return { min: 0, max: 1 };
1772
+ let min = Infinity;
1773
+ let max = -Infinity;
1774
+ for (const d of props.data) {
1775
+ if (typeof d.value === "number") {
1776
+ if (d.value < min) min = d.value;
1777
+ if (d.value > max) max = d.value;
1778
+ }
1779
+ }
1780
+ if (!isFinite(min)) return { min: 0, max: 1 };
1781
+ if (min === max) return { min, max: min + 1 };
1782
+ return { min, max };
1783
+ });
1784
+
1785
+ const isCategorical = computed(
1786
+ () =>
1787
+ Array.isArray(props.colorScale) &&
1788
+ props.colorScale.length > 0 &&
1789
+ "value" in props.colorScale[0],
1790
+ );
1791
+
1792
+ const isThreshold = computed(
1793
+ () => Array.isArray(props.colorScale) && !isCategorical.value,
1794
+ );
1795
+
1796
+ const DEFAULT_MIN_COLOR = "#e5f0fa";
1797
+ const DEFAULT_MAX_COLOR = "#08519c";
1798
+
1799
+ const minColor = computed(() =>
1800
+ !isThreshold.value
1801
+ ? ((props.colorScale as ChoroplethColorScale | undefined)?.min ??
1802
+ DEFAULT_MIN_COLOR)
1803
+ : "",
1804
+ );
1805
+ const maxColor = computed(() =>
1806
+ !isThreshold.value
1807
+ ? ((props.colorScale as ChoroplethColorScale | undefined)?.max ??
1808
+ DEFAULT_MAX_COLOR)
1809
+ : "",
1810
+ );
1811
+
1812
+ // Sorted high-to-low so the first match wins (highest threshold ≤ value).
1813
+ // Cached so we don't re-sort 3k+ times during a rebuild.
1814
+ const thresholdStopsDesc = computed(() =>
1815
+ isThreshold.value
1816
+ ? (props.colorScale as ThresholdStop[])
1817
+ .slice()
1818
+ .sort((a, b) => b.min - a.min)
1819
+ : null,
1820
+ );
1821
+
1822
+ // Every colorScale color routed through the theme probe, so var() /
1823
+ // light-dark() endpoints and stop colors paint on both backends and
1824
+ // re-resolve on page theme flips. Order matches the mode's source array.
1825
+ // Raw colors double as their own fallbacks: a plain color still paints
1826
+ // where computed styles are unavailable.
1827
+ const scaleColorInputs = computed<string[]>(() => {
1828
+ if (isCategorical.value) {
1829
+ return (props.colorScale as CategoricalStop[]).map((s) => s.color);
1830
+ }
1831
+ const thresholds = thresholdStopsDesc.value;
1832
+ if (thresholds) return thresholds.map((s) => s.color);
1833
+ return [minColor.value, maxColor.value];
1834
+ });
1835
+
1836
+ const resolvedScaleColors = computed(() => {
1837
+ const colors = scaleColorInputs.value;
1838
+ return mapTheme.resolvePalette(colors, colors);
1839
+ });
1840
+
1841
+ // Unresolvable endpoint colors fall back to the default scale endpoints.
1842
+ function interpolateColor(t: number): string {
1843
+ const resolved = resolvedScaleColors.value;
1844
+ const [r1, g1, b1] = parseRgb(resolved[0] ?? "") ?? [229, 240, 250];
1845
+ const [r2, g2, b2] = parseRgb(resolved[1] ?? "") ?? [8, 81, 156];
1846
+ const r = Math.round(r1 + (r2 - r1) * t);
1847
+ const g = Math.round(g1 + (g2 - g1) * t);
1848
+ const b = Math.round(b1 + (b2 - b1) * t);
1849
+ return `rgb(${r},${g},${b})`;
1850
+ }
1851
+
1852
+ const categoricalByValue = computed(() => {
1853
+ if (!isCategorical.value) return null;
1854
+ const resolved = resolvedScaleColors.value;
1855
+ const m = new Map<string, string>();
1856
+ (props.colorScale as CategoricalStop[]).forEach((s, i) =>
1857
+ m.set(s.value, resolved[i] ?? s.color),
1858
+ );
1859
+ return m;
1860
+ });
1861
+
1862
+ /**
1863
+ * Looks up the data value for a rendered feature id, honoring `dataGeoType`.
1864
+ * Features substituted by a mixed-level row carry their own level, so they're
1865
+ * keyed by their own id and skip the `dataGeoType` remapping entirely.
1866
+ */
1867
+ function valueFor(featureId: string): number | string | undefined {
1868
+ if (featureLevelById.value.has(featureId))
1869
+ return dataMap.value.get(featureId);
1870
+ const dataId = baseToDataId(featureId);
1871
+ return dataId == null ? undefined : dataMap.value.get(dataId);
1872
+ }
1873
+
1874
+ /** Single color-resolution path. Missing rows get the theme's base fill. */
1875
+ function colorFor(id: string): string {
1876
+ const value = valueFor(id);
1877
+ const noData = resolvedTheme.value.fill;
1878
+ if (value == null) return noData;
1879
+ const cat = categoricalByValue.value;
1880
+ if (cat) return cat.get(String(value)) ?? noData;
1881
+ const thresholds = thresholdStopsDesc.value;
1882
+ if (thresholds) {
1883
+ const resolved = resolvedScaleColors.value;
1884
+ const n = value as number;
1885
+ for (let i = 0; i < thresholds.length; i++) {
1886
+ if (n >= thresholds[i].min) return resolved[i] ?? thresholds[i].color;
1887
+ }
1888
+ return noData;
1889
+ }
1890
+ const { min, max } = extent.value;
1891
+ return interpolateColor(((value as number) - min) / (max - min));
1892
+ }
1893
+
1894
+ const featureName = (
1895
+ feat: (typeof featuresGeo.value.features)[number],
1896
+ ): string => feat.properties?.name ?? String(feat.id);
1897
+
1898
+ function formatTooltipValue(value: number | string | undefined): string {
1899
+ if (value == null) return "";
1900
+ if (typeof value === "number" && props.tooltipValueFormat !== undefined) {
1901
+ return formatNumber(value, props.tooltipValueFormat);
1902
+ }
1903
+ return String(value);
1904
+ }
1905
+
1906
+ /** "Name" or "Name: formatted-value" — used for the SVG <title> fallback. */
1907
+ function titleText(name: string, value: number | string | undefined): string {
1908
+ return value == null ? name : `${name}: ${formatTooltipValue(value)}`;
1909
+ }
1910
+
1911
+ // ─── Tooltip (fully synchronous; positioning uses cached size) ───────────
1912
+ //
1913
+ // The flow is:
1914
+ // 1. mouseover → setData (Vue patches slot props on the *child*) → position
1915
+ // using lastTooltipSize (possibly stale by one frame) → visibility:visible
1916
+ // 2. tooltipObserver fires when the slot DOM has actually committed → we
1917
+ // refresh lastTooltipSize and re-apply the position if still visible.
1918
+ // 3. mousemove → rAF-throttled; re-runs the same flip/clamp placement as
1919
+ // the initial hover (direct DOM write of transform, no reactivity).
1920
+ // 4. mouseout (leaving the map) → visibility:hidden.
1921
+ //
1922
+ // There is no `await` and no token: out-of-order completion is impossible
1923
+ // because every step is synchronous from the event handler's perspective.
1924
+
1925
+ function attachTooltipObserver() {
1926
+ const el = tooltipChildRef.value?.getEl();
1927
+ if (!el) return;
1928
+ tooltipObserver?.disconnect();
1929
+ tooltipObserver = new ResizeObserver((entries) => {
1930
+ const entry = entries[0];
1931
+ if (!entry) return;
1932
+ // Cache the border-box size: `.chart-tooltip-content` carries padding
1933
+ // and a border, so contentRect under-measures by ~23px — enough to put
1934
+ // a left-flipped tooltip on top of the cursor instead of GAP away.
1935
+ const box = entry.borderBoxSize?.[0];
1936
+ lastTooltipSize.width = box ? box.inlineSize : entry.contentRect.width;
1937
+ lastTooltipSize.height = box ? box.blockSize : entry.contentRect.height;
1938
+ // Re-apply with the just-measured size. The hover positioned the
1939
+ // tooltip with the previous feature's size; when the new content is
1940
+ // wider, a left-flipped tooltip sits under the cursor (and can poke
1941
+ // past the clamp boundary) until corrected here.
1942
+ if (tooltipVisible && lastPointer) {
1943
+ applyTooltipPosition(lastPointer.x, lastPointer.y);
1944
+ }
1945
+ });
1946
+ tooltipObserver.observe(el);
1947
+ }
1948
+
1949
+ function applyTooltipPosition(clientX: number, clientY: number) {
1950
+ const el = tooltipChildRef.value?.getEl();
1951
+ if (!el) return;
1952
+ // Use the cached size — accurate after the first ResizeObserver tick. On
1953
+ // the very first show before the observer has fired, this falls through
1954
+ // placeTooltip's no-flip path (size 0 → no flip), which simply pins the
1955
+ // tooltip to the right of the cursor.
1956
+ const chartRect = containerRef.value?.getBoundingClientRect();
1957
+ const { left, top } = placeTooltip(
1958
+ clientX,
1959
+ clientY,
1960
+ lastTooltipSize.width,
1961
+ lastTooltipSize.height,
1962
+ props.tooltipClamp,
1963
+ chartRect,
1964
+ );
1965
+ el.style.transform = `translate3d(${left}px, ${top}px, 0) translateY(-50%)`;
1966
+ // Safari reports client coordinates (events AND element rects) in
1967
+ // visual-viewport space, while position: fixed resolves against the
1968
+ // layout viewport — under page pinch-zoom the tooltip lands offset from
1969
+ // its target (typically "too high" by the pan offset). Rather than
1970
+ // sniffing engines, self-calibrate: re-measure where the tooltip
1971
+ // actually landed — the rect comes back in the same client space as the
1972
+ // target coords — and shift by the residual. Chrome measures ~0.
1973
+ const vv = typeof window !== "undefined" ? window.visualViewport : null;
1974
+ if (!vv || (vv.scale <= 1.01 && vv.offsetTop < 1 && vv.offsetLeft < 1)) {
1975
+ return;
1976
+ }
1977
+ const actual = el.getBoundingClientRect();
1978
+ const dx = actual.left - left;
1979
+ const dy = actual.top + actual.height / 2 - top;
1980
+ if (Math.abs(dx) > 1 || Math.abs(dy) > 1) {
1981
+ el.style.transform = `translate3d(${left - dx}px, ${top - dy}px, 0) translateY(-50%)`;
1982
+ }
1983
+ }
1984
+
1985
+ // The expanded touch view swaps the pointer-anchored tooltip for a bottom
1986
+ // sheet: floating fixed positioning is unreliable there (Safari reports
1987
+ // touch/rect coordinates in visual-viewport space once the page is
1988
+ // pinch-zoomed), while a sheet pinned to the wrapper is always right.
1989
+ const tooltipMode = computed<"float" | "sheet">(() =>
1990
+ fullscreen.isFullscreen.value && isTouchDevice() ? "sheet" : "float",
1991
+ );
1992
+
1993
+ function showTooltip(featId: string, clientX: number, clientY: number) {
1994
+ const data = tooltipDataById.get(featId);
1995
+ if (!data) return;
1996
+ const child = tooltipChildRef.value;
1997
+ if (!child) return;
1998
+ child.setData(data);
1999
+ tooltipVisible = true;
2000
+ if (tooltipMode.value === "sheet") {
2001
+ child.setOpen(true);
2002
+ return;
2003
+ }
2004
+ const el = child.getEl();
2005
+ if (!el) return;
2006
+ lastPointer = { x: clientX, y: clientY };
2007
+ applyTooltipPosition(clientX, clientY);
2008
+ el.style.visibility = "visible";
2009
+ }
2010
+
2011
+ function moveTooltip(clientX: number, clientY: number) {
2012
+ if (!tooltipVisible || tooltipMode.value === "sheet") return;
2013
+ pendingMoveX = clientX;
2014
+ pendingMoveY = clientY;
2015
+ if (pendingMoveFrame) return;
2016
+ pendingMoveFrame = requestAnimationFrame(() => {
2017
+ pendingMoveFrame = 0;
2018
+ if (!tooltipVisible) return;
2019
+ lastPointer = { x: pendingMoveX, y: pendingMoveY };
2020
+ // Re-run the same flip/clamp as the initial hover. This is already
2021
+ // rAF-coalesced (at most once per frame), so the flip/clamp cost is
2022
+ // negligible. Hardcoding the right side here instead made the tooltip
2023
+ // ignore the boundary while moving and fight showTooltip's per-feature
2024
+ // flip on dense maps (the tooltip appeared to switch sides).
2025
+ applyTooltipPosition(pendingMoveX, pendingMoveY);
2026
+ });
2027
+ }
2028
+
2029
+ function hideTooltip() {
2030
+ if (!tooltipVisible) return;
2031
+ tooltipVisible = false;
2032
+ lastPointer = null;
2033
+ tooltipChildRef.value?.setOpen(false);
2034
+ const el = tooltipChildRef.value?.getEl();
2035
+ if (el) el.style.visibility = "hidden";
2036
+ }
2037
+
2038
+ /**
2039
+ * Sets `stroke-dasharray` (and `stroke-linecap` for round dots) on a
2040
+ * path element to match the requested FocusStyle. Used both for
2041
+ * highlighted base paths and for cross-geoType overlay paths.
2042
+ */
2043
+ function applyDasharray(el: SVGPathElement, style?: FocusStyle) {
2044
+ if (style === "dashed") {
2045
+ // Long dash + short gap so the pattern reads clearly even when the
2046
+ // overlay is painted on top of a similarly-colored parent-border
2047
+ // mesh.
2048
+ el.setAttribute("stroke-dasharray", "8 4");
2049
+ el.removeAttribute("stroke-linecap");
2050
+ } else if (style === "dotted") {
2051
+ // 0-length dashes with round caps render as evenly-spaced dots.
2052
+ el.setAttribute("stroke-dasharray", "0 5");
2053
+ el.setAttribute("stroke-linecap", "round");
2054
+ } else {
2055
+ el.removeAttribute("stroke-dasharray");
2056
+ el.removeAttribute("stroke-linecap");
2057
+ }
2058
+ }
2059
+
2060
+ // ─── Stroke-width compensation (no `vector-effect`) ──────────────────────
2061
+ //
2062
+ // WebKit renders `vector-effect: non-scaling-stroke` across thousands of
2063
+ // paths dramatically slowly (the docs page dropped to ~3fps on iOS), so
2064
+ // visual stroke widths are compensated by hand instead: a width of `w`
2065
+ // CSS px is written as `w / (zoom scale × viewBox-to-CSS scale)`. Base
2066
+ // feature paths inherit one group-level width; only the borders mesh,
2067
+ // focus overlays (via their group), and highlighted paths carry their own.
2068
+
2069
+ /** Divisor turning a visual CSS-px width into an attribute width. */
2070
+ function strokeDivisor(): number {
2071
+ return scaleK.value * viewScale.value || 1;
2072
+ }
2073
+
2074
+ // Highlight (hover + focus) outline color for the SVG backend: the RAW
2075
+ // theme value (default `var(--choropleth-highlight, light-dark(#000,
2076
+ // #fff))`), applied via inline style so the browser resolves it live —
2077
+ // SVG presentation attributes don't parse CSS functions. The canvas
2078
+ // backend paints the probe-resolved `resolvedTheme.highlight` instead.
2079
+ // On browsers without light-dark() the var()'s fallback is invalid at
2080
+ // computed-value time and the stroke would compute to `none`, erasing
2081
+ // the highlight; degrade to the light constant there, mirroring the
2082
+ // resolver's adaptive gate.
2083
+ const supportsLightDark =
2084
+ typeof CSS !== "undefined" &&
2085
+ typeof CSS.supports === "function" &&
2086
+ CSS.supports("color", "light-dark(#fff, #000)");
2087
+ const highlightInlineStroke = computed(() => {
2088
+ if (props.theme?.highlight) return props.theme.highlight;
2089
+ return supportsLightDark
2090
+ ? mapThemeDefaults.highlight
2091
+ : `var(--choropleth-highlight, ${LIGHT_HIGHLIGHT})`;
2092
+ });
2093
+
2094
+ /** Visual outline width for an in-place highlight (hover or focus). */
2095
+ function highlightWidthFor(item?: FocusItem): number {
2096
+ return item?.strokeWidth ?? effectiveStrokeWidth.value + 1;
2097
+ }
2098
+
2099
+ // ─── Canvas renderer: redraw, picking, tooltip anchors ───────────────────
2100
+
2101
+ function currentCanvasView(): CanvasViewState {
2102
+ const t = zoomTransform(svgRef.value!);
2103
+ return {
2104
+ dpr: (typeof window !== "undefined" && window.devicePixelRatio) || 1,
2105
+ meetScale: viewScale.value,
2106
+ offsetX: meetOffsetX,
2107
+ offsetY: meetOffsetY,
2108
+ zoom: { k: t.k, x: t.x, y: t.y },
2109
+ };
2110
+ }
2111
+
2112
+ function canvasDrawState(): CanvasDrawState {
2113
+ const t = resolvedTheme.value;
2114
+ return {
2115
+ strokeColor: t.stroke,
2116
+ strokeWidth: effectiveStrokeWidth.value,
2117
+ bordersColor: t.borders,
2118
+ bordersWidth: effectiveBordersWidth.value,
2119
+ outlineColor: t.outline,
2120
+ outlineWidth: effectiveOutlineWidth.value,
2121
+ background: t.background,
2122
+ highlightStroke: t.highlight,
2123
+ hoveredId: canvasHoveredId,
2124
+ focused: canvasFocused,
2125
+ overlays: canvasOverlays,
2126
+ };
2127
+ }
2128
+
2129
+ // Direct rendering with an adaptive buffered fallback. With the canvas on
2130
+ // its own compositor layer and feature outlines stroked as one path, a
2131
+ // full county-scale pass rasters in single-digit milliseconds, so every
2132
+ // frame renders the whole scene crisply. If a device proves slower (two
2133
+ // consecutive full draws over SLOW_FRAME_MS), gesture frames fall back to
2134
+ // blitting the last crisp render, refreshed at least every
2135
+ // GESTURE_REFRESH_MS, and always sharpened at rest.
2136
+ const SLOW_FRAME_MS = 24;
2137
+ const GESTURE_REFRESH_MS = 350;
2138
+ let slowDrawStreak = 0;
2139
+ let fastDrawStreak = 0;
2140
+ let rendererIsSlow = false;
2141
+ // Canvas backing stores are sized in device pixels, and ResizeObserver
2142
+ // doesn't fire when the window moves to a display with a different
2143
+ // devicePixelRatio. Watch a resolution media query instead — it matches
2144
+ // only the current DPR, so each change re-arms a fresh query (the
2145
+ // standard trick), resizes the backing store, and redraws.
2146
+ let dprQuery: MediaQueryList | null = null;
2147
+
2148
+ function armDprListener() {
2149
+ if (
2150
+ typeof window === "undefined" ||
2151
+ typeof window.matchMedia !== "function"
2152
+ ) {
2153
+ return;
2154
+ }
2155
+ dprQuery?.removeEventListener("change", onDprChange);
2156
+ dprQuery = window.matchMedia(
2157
+ `(resolution: ${window.devicePixelRatio || 1}dppx)`,
2158
+ );
2159
+ dprQuery.addEventListener("change", onDprChange);
2160
+ }
2161
+
2162
+ function onDprChange() {
2163
+ armDprListener();
2164
+ const canvas = canvasRef.value;
2165
+ const svgEl = svgRef.value;
2166
+ if (!isCanvas.value || !canvas || !svgEl) return;
2167
+ const rect = svgEl.getBoundingClientRect();
2168
+ if (rect.width) {
2169
+ const dpr = window.devicePixelRatio || 1;
2170
+ canvas.width = Math.max(1, Math.round(rect.width * dpr));
2171
+ canvas.height = Math.max(1, Math.round(rect.height * dpr));
2172
+ }
2173
+ // Synchronous: resizing the backing store above cleared it, so redraw now
2174
+ // rather than leaving it blank until the next frame.
2175
+ redrawNow();
2176
+ }
2177
+
2178
+ // The last full base render (fills + outlines + borders + focus + overlays,
2179
+ // but NOT the transient hover) is cached to this offscreen canvas along with
2180
+ // the view it was rendered at. It backs two fast paths in drawNow: an exact
2181
+ // blit when only the hover changed (base + view identical), and a scaled blit
2182
+ // during slow-device gestures / resizes (base identical, view moving).
2183
+ let snapshotCanvas: HTMLCanvasElement | null = null;
2184
+ let snapshotView: CanvasViewState | null = null;
2185
+ let lastFullDrawAt = 0;
2186
+ // The base content (colors, geometry, focus, overlays, strokes) changed since
2187
+ // the cached snapshot, so it can't be reused — force a full render. Hover,
2188
+ // zoom, and resize do NOT dirty the base (only the hover-on-top or the view).
2189
+ let baseDirty = true;
2190
+ // Debounced crisp redraw after a slow-device resize settles.
2191
+ const RESIZE_SETTLE_MS = 120;
2192
+ let crispResizeTimer = 0;
2193
+
2194
+ /** Base content changed → the snapshot is stale; the next draw must be full. */
2195
+ function markBaseDirty() {
2196
+ baseDirty = true;
2197
+ }
2198
+
2199
+ /** Two views map canonical coords to device pixels identically. */
2200
+ function sameCanvasView(a: CanvasViewState, b: CanvasViewState): boolean {
2201
+ return (
2202
+ a.dpr === b.dpr &&
2203
+ a.meetScale === b.meetScale &&
2204
+ a.offsetX === b.offsetX &&
2205
+ a.offsetY === b.offsetY &&
2206
+ a.zoom.k === b.zoom.k &&
2207
+ a.zoom.x === b.zoom.x &&
2208
+ a.zoom.y === b.zoom.y
2209
+ );
2210
+ }
2211
+
2212
+ function blitSnapshot(
2213
+ ctx: CanvasRenderingContext2D,
2214
+ view: CanvasViewState,
2215
+ img: HTMLCanvasElement,
2216
+ imgView: CanvasViewState,
2217
+ ) {
2218
+ const off = (v: CanvasViewState, axis: "x" | "y") =>
2219
+ v.dpr *
2220
+ ((axis === "x" ? v.offsetX : v.offsetY) +
2221
+ v.meetScale * (axis === "x" ? v.zoom.x : v.zoom.y));
2222
+ const r =
2223
+ (view.dpr * view.meetScale * view.zoom.k) /
2224
+ (imgView.dpr * imgView.meetScale * imgView.zoom.k);
2225
+ ctx.setTransform(
2226
+ r,
2227
+ 0,
2228
+ 0,
2229
+ r,
2230
+ off(view, "x") - r * off(imgView, "x"),
2231
+ off(view, "y") - r * off(imgView, "y"),
2232
+ );
2233
+ ctx.drawImage(img, 0, 0);
2234
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
2235
+ }
2236
+
2237
+ // Copy the just-drawn base (hover NOT yet applied) into the offscreen cache,
2238
+ // tagged with the view it was rendered at. Must be called after drawBase but
2239
+ // before drawHoverHighlight so the cache stays hover-free.
2240
+ function captureBaseSnapshot(
2241
+ display: HTMLCanvasElement,
2242
+ view: CanvasViewState,
2243
+ ) {
2244
+ if (typeof document === "undefined") {
2245
+ snapshotView = null;
2246
+ return;
2247
+ }
2248
+ if (!snapshotCanvas) snapshotCanvas = document.createElement("canvas");
2249
+ if (snapshotCanvas.width !== display.width) {
2250
+ snapshotCanvas.width = display.width;
2251
+ }
2252
+ if (snapshotCanvas.height !== display.height) {
2253
+ snapshotCanvas.height = display.height;
2254
+ }
2255
+ const sctx = snapshotCanvas.getContext("2d");
2256
+ if (sctx && typeof sctx.drawImage === "function") {
2257
+ sctx.setTransform(1, 0, 0, 1, 0, 0);
2258
+ sctx.clearRect(0, 0, snapshotCanvas.width, snapshotCanvas.height);
2259
+ sctx.drawImage(display, 0, 0);
2260
+ snapshotView = view;
2261
+ } else {
2262
+ snapshotView = null;
2263
+ }
2264
+ }
2265
+
2266
+ function drawNow() {
2267
+ const display = canvasRef.value;
2268
+ if (!display) return;
2269
+ const ctx = display.getContext("2d");
2270
+ if (!ctx) return;
2271
+ const now = () =>
2272
+ typeof performance !== "undefined" ? performance.now() : 0;
2273
+ if (!scene) {
2274
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
2275
+ ctx.clearRect(0, 0, display.width, display.height);
2276
+ return;
2277
+ }
2278
+ const view = currentCanvasView();
2279
+ const canBlit = typeof ctx.drawImage === "function";
2280
+
2281
+ // Fast hover path: only the hover highlight changed — the base content and
2282
+ // the view match the cache — so blit the cached base and redraw the single
2283
+ // highlight instead of re-filling every feature. Falls through to a full
2284
+ // render whenever the cache can't be trusted, so a miss is never wrong,
2285
+ // only slower.
2286
+ if (
2287
+ !baseDirty &&
2288
+ canBlit &&
2289
+ snapshotCanvas &&
2290
+ snapshotView &&
2291
+ snapshotCanvas.width === display.width &&
2292
+ snapshotCanvas.height === display.height &&
2293
+ sameCanvasView(view, snapshotView)
2294
+ ) {
2295
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
2296
+ ctx.clearRect(0, 0, display.width, display.height);
2297
+ ctx.drawImage(snapshotCanvas, 0, 0);
2298
+ if (canvasHoveredId) {
2299
+ drawHoverHighlight(ctx, scene, view, canvasDrawState(), canvasHoveredId);
2300
+ }
2301
+ return;
2302
+ }
2303
+
2304
+ // Slow-device gesture fallback: the base is unchanged but the view is moving
2305
+ // (a live zoom), so blit it scaled between throttled crisp refreshes.
2306
+ if (
2307
+ !baseDirty &&
2308
+ rendererIsSlow &&
2309
+ isZooming &&
2310
+ canBlit &&
2311
+ snapshotCanvas &&
2312
+ snapshotView &&
2313
+ now() - lastFullDrawAt < GESTURE_REFRESH_MS
2314
+ ) {
2315
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
2316
+ ctx.clearRect(0, 0, display.width, display.height);
2317
+ blitSnapshot(ctx, view, snapshotCanvas, snapshotView);
2318
+ if (canvasHoveredId) {
2319
+ drawHoverHighlight(ctx, scene, view, canvasDrawState(), canvasHoveredId);
2320
+ }
2321
+ return;
2322
+ }
2323
+
2324
+ // Full render: base pass (clears), then cache it, then hover on top.
2325
+ const t0 = now();
2326
+ drawBase(ctx, scene, view, canvasDrawState());
2327
+ const dt = now() - t0;
2328
+ lastFullDrawAt = t0 + dt;
2329
+ baseDirty = false;
2330
+ // Symmetric hysteresis: two consecutive slow full draws engage the
2331
+ // fallback, two consecutive fast ones release it — so a cold-start
2332
+ // hiccup (JIT warmup, first-paint contention) can't lock a fast device
2333
+ // into blurry gesture blits forever.
2334
+ if (dt > SLOW_FRAME_MS) {
2335
+ fastDrawStreak = 0;
2336
+ if (++slowDrawStreak >= 2) rendererIsSlow = true;
2337
+ } else {
2338
+ slowDrawStreak = 0;
2339
+ if (++fastDrawStreak >= 2) rendererIsSlow = false;
2340
+ }
2341
+ // Cache the hover-free base for the fast paths above (all devices — the
2342
+ // hover reuse needs it even on fast renderers), then draw hover on top.
2343
+ captureBaseSnapshot(display, view);
2344
+ if (canvasHoveredId) {
2345
+ drawHoverHighlight(ctx, scene, view, canvasDrawState(), canvasHoveredId);
2346
+ }
2347
+ }
2348
+
2349
+ // rAF-coalesced: gesture zooms can emit several events per frame.
2350
+ function requestRedraw() {
2351
+ if (!isCanvas.value || redrawFrame) return;
2352
+ redrawFrame = requestAnimationFrame(() => {
2353
+ redrawFrame = 0;
2354
+ drawNow();
2355
+ });
2356
+ }
2357
+
2358
+ // Synchronous repaint for resize / DPR changes: those handlers resize the
2359
+ // backing store (which clears it), so a deferred rAF redraw would leave the
2360
+ // canvas blank for a frame — a drag-resize flashes in and out. Drawing now
2361
+ // (and dropping any pending rAF so it can't double-draw) keeps it filled.
2362
+ function redrawNow() {
2363
+ if (!isCanvas.value) return;
2364
+ if (redrawFrame) {
2365
+ cancelAnimationFrame(redrawFrame);
2366
+ redrawFrame = 0;
2367
+ }
2368
+ drawNow();
2369
+ }
2370
+
2371
+ // After a slow-device resize drag stops, repaint crisply once.
2372
+ function scheduleCrispResize() {
2373
+ if (typeof window === "undefined") return;
2374
+ if (crispResizeTimer) window.clearTimeout(crispResizeTimer);
2375
+ crispResizeTimer = window.setTimeout(() => {
2376
+ crispResizeTimer = 0;
2377
+ requestRedraw();
2378
+ }, RESIZE_SETTLE_MS);
2379
+ }
2380
+
2381
+ // Resize the canvas backing store to the new svg box and repaint. The resize
2382
+ // clears the backing store, so we always repaint in the same tick to avoid a
2383
+ // blank frame. On a renderer measured as slow, a full redraw every drag tick
2384
+ // would lag, so instead blit the cached base scaled to the new box (smooth,
2385
+ // slightly soft) and defer one crisp redraw until the drag settles. Fast
2386
+ // renderers (or before we've measured one as slow) just repaint crisply now.
2387
+ function resizeCanvasSurface(rectWidth: number, rectHeight: number) {
2388
+ const canvas = canvasRef.value;
2389
+ if (!canvas) return;
2390
+ // Pin the canvas over the svg box, then size its backing store in device px.
2391
+ pinToSvgBox(canvas);
2392
+ const dpr = (typeof window !== "undefined" && window.devicePixelRatio) || 1;
2393
+ canvas.width = Math.max(1, Math.round(rectWidth * dpr));
2394
+ canvas.height = Math.max(1, Math.round(rectHeight * dpr));
2395
+
2396
+ if (
2397
+ rendererIsSlow &&
2398
+ !baseDirty &&
2399
+ scene &&
2400
+ snapshotCanvas &&
2401
+ snapshotView &&
2402
+ typeof document !== "undefined"
2403
+ ) {
2404
+ const ctx = canvas.getContext("2d");
2405
+ if (ctx && typeof ctx.drawImage === "function") {
2406
+ const view = currentCanvasView();
2407
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
2408
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
2409
+ blitSnapshot(ctx, view, snapshotCanvas, snapshotView);
2410
+ if (canvasHoveredId) {
2411
+ drawHoverHighlight(
2412
+ ctx,
2413
+ scene,
2414
+ view,
2415
+ canvasDrawState(),
2416
+ canvasHoveredId,
2417
+ );
2418
+ }
2419
+ scheduleCrispResize();
2420
+ return;
2421
+ }
2422
+ }
2423
+ redrawNow();
2424
+ }
2425
+
2426
+ // Feature under a client point via the picking canvas — O(1) per query
2427
+ // regardless of feature count. The zoom transform is unwound here; the
2428
+ // picking bitmap itself is only rebuilt on geometry changes.
2429
+ function pickFeatureAt(clientX: number, clientY: number): string | null {
2430
+ const svgEl = svgRef.value;
2431
+ if (!svgEl || !scene || !pickingCtx) return null;
2432
+ const p = clientToViewBox(clientX, clientY);
2433
+ if (!p) return null;
2434
+ const [mx, my] = zoomTransform(svgEl).invert(p);
2435
+ const idx = pickIndexAt(pickingCtx, scene, mx, my);
2436
+ return idx == null ? null : scene.items[idx].id;
2437
+ }
2438
+
2439
+ // Client-coordinate center of a feature — the canvas-mode stand-in for a
2440
+ // path element's getBoundingClientRect (tooltip anchoring).
2441
+ function featureClientAnchor(featId: string): { x: number; y: number } | null {
2442
+ const svgEl = svgRef.value;
2443
+ const f = featuresById.value.get(featId);
2444
+ if (!svgEl || !f) return null;
2445
+ const [[x0, y0], [x1, y1]] = pathGenerator.value.bounds(f);
2446
+ const [vx, vy] = zoomTransform(svgEl).apply([(x0 + x1) / 2, (y0 + y1) / 2]);
2447
+ let ctm: DOMMatrix | null = null;
2448
+ try {
2449
+ ctm = svgEl.getScreenCTM();
2450
+ } catch {
2451
+ ctm = null;
2452
+ }
2453
+ if (!ctm) return null;
2454
+ return {
2455
+ x: ctm.a * vx + ctm.c * vy + ctm.e,
2456
+ y: ctm.b * vx + ctm.d * vy + ctm.f,
2457
+ };
2458
+ }
2459
+
2460
+ function applyStrokeScale() {
2461
+ if (isCanvas.value) return; // stroke widths are computed at draw time
2462
+ const d = strokeDivisor();
2463
+ const eff = effectiveStrokeWidth.value;
2464
+ baseGroupRef.value?.setAttribute("stroke-width", String(eff / d));
2465
+ bordersPathEl?.setAttribute(
2466
+ "stroke-width",
2467
+ String(effectiveBordersWidth.value / d),
2468
+ );
2469
+ outlinePathEl?.setAttribute(
2470
+ "stroke-width",
2471
+ String(effectiveOutlineWidth.value / d),
2472
+ );
2473
+ overlayGroupRef.value?.setAttribute("stroke-width", String((eff + 1.5) / d));
2474
+ for (const { el, strokeWidth } of overlayPathEls.values()) {
2475
+ if (strokeWidth != null) {
2476
+ el.setAttribute("stroke-width", String(strokeWidth / d));
2477
+ } else {
2478
+ el.removeAttribute("stroke-width");
2479
+ }
2480
+ }
2481
+ for (const [p, item] of focusedPathStyles) {
2482
+ p.setAttribute("stroke-width", String(highlightWidthFor(item) / d));
2483
+ }
2484
+ if (hoveredEl && !focusedPathStyles.has(hoveredEl)) {
2485
+ hoveredEl.setAttribute("stroke-width", String(highlightWidthFor() / d));
2486
+ }
2487
+ }
2488
+
2489
+ function applyHighlightStroke(pathEl: SVGPathElement, item?: FocusItem) {
2490
+ // Bring path to top so its thicker border isn't clipped by neighbors.
2491
+ // Skip for overlay paths (they live above everything and own their
2492
+ // own z-order via syncOverlayPaths).
2493
+ pathEl.parentNode?.appendChild(pathEl);
2494
+ pathEl.setAttribute(
2495
+ "stroke-width",
2496
+ String(highlightWidthFor(item) / strokeDivisor()),
2497
+ );
2498
+ pathEl.style.stroke = item?.stroke ?? highlightInlineStroke.value;
2499
+ applyDasharray(pathEl, item?.style);
2500
+ }
2501
+
2502
+ function restoreDefaultStroke(pathEl: SVGPathElement) {
2503
+ // Dropping the attribute lets the path inherit the compensated width
2504
+ // from the base group again.
2505
+ pathEl.removeAttribute("stroke-width");
2506
+ pathEl.style.stroke = "";
2507
+ pathEl.setAttribute("stroke", resolvedTheme.value.stroke);
2508
+ pathEl.removeAttribute("stroke-dasharray");
2509
+ pathEl.removeAttribute("stroke-linecap");
2510
+ // applyHighlightStroke raised the path above the borders mesh and the
2511
+ // exterior outline; lower it back so those layers aren't left occluded
2512
+ // along this feature's edges. Order among feature paths doesn't matter
2513
+ // (they only meet at shared edges), so any slot before the anchor works.
2514
+ // The position check keeps bulk restores (updateStrokes) from moving
2515
+ // paths that were never raised.
2516
+ const anchor = bordersPathEl ?? outlinePathEl;
2517
+ if (
2518
+ anchor?.parentNode &&
2519
+ anchor.parentNode === pathEl.parentNode &&
2520
+ anchor.compareDocumentPosition(pathEl) & Node.DOCUMENT_POSITION_FOLLOWING
2521
+ ) {
2522
+ anchor.parentNode.insertBefore(pathEl, anchor);
2523
+ }
2524
+ }
2525
+
2526
+ function setHover(pathEl: SVGPathElement) {
2527
+ if (hoveredEl === pathEl) return;
2528
+ if (hoveredEl && !focusedPathStyles.has(hoveredEl)) {
2529
+ // Restore previous hover unless it's also focused — focus keeps the
2530
+ // highlight on its own.
2531
+ restoreDefaultStroke(hoveredEl);
2532
+ }
2533
+ hoveredEl = pathEl;
2534
+ // Hover styling follows whatever focus styling is in effect (or the
2535
+ // defaults).
2536
+ applyHighlightStroke(pathEl, focusedPathStyles.get(pathEl));
2537
+ }
2538
+
2539
+ // Renderer-agnostic hover entry point: canvas mode tracks an id and
2540
+ // repaints; svg mode restyles the path element.
2541
+ function setHoverId(featId: string) {
2542
+ if (isCanvas.value) {
2543
+ if (canvasHoveredId === featId) return;
2544
+ canvasHoveredId = featId;
2545
+ requestRedraw();
2546
+ return;
2547
+ }
2548
+ const p = pathsByFeatureId.get(featId);
2549
+ if (p) setHover(p);
2550
+ }
2551
+
2552
+ function clearHover() {
2553
+ if (isCanvas.value) {
2554
+ if (canvasHoveredId != null) {
2555
+ canvasHoveredId = null;
2556
+ // The hover highlight lives on the display layer, so dropping it
2557
+ // is just another cheap composite frame.
2558
+ requestRedraw();
2559
+ emit("stateHover", null);
2560
+ }
2561
+ hideTooltip();
2562
+ return;
2563
+ }
2564
+ if (hoveredEl) {
2565
+ const focusItem = focusedPathStyles.get(hoveredEl);
2566
+ if (focusItem == null) {
2567
+ restoreDefaultStroke(hoveredEl);
2568
+ } else {
2569
+ // Restore the focused styling (in case hover overwrote a dashed
2570
+ // or custom-styled item).
2571
+ applyHighlightStroke(hoveredEl, focusItem);
2572
+ }
2573
+ hoveredEl = null;
2574
+ emit("stateHover", null);
2575
+ }
2576
+ hideTooltip();
2577
+ }
2578
+
2579
+ // ─── Delegated event handlers (single set of listeners on the <g>) ───────
2580
+
2581
+ function eventToFeatureId(target: EventTarget | null): string | null {
2582
+ let el = target as Element | null;
2583
+ while (el && !(el as HTMLElement).dataset?.featId) el = el.parentElement;
2584
+ return el ? ((el as HTMLElement).dataset.featId ?? null) : null;
2585
+ }
2586
+
2587
+ // Emits stateClick plus the baked-in click-to-focus toggle so
2588
+ // `v-model:focus="ref"` Just Works: selecting the currently focused feature
2589
+ // clears focus (emits null); selecting any other feature emits its id. With
2590
+ // a `focus` array, any selection of a member clears everything — parents
2591
+ // wanting fine-grained multi-select handle merging via `@update:focus`.
2592
+ // Shared by the mouse-click and touch-tap paths.
2593
+ function emitSelection(data: TooltipPayload) {
2594
+ emit("stateClick", { id: data.id, name: data.name, value: data.value });
2595
+ const wasFocused = focusedBaseIds(normalizedFocus.value).has(data.id);
2596
+ emit("update:focus", wasFocused ? null : data.id);
2597
+ }
2598
+
2599
+ function onDelegatedEvent(event: Event) {
2600
+ // Only hover is suppressed mid-gesture (stroke churn while the map
2601
+ // moves); clicks stay live — even during zoom animations — since d3
2602
+ // already swallows genuine post-drag clicks via clickDistance.
2603
+ if (event.type === "mouseover" && isZooming) return;
2604
+ const me = event as MouseEvent;
2605
+ const featId = featureIdFromEvent(me.target, me.clientX, me.clientY);
2606
+ if (!featId) return;
2607
+ const data = tooltipDataById.get(featId);
2608
+ if (!data) return;
2609
+ const payload = { id: data.id, name: data.name, value: data.value };
2610
+ if (event.type === "click") {
2611
+ if (props.zoom) {
2612
+ window.clearTimeout(pendingSelectTimer);
2613
+ pendingSelectTimer = 0;
2614
+ // detail > 1 is the second click of a double-click — that gesture is
2615
+ // a zoom (handled by d3), not a select.
2616
+ if (me.detail <= 1) {
2617
+ pendingSelectTimer = window.setTimeout(() => {
2618
+ pendingSelectTimer = 0;
2619
+ emitSelection(data);
2620
+ }, CLICK_SELECT_DELAY_MS);
2621
+ }
2622
+ } else {
2623
+ emitSelection(data);
2624
+ }
2625
+ } else if (event.type === "mouseover") {
2626
+ setHoverId(featId);
2627
+ if (hasInteractiveTooltip.value)
2628
+ showTooltip(featId, me.clientX, me.clientY);
2629
+ emit("stateHover", payload);
2630
+ }
2631
+ }
2632
+
2633
+ function onDelegatedMouseMove(event: MouseEvent) {
2634
+ if (isZooming) return;
2635
+ moveTooltip(event.clientX, event.clientY);
2636
+ }
2637
+
2638
+ function onDelegatedMouseOut(event: MouseEvent) {
2639
+ const related = event.relatedTarget as Element | null;
2640
+ if (related && mapGroupRef.value?.contains(related)) return;
2641
+ clearHover();
2642
+ }
2643
+
2644
+ // Canvas mode has no per-feature elements to delegate mouseover to —
2645
+ // hover is resolved by picking on every mousemove instead (a 1px read
2646
+ // from a CPU canvas; cheap even at mousemove rates).
2647
+ function onCanvasMouseMove(event: MouseEvent) {
2648
+ if (isZooming) return;
2649
+ const featId = pickFeatureAt(event.clientX, event.clientY);
2650
+ if (featId === canvasHoveredId) {
2651
+ if (featId) moveTooltip(event.clientX, event.clientY);
2652
+ return;
2653
+ }
2654
+ if (!featId) {
2655
+ clearHover();
2656
+ return;
2657
+ }
2658
+ const data = tooltipDataById.get(featId);
2659
+ if (!data) return;
2660
+ setHoverId(featId);
2661
+ emit("stateHover", { id: data.id, name: data.name, value: data.value });
2662
+ if (hasInteractiveTooltip.value) {
2663
+ showTooltip(featId, event.clientX, event.clientY);
2664
+ }
2665
+ }
2666
+
2667
+ function onCanvasMouseLeave() {
2668
+ clearHover();
2669
+ }
2670
+
2671
+ // Renderer-agnostic feature resolution for pointer events.
2672
+ function featureIdFromEvent(
2673
+ target: EventTarget | null,
2674
+ clientX: number,
2675
+ clientY: number,
2676
+ ): string | null {
2677
+ return isCanvas.value
2678
+ ? pickFeatureAt(clientX, clientY)
2679
+ : eventToFeatureId(target);
2680
+ }
2681
+
2682
+ function onTouchStart(event: TouchEvent) {
2683
+ // Single-finger only — a second touch is a pinch-zoom, not a selection.
2684
+ if (event.touches.length !== 1) {
2685
+ tapStart = null;
2686
+ return;
2687
+ }
2688
+ const t = event.touches[0]!;
2689
+ tapStart = {
2690
+ x: t.clientX,
2691
+ y: t.clientY,
2692
+ time: event.timeStamp,
2693
+ featId: featureIdFromEvent(event.target, t.clientX, t.clientY),
2694
+ };
2695
+ }
2696
+
2697
+ function onTouchEnd(event: TouchEvent) {
2698
+ const start = tapStart;
2699
+ tapStart = null;
2700
+ // A second finger joined in — that's a pinch, not a tap.
2701
+ if (!start || event.touches.length > 0) return;
2702
+ const t = event.changedTouches[0];
2703
+ if (!t) return;
2704
+ // Moved too far (a pan) or held too long (a long-press) → not a tap.
2705
+ if (Math.abs(t.clientX - start.x) > TAP_SLOP) return;
2706
+ if (Math.abs(t.clientY - start.y) > TAP_SLOP) return;
2707
+ if (event.timeStamp - start.time > TAP_MAX_MS) return;
2708
+ // Inline touch map with zoom on (activate mode): a *double tap*
2709
+ // anywhere (features and background alike) is the zoom gesture —
2710
+ // expanding to fill the window by default, or zooming in place with
2711
+ // `touchExpand: false` (a pinch works there too). Single taps select,
2712
+ // deferred by the double-tap window so the second tap of a double tap
2713
+ // can cancel them — mirroring the desktop click debounce. Scroll mode
2714
+ // and the expanded view skip all of this: taps there select directly.
2715
+ if (
2716
+ props.zoom &&
2717
+ !isScrollMode.value &&
2718
+ isTouchDevice() &&
2719
+ !fullscreen.isFullscreen.value
2720
+ ) {
2721
+ // Suppress the synthetic click/hover the browser would replay from
2722
+ // this tap (and iOS's hover-first tap). Guard on cancelable: a
2723
+ // touchend fired while the page is mid-scroll is non-cancelable, and
2724
+ // preventDefault there is a no-op that only logs a console intervention.
2725
+ if (event.cancelable) event.preventDefault();
2726
+ const prev = lastTap;
2727
+ lastTap = { x: t.clientX, y: t.clientY, time: event.timeStamp };
2728
+ const isDoubleTap =
2729
+ prev != null &&
2730
+ event.timeStamp - prev.time <= CLICK_SELECT_DELAY_MS &&
2731
+ Math.abs(t.clientX - prev.x) <= DOUBLE_TAP_SLOP &&
2732
+ Math.abs(t.clientY - prev.y) <= DOUBLE_TAP_SLOP;
2733
+ if (isDoubleTap) {
2734
+ lastTap = null;
2735
+ window.clearTimeout(pendingSelectTimer);
2736
+ pendingSelectTimer = 0;
2737
+ if (props.touchExpand) {
2738
+ enterTouchZoom(t.clientX, t.clientY);
2739
+ } else {
2740
+ zoomInPlaceAt(t.clientX, t.clientY);
2741
+ }
2742
+ return;
2743
+ }
2744
+ const data = start.featId ? tooltipDataById.get(start.featId) : undefined;
2745
+ if (!data) {
2746
+ // Background tap: dismiss any tap-hover state.
2747
+ clearHover();
2748
+ return;
2749
+ }
2750
+ // Highlight + tooltip respond instantly; only the selection emits
2751
+ // wait out the double-tap window.
2752
+ touchHover(data, t.clientX, t.clientY);
2753
+ window.clearTimeout(pendingSelectTimer);
2754
+ pendingSelectTimer = window.setTimeout(() => {
2755
+ pendingSelectTimer = 0;
2756
+ emitSelection(data);
2757
+ }, CLICK_SELECT_DELAY_MS);
2758
+ return;
2759
+ }
2760
+ if (!start.featId) {
2761
+ clearHover();
2762
+ return;
2763
+ }
2764
+ const data = tooltipDataById.get(start.featId);
2765
+ if (!data) return;
2766
+ // Suppress the synthetic click/hover the browser would replay from this
2767
+ // tap, so selection fires exactly once and never via iOS's hover-first
2768
+ // tap. Guard on cancelable — a touchend fired mid page-scroll is
2769
+ // non-cancelable, and preventDefault there only logs an intervention.
2770
+ if (event.cancelable) event.preventDefault();
2771
+ touchHover(data, t.clientX, t.clientY);
2772
+ emitSelection(data);
2773
+ }
2774
+
2775
+ // A tap doubles as the touch replacement for hover: apply the hover-style
2776
+ // highlight, announce it, and show the tooltip. (Continuous hover
2777
+ // *tracking* stays off on touch — see setupInteraction — this is the
2778
+ // one-shot equivalent.)
2779
+ function touchHover(data: TooltipPayload, clientX: number, clientY: number) {
2780
+ setHoverId(data.id);
2781
+ emit("stateHover", { id: data.id, name: data.name, value: data.value });
2782
+ if (!hasInteractiveTooltip.value) return;
2783
+ // Anchor to the feature, not the finger: element rects and fixed
2784
+ // positioning share the layout-viewport coordinate space, so this
2785
+ // stays put when the page itself is pinch-zoomed — Safari reports
2786
+ // touch client coords in *visual*-viewport space, which would skew a
2787
+ // finger-anchored tooltip. Also keeps it out from under the finger.
2788
+ if (isCanvas.value) {
2789
+ const anchor = featureClientAnchor(data.id);
2790
+ if (anchor) showTooltip(data.id, anchor.x, anchor.y);
2791
+ else showTooltip(data.id, clientX, clientY);
2792
+ return;
2793
+ }
2794
+ const pathEl = pathsByFeatureId.get(data.id);
2795
+ if (pathEl) {
2796
+ const rect = pathEl.getBoundingClientRect();
2797
+ showTooltip(
2798
+ data.id,
2799
+ rect.left + rect.width / 2,
2800
+ rect.top + rect.height / 2,
2801
+ );
2802
+ } else {
2803
+ showTooltip(data.id, clientX, clientY);
2804
+ }
2805
+ }
2806
+
2807
+ function onTouchCancel() {
2808
+ tapStart = null;
2809
+ }
2810
+
2811
+ // ─── Imperative SVG path management ──────────────────────────────────────
2812
+ //
2813
+ // 3,000+ counties are too many to round-trip through Vue's render scheduler
2814
+ // on every reactive change. We build the SVG path tree once per feature set
2815
+ // and mutate attributes directly when data/styling changes.
2816
+
2817
+ function makePath(d: string | null): SVGPathElement {
2818
+ const p = document.createElementNS(SVG_NS, "path") as SVGPathElement;
2819
+ if (d) p.setAttribute("d", d);
2820
+ return p;
2821
+ }
2822
+
2823
+ // ─── City markers overlay ────────────────────────────────────────────────
2824
+ const CITY_LABEL_PX = 11;
2825
+ const CITY_DOT_PX = 2.4;
2826
+ const CITY_LABEL_GAP_PX = 3;
2827
+ // Halo widths (px). Set as attributes, not CSS, so they scale with viewScale
2828
+ // like font-size and stay a constant on-screen thickness under any map size.
2829
+ const CITY_MARKER_HALO_PX = 0.9;
2830
+ const CITY_LABEL_HALO_PX = 2.6;
2831
+
2832
+ // Marker-layer theme styling. The layer is SVG in both renderers, so
2833
+ // colors apply as inline CSS custom properties (the browser resolves
2834
+ // var() / light-dark() and follows theme flips on its own — no probe
2835
+ // involved); opacity goes on the group. JS values override any
2836
+ // stylesheet-level `--choropleth-city-*` override, matching the rest of
2837
+ // the theme.
2838
+ const cityLayerStyle = computed(() => {
2839
+ const t = props.theme;
2840
+ const style: Record<string, string> = {};
2841
+ if (t?.markerColor) {
2842
+ style["--choropleth-city-marker"] = t.markerColor;
2843
+ style["--choropleth-city-label-color"] = t.markerColor;
2844
+ }
2845
+ if (t?.markerHalo) style["--choropleth-city-halo"] = t.markerHalo;
2846
+ if (t?.markerOpacity != null) style.opacity = String(t.markerOpacity);
2847
+ return style;
2848
+ });
2849
+
2850
+ // Pin an absolutely-positioned overlay (the canvas backend, or the city svg)
2851
+ // exactly over the interaction svg's box. The wrapper can include an in-flow
2852
+ // header (title/legend) above the map, so the svg doesn't start at the wrapper
2853
+ // top; derive the offset from the two rects (svg elements have no offsetTop).
2854
+ function pinToSvgBox(el: HTMLElement | SVGElement | null): void {
2855
+ const svgEl = svgRef.value;
2856
+ const wrapRect = containerRef.value?.getBoundingClientRect();
2857
+ if (!el || !svgEl || !wrapRect) return;
2858
+ const svgRect = svgEl.getBoundingClientRect();
2859
+ el.style.top = `${svgRect.top - wrapRect.top}px`;
2860
+ el.style.left = `${svgRect.left - wrapRect.left}px`;
2861
+ el.style.width = `${svgRect.width}px`;
2862
+ el.style.height = `${svgRect.height}px`;
2863
+ }
2864
+
2865
+ // Project + place city markers and paint them into cityLayerRef. Runs in JS
2866
+ // (not via the mapGroup transform) so it's identical across svg/canvas mode.
2867
+ // rAF-coalesced through scheduleCityLayout — safe to call on every zoom frame.
2868
+ function renderCityLayer() {
2869
+ const g = cityLayerRef.value;
2870
+ if (!g) return;
2871
+
2872
+ const cities = props.cities;
2873
+ const proj = projection.value;
2874
+ if (!cities || cities.length === 0 || !proj) {
2875
+ while (g.firstChild) g.removeChild(g.firstChild);
2876
+ return;
2877
+ }
2878
+
2879
+ // Pin the overlay to the map svg box BEFORE clearing the layer below, so
2880
+ // getBoundingClientRect reads the settled layout instead of the one the
2881
+ // removeChild loop would dirty (which would force a synchronous reflow every
2882
+ // zoom frame). The box only changes on resize / header toggle.
2883
+ pinToSvgBox(cityOverlayRef.value);
2884
+ while (g.firstChild) g.removeChild(g.firstChild);
2885
+
2886
+ const t = svgRef.value ? zoomTransform(svgRef.value) : { k: 1, x: 0, y: 0 };
2887
+ // Level-of-detail: on a zoomable map, each city shows only once zoomed to its
2888
+ // `minZoom` (falling back to the `citiesMinZoom` prop), so bigger cities can
2889
+ // appear first and more reveal as you zoom in. A static map (zoom off) can't
2890
+ // zoom, so all its cities always show. (The layer was already cleared above.)
2891
+ const visible = props.zoom
2892
+ ? cities.filter((c) => t.k >= (c.minZoom ?? props.citiesMinZoom))
2893
+ : cities;
2894
+ if (visible.length === 0) return;
2895
+ const placed = layoutCities(
2896
+ visible,
2897
+ (coord) => proj(coord),
2898
+ { k: t.k, x: t.x, y: t.y },
2899
+ {
2900
+ width: width.value,
2901
+ height: height.value,
2902
+ viewScale: viewScale.value,
2903
+ labelPx: CITY_LABEL_PX,
2904
+ dotPx: CITY_DOT_PX,
2905
+ gapPx: CITY_LABEL_GAP_PX,
2906
+ margin: 4,
2907
+ },
2908
+ );
2909
+
2910
+ const vs = viewScale.value || 1;
2911
+ const font = CITY_LABEL_PX / vs;
2912
+ const markerHalo = (props.theme?.markerHaloWidth ?? CITY_MARKER_HALO_PX) / vs;
2913
+ const labelHalo = CITY_LABEL_HALO_PX / vs;
2914
+ const frag = document.createDocumentFragment();
2915
+ for (const c of placed) {
2916
+ const marker = document.createElementNS(SVG_NS, "circle");
2917
+ marker.setAttribute("cx", String(c.x));
2918
+ marker.setAttribute("cy", String(c.y));
2919
+ marker.setAttribute("r", String(c.radius));
2920
+ marker.setAttribute("class", "choropleth-city-dot");
2921
+ marker.setAttribute("stroke-width", String(markerHalo));
2922
+ frag.appendChild(marker);
2923
+
2924
+ if (c.label) {
2925
+ const text = document.createElementNS(SVG_NS, "text");
2926
+ text.setAttribute("x", String(c.label.x));
2927
+ text.setAttribute("y", String(c.label.y));
2928
+ text.setAttribute("text-anchor", c.label.anchor);
2929
+ text.setAttribute("dominant-baseline", "central");
2930
+ text.setAttribute("font-size", String(font));
2931
+ text.setAttribute("stroke-width", String(labelHalo));
2932
+ text.setAttribute(
2933
+ "class",
2934
+ c.capital
2935
+ ? "choropleth-city-label choropleth-city-label-capital"
2936
+ : "choropleth-city-label",
2937
+ );
2938
+ text.textContent = c.name;
2939
+ frag.appendChild(text);
2940
+ }
2941
+ }
2942
+ g.appendChild(frag);
2943
+ }
2944
+
2945
+ // rAF-coalesced: zoom/pan can fire several events per frame, and the v-if `<g>`
2946
+ // isn't in the DOM until after Vue's patch, which rAF defers past.
2947
+ function scheduleCityLayout() {
2948
+ if (cityLayoutFrame) return;
2949
+ cityLayoutFrame = requestAnimationFrame(() => {
2950
+ cityLayoutFrame = 0;
2951
+ renderCityLayer();
2952
+ });
2953
+ }
2954
+
2955
+ function rebuildPaths() {
2956
+ const baseG = baseGroupRef.value;
2957
+ const overlayG = overlayGroupRef.value;
2958
+ if (!baseG || !overlayG) return;
2959
+ // Only the base group is wiped — the overlay group stays in place so
2960
+ // applyFocus can repopulate it. Overlay path *elements* are dropped
2961
+ // from the tracking map so applyFocus rebuilds them against the new
2962
+ // base tree (their `d` attributes are derived from `pathGenerator`).
2963
+ while (baseG.firstChild) baseG.removeChild(baseG.firstChild);
2964
+ while (overlayG.firstChild) overlayG.removeChild(overlayG.firstChild);
2965
+ pathsByFeatureId.clear();
2966
+ tooltipDataById.clear();
2967
+ bordersPathEl = null;
2968
+ outlinePathEl = null;
2969
+ hoveredEl = null;
2970
+ // Old focused / overlay paths are about to be detached — drop refs so
2971
+ // applyFocus can re-resolve against the new path tree.
2972
+ focusedPathStyles.clear();
2973
+ overlayPathEls.clear();
2974
+ canvasHoveredId = null;
2975
+ canvasFocused.clear();
2976
+ canvasOverlays = [];
2977
+
2978
+ const path = pathGenerator.value;
2979
+ const features = featuresGeo.value.features;
2980
+ // No features (e.g. geoType="hsas" before the lazy HSA module resolves)
2981
+ // means the projection was fitted to an empty collection and yields NaN
2982
+ // coordinates — skip drawing entirely, including the state-borders mesh,
2983
+ // until real features arrive and re-trigger a rebuild.
2984
+ if (features.length === 0) {
2985
+ scene = null;
2986
+ pickingCtx = null;
2987
+ snapshotView = null;
2988
+ markBaseDirty();
2989
+ requestRedraw();
2990
+ return;
2991
+ }
2992
+
2993
+ if (isCanvas.value) {
2994
+ // Canvas backend: no DOM per feature — build the scene + picking
2995
+ // bitmap, and keep the tooltip payload cache exactly as svg mode does.
2996
+ for (const feat of features) {
2997
+ const id = String(feat.id);
2998
+ tooltipDataById.set(id, {
2999
+ id,
3000
+ name: featureName(feat),
3001
+ value: valueFor(id),
3002
+ feature: feat as ChoroplethFeature,
3003
+ });
3004
+ }
3005
+ const borders = stateBordersPath.value;
3006
+ scene = buildScene(
3007
+ features as Array<{ id?: string | number | null }>,
3008
+ path as (feature: never) => string | null,
3009
+ colorFor,
3010
+ borders ? path(borders) : null,
3011
+ );
3012
+ syncOutlinePath();
3013
+ if (!pickingCanvas && typeof document !== "undefined") {
3014
+ pickingCanvas = document.createElement("canvas");
3015
+ }
3016
+ pickingCtx = pickingCanvas
3017
+ ? buildPicking(scene, width.value, height.value, pickingCanvas)
3018
+ : null;
3019
+ markBaseDirty();
3020
+ requestRedraw();
3021
+ return;
3022
+ }
3023
+
3024
+ const stroke = resolvedTheme.value.stroke;
3025
+ const wantsTitleFallback = !hasInteractiveTooltip.value;
3026
+
3027
+ // Single DocumentFragment append → one layout flush for the whole batch.
3028
+ // Feature paths carry no stroke-width of their own — they inherit the
3029
+ // compensated group-level width from applyStrokeScale.
3030
+ const frag = document.createDocumentFragment();
3031
+ for (const feat of features) {
3032
+ const id = String(feat.id);
3033
+ const name = featureName(feat);
3034
+ const value = valueFor(id);
3035
+ const p = makePath(path(feat));
3036
+ p.setAttribute("class", "state-path");
3037
+ p.setAttribute("data-feat-id", id);
3038
+ p.setAttribute("fill", colorFor(id));
3039
+ p.setAttribute("stroke", stroke);
3040
+ if (wantsTitleFallback) {
3041
+ const title = document.createElementNS(SVG_NS, "title");
3042
+ title.textContent = titleText(name, value);
3043
+ p.appendChild(title);
3044
+ }
3045
+ frag.appendChild(p);
3046
+ pathsByFeatureId.set(id, p);
3047
+ tooltipDataById.set(id, {
3048
+ id,
3049
+ name,
3050
+ value,
3051
+ feature: feat as ChoroplethFeature,
3052
+ });
3053
+ }
3054
+
3055
+ // State-borders overlay (counties / hsas mode).
3056
+ const borders = stateBordersPath.value;
3057
+ if (borders) {
3058
+ const b = makePath(path(borders));
3059
+ b.setAttribute("fill", "none");
3060
+ b.setAttribute("stroke", resolvedTheme.value.borders);
3061
+ b.setAttribute("stroke-linejoin", "round");
3062
+ b.setAttribute("pointer-events", "none");
3063
+ frag.appendChild(b);
3064
+ bordersPathEl = b;
3065
+ }
3066
+ baseG.appendChild(frag);
3067
+ syncOutlinePath();
3068
+ applyStrokeScale();
3069
+ }
3070
+
3071
+ // Create / update / remove the exterior outline (theme.outline). Kept
3072
+ // separate from rebuildPaths so the outline can appear or disappear (a
3073
+ // `--choropleth-outline` flip) without rebuilding the feature tree. SVG:
3074
+ // a path appended after the borders mesh (hover-raised features go above
3075
+ // it, matching the borders behavior). Canvas: scene state for
3076
+ // finishBasePass.
3077
+ function syncOutlinePath() {
3078
+ // With no features (e.g. hsas before the lazy module resolves) the
3079
+ // projection was fitted to an empty collection and yields NaN paths;
3080
+ // treat the outline as absent until rebuildPaths runs with real data.
3081
+ const m = featuresGeo.value.features.length ? outlineMesh.value : null;
3082
+ const d = m ? pathGenerator.value(m) : null;
3083
+ if (isCanvas.value) {
3084
+ if (!scene) return;
3085
+ scene.exterior = d ? new Path2D(d) : null;
3086
+ markBaseDirty();
3087
+ requestRedraw();
3088
+ return;
3089
+ }
3090
+ const baseG = baseGroupRef.value;
3091
+ if (!baseG) return;
3092
+ if (!d) {
3093
+ outlinePathEl?.remove();
3094
+ outlinePathEl = null;
3095
+ return;
3096
+ }
3097
+ if (!outlinePathEl) {
3098
+ const el = document.createElementNS(SVG_NS, "path") as SVGPathElement;
3099
+ el.setAttribute("fill", "none");
3100
+ el.setAttribute("stroke-linejoin", "round");
3101
+ el.setAttribute("pointer-events", "none");
3102
+ el.setAttribute("class", "choropleth-outline");
3103
+ baseG.appendChild(el);
3104
+ outlinePathEl = el;
3105
+ }
3106
+ outlinePathEl.setAttribute("d", d);
3107
+ outlinePathEl.setAttribute("stroke", resolvedTheme.value.outline!);
3108
+ applyStrokeScale();
3109
+ }
3110
+
3111
+ function updateFills() {
3112
+ if (isCanvas.value) {
3113
+ if (scene) {
3114
+ for (const item of scene.items) item.fill = colorFor(item.id);
3115
+ }
3116
+ for (const [id, entry] of tooltipDataById) entry.value = valueFor(id);
3117
+ markBaseDirty();
3118
+ requestRedraw();
3119
+ return;
3120
+ }
3121
+ const refreshTitle = !hasInteractiveTooltip.value;
3122
+ for (const [id, p] of pathsByFeatureId) {
3123
+ const value = valueFor(id);
3124
+ const entry = tooltipDataById.get(id);
3125
+ p.setAttribute("fill", colorFor(id));
3126
+ // Refresh cached tooltip payload so a later hover (or the SVG <title>
3127
+ // fallback below) reflects the new value.
3128
+ if (entry) entry.value = value;
3129
+ if (refreshTitle && entry) {
3130
+ // First child is the <title> appended in rebuildPaths when fallback
3131
+ // mode is active.
3132
+ const title = p.firstElementChild;
3133
+ if (title) title.textContent = titleText(entry.name, value);
3134
+ }
3135
+ }
3136
+ }
3137
+
3138
+ function updateStrokes() {
3139
+ if (isCanvas.value) {
3140
+ // Stroke params are read at render time; the base strokes change, so the
3141
+ // cache is stale.
3142
+ markBaseDirty();
3143
+ requestRedraw();
3144
+ return;
3145
+ }
3146
+ for (const p of pathsByFeatureId.values()) {
3147
+ if (p === hoveredEl || focusedPathStyles.has(p)) continue;
3148
+ restoreDefaultStroke(p);
3149
+ }
3150
+ // Re-apply highlight styling so a theme change restyles hovered/focused
3151
+ // paths too (their stroke may come from theme.highlight).
3152
+ for (const [p, item] of focusedPathStyles) applyHighlightStroke(p, item);
3153
+ if (hoveredEl && !focusedPathStyles.has(hoveredEl)) {
3154
+ applyHighlightStroke(hoveredEl);
3155
+ }
3156
+ if (bordersPathEl) {
3157
+ bordersPathEl.setAttribute("stroke", resolvedTheme.value.borders);
3158
+ }
3159
+ // Group-level widths track the effective theme widths.
3160
+ applyStrokeScale();
3161
+ }
3162
+
3163
+ function menuFilename() {
3164
+ return typeof props.menu === "string" ? props.menu : "choropleth";
3165
+ }
3166
+
3167
+ const showLegend = computed(
3168
+ () =>
3169
+ props.legend && (isCategorical.value || isThreshold.value || props.data),
3170
+ );
3171
+
3172
+ const sortedThresholdStops = computed(() =>
3173
+ (props.colorScale as ThresholdStop[]).slice().sort((a, b) => a.min - b.min),
3174
+ );
3175
+
3176
+ const gradientStops = computed(() => {
3177
+ const steps = 10;
3178
+ const result: { offset: string; color: string }[] = [];
3179
+ for (let i = 0; i <= steps; i++) {
3180
+ const t = i / steps;
3181
+ result.push({
3182
+ offset: `${(t * 100).toFixed(0)}%`,
3183
+ color: interpolateColor(t),
3184
+ });
3185
+ }
3186
+ return result;
3187
+ });
3188
+
3189
+ // Compact formatter so legend ticks for large ranges (e.g. populations in
3190
+ // the millions) don't render wide enough to collide with each other.
3191
+ const compactTickFormat = new Intl.NumberFormat("en-US", {
3192
+ notation: "compact",
3193
+ maximumFractionDigits: 1,
3194
+ });
3195
+
3196
+ const continuousTicks = computed(() => {
3197
+ const { min, max } = extent.value;
3198
+ const range = max - min;
3199
+ const count = 3;
3200
+ const ticks: { value: string; pct: number }[] = [];
3201
+ for (let i = 1; i <= count; i++) {
3202
+ const t = i / (count + 1);
3203
+ const v = min + range * t;
3204
+ const formatted =
3205
+ Math.abs(v) >= 1000
3206
+ ? compactTickFormat.format(v)
3207
+ : Number.isInteger(v)
3208
+ ? String(v)
3209
+ : v.toFixed(1).replace(/\.0$/, "");
3210
+ ticks.push({ value: formatted, pct: t * 100 });
3211
+ }
3212
+ return ticks;
3213
+ });
3214
+
3215
+ const discreteLegendItems = computed(() => {
3216
+ const items: { key: string; color: string; label: string }[] = [];
3217
+ if (isCategorical.value) {
3218
+ for (const stop of props.colorScale as CategoricalStop[]) {
3219
+ items.push({ key: stop.value, color: stop.color, label: stop.value });
3220
+ }
3221
+ } else if (isThreshold.value) {
3222
+ for (const stop of sortedThresholdStops.value) {
3223
+ items.push({
3224
+ key: String(stop.min),
3225
+ color: stop.color,
3226
+ label: stop.label ?? String(stop.min),
3227
+ });
3228
+ }
3229
+ }
3230
+ return items;
3231
+ });
3232
+
3233
+ // Linear-gradient CSS for the continuous legend bar, derived from the same
3234
+ // stops the SVG version used.
3235
+ const gradientCss = computed(() => {
3236
+ const stops = gradientStops.value
3237
+ .map((s) => `${s.color} ${s.offset}`)
3238
+ .join(", ");
3239
+ return `linear-gradient(to right, ${stops})`;
3240
+ });
3241
+
3242
+ const fullscreen = useChartFullscreen({
3243
+ target: () => props.fullscreenTarget,
3244
+ });
3245
+
3246
+ const menuItems = computed<ChartMenuItem[]>(() => {
3247
+ const fname = menuFilename();
3248
+ if (isCanvas.value) {
3249
+ // No SVG DOM to serialize in canvas mode; PNG exports straight off
3250
+ // the rendering canvas (already devicePixelRatio-sized).
3251
+ return [
3252
+ fullscreen.menuItem.value,
3253
+ {
3254
+ label: "Save as PNG",
3255
+ action: () => {
3256
+ if (canvasRef.value) saveCanvasPng(canvasRef.value, fname);
3257
+ },
3258
+ },
3259
+ ];
3260
+ }
3261
+ return [
3262
+ fullscreen.menuItem.value,
3263
+ {
3264
+ label: "Save as SVG",
3265
+ action: () => {
3266
+ if (svgRef.value) saveSvg(svgRef.value, fname);
3267
+ },
3268
+ },
3269
+ {
3270
+ label: "Save as PNG",
3271
+ action: () => {
3272
+ if (svgRef.value) savePng(svgRef.value, fname);
3273
+ },
3274
+ },
3275
+ ];
3276
+ });
3277
+
3278
+ // ─── Reactive triggers for the imperative SVG tree ───────────────────────
3279
+ // Registered last so the eagerly-evaluated source getters can read every
3280
+ // computed defined above without hitting a TDZ.
3281
+
3282
+ // Geometry / projection / tooltip-mode → full rebuild. `featuresGeo` is
3283
+ // watched explicitly because in single-state mode the projection is pinned
3284
+ // to the static state outline, so an async change to the feature set (e.g.
3285
+ // the lazy HSA module resolving) wouldn't otherwise change `pathGenerator`.
3286
+ watch(
3287
+ () => [featuresGeo.value, pathGenerator.value, hasInteractiveTooltip.value],
3288
+ () => rebuildPaths(),
3289
+ );
3290
+
3291
+ // Data or scale → repaint fills (and refresh fallback <title>s). Reading
3292
+ // `props.dataGeoType` directly so a change to the parent-mapping mode
3293
+ // re-evaluates every county's color even when `dataMap` itself
3294
+ // (data-id keyed) is unchanged. `resolvedScaleColors` and the theme's
3295
+ // base fill make a page theme flip repaint both backends.
3296
+ watch(
3297
+ () =>
3298
+ [
3299
+ dataMap.value,
3300
+ props.colorScale,
3301
+ props.dataGeoType,
3302
+ resolvedScaleColors.value,
3303
+ resolvedTheme.value.fill,
3304
+ ] as const,
3305
+ () => updateFills(),
3306
+ );
3307
+
3308
+ // Stroke styling → refresh stroke attrs.
3309
+ watch(
3310
+ () =>
3311
+ [
3312
+ resolvedTheme.value.stroke,
3313
+ resolvedTheme.value.borders,
3314
+ resolvedTheme.value.highlight,
3315
+ effectiveStrokeWidth.value,
3316
+ effectiveBordersWidth.value,
3317
+ effectiveOutlineWidth.value,
3318
+ ] as const,
3319
+ () => updateStrokes(),
3320
+ );
3321
+
3322
+ // Exterior outline → create/update/remove its path (or canvas scene state).
3323
+ // Also fires when the projection refits, re-deriving the `d`.
3324
+ watch(
3325
+ () => [outlineMesh.value, resolvedTheme.value.outline] as const,
3326
+ () => syncOutlinePath(),
3327
+ );
3328
+
3329
+ // Focus or projection changed → re-apply the focus transform imperatively.
3330
+ // `flush: "post"` so any pending path rebuild from the watcher above has
3331
+ // already run; we still use the GeoJSON pathGenerator directly so the SVG
3332
+ // path tree isn't actually required, but keeping the order avoids stacking
3333
+ // two zoom transforms in the same tick. `hsaModule` is included so a
3334
+ // cross-geoType focus on an hsa item re-applies once the lazy module
3335
+ // resolves (the base pathGenerator doesn't depend on hsa data).
3336
+ watch(
3337
+ () => [normalizedFocus.value, pathGenerator.value, hsaModule.value],
3338
+ () => applyFocus(),
3339
+ { flush: "post" },
3340
+ );
3341
+
3342
+ // City overlay → re-place markers. Fires on data change, projection refit
3343
+ // (state/geoType/tightFit), and container resize (viewScale). `flush: "post"`
3344
+ // so the v-if `<g>` exists before we paint into it.
3345
+ watch(
3346
+ () => [
3347
+ props.cities,
3348
+ projection.value,
3349
+ viewScale.value,
3350
+ props.citiesMinZoom,
3351
+ props.zoom,
3352
+ // Halo width is written as an attribute inside renderCityLayer, so a
3353
+ // theme change needs a re-render (colors/opacity are style-bound).
3354
+ props.theme?.markerHaloWidth,
3355
+ // Title/legend form an in-flow header above the map; toggling it moves the
3356
+ // map svg, so re-pin the overlay to match.
3357
+ props.title,
3358
+ props.legend,
3359
+ props.legendTitle,
3360
+ ],
3361
+ () => scheduleCityLayout(),
3362
+ { flush: "post" },
3363
+ );
3364
+
3365
+ // Exiting the expanded touch view (✕, Escape) returns to the static inline
3366
+ // map at full extent — the inline map has no pan gestures, so a preserved
3367
+ // transform would strand the user off-center. Any toggle also drops the
3368
+ // tooltip: the presentation mode (float vs sheet) may change with it.
3369
+ watch(
3370
+ () => fullscreen.isFullscreen.value,
3371
+ (expanded) => {
3372
+ hideTooltip();
3373
+ if (expanded || !isTouchDevice()) return;
3374
+ if (!svgRef.value || !zoomBehavior) return;
3375
+ const svg = select(svgRef.value);
3376
+ svg.interrupt();
3377
+ zoomBehavior.transform(svg, zoomIdentity);
3378
+ },
3379
+ );
3380
+ </script>
3381
+
3382
+ <template>
3383
+ <Teleport
3384
+ :to="fullscreen.teleportTarget.value"
3385
+ :disabled="!fullscreen.isFullscreen.value"
3386
+ >
3387
+ <div
3388
+ ref="containerRef"
3389
+ v-bind="$attrs"
3390
+ :class="[
3391
+ 'choropleth-wrapper',
3392
+ {
3393
+ pannable: isPannable,
3394
+ 'is-fullscreen': fullscreen.isFullscreen.value,
3395
+ },
3396
+ ]"
3397
+ :style="fullscreen.fullscreenStyle.value"
3398
+ :role="chartRole || undefined"
3399
+ :aria-label="chartAriaLabel || undefined"
3400
+ >
3401
+ <!-- Rendered while expanded even with `menu` off — the ✕ close
3402
+ button it swaps to is the way back from the tap-to-expand view. -->
3403
+ <ChartMenu
3404
+ v-if="menu || fullscreen.isFullscreen.value"
3405
+ :items="menuItems"
3406
+ :is-fullscreen="fullscreen.isFullscreen.value"
3407
+ @close="fullscreen.exit"
3408
+ />
3409
+ <div class="chart-sr-only" aria-live="polite">
3410
+ {{ fullscreen.isFullscreen.value ? "Map expanded to fill window" : "" }}
3411
+ </div>
3412
+ <!--
3413
+ Title + legend live as an HTML overlay on top of the SVG so they keep
3414
+ their intrinsic px sizes regardless of how the browser scales the
3415
+ viewBox to fit the container.
3416
+ -->
3417
+ <div v-if="title || showLegend" class="choropleth-header">
3418
+ <div v-if="title" class="choropleth-title" :style="titleInlineStyle">
3419
+ {{ title }}
3420
+ </div>
3421
+ <div
3422
+ v-if="showLegend"
3423
+ class="choropleth-legend"
3424
+ :style="legendInlineStyle"
3425
+ >
3426
+ <span v-if="legendTitle" class="choropleth-legend-title">
3427
+ {{ legendTitle }}
3428
+ </span>
3429
+ <template v-if="isCategorical || isThreshold">
3430
+ <span
3431
+ v-for="item in discreteLegendItems"
3432
+ :key="item.key"
3433
+ class="choropleth-legend-item"
3434
+ >
3435
+ <span
3436
+ class="choropleth-legend-swatch"
3437
+ :style="{ background: item.color }"
3438
+ />
3439
+ {{ item.label }}
3440
+ </span>
3441
+ </template>
3442
+ <div v-else class="choropleth-legend-continuous">
3443
+ <div
3444
+ class="choropleth-legend-gradient"
3445
+ :style="{ background: gradientCss }"
3446
+ />
3447
+ <div class="choropleth-legend-ticks">
3448
+ <span
3449
+ v-for="tick in continuousTicks"
3450
+ :key="tick.value"
3451
+ :style="{ left: tick.pct + '%' }"
3452
+ >
3453
+ {{ tick.value }}
3454
+ </span>
3455
+ </div>
3456
+ </div>
3457
+ </div>
3458
+ </div>
3459
+ <div v-if="showZoomHint" class="choropleth-zoom-hint">
3460
+ {{ zoomHintText }}
3461
+ </div>
3462
+ <!-- Canvas backend paints here; the (empty) svg after it stays the
3463
+ interaction/zoom surface. pointer-events: none lets every event fall
3464
+ through to the svg even though the positioned canvas paints above
3465
+ the transparent svg. Position/size are pinned to the svg box by the
3466
+ resize observer. -->
3467
+ <canvas
3468
+ v-if="isCanvas"
3469
+ ref="canvasRef"
3470
+ class="choropleth-canvas"
3471
+ aria-hidden="true"
3472
+ />
3473
+ <svg
3474
+ ref="svgRef"
3475
+ :viewBox="`0 0 ${width} ${height}`"
3476
+ preserveAspectRatio="xMidYMid meet"
3477
+ :style="svgStyle"
3478
+ >
3479
+ <!--
3480
+ Path elements are created imperatively in `rebuildPaths()`; Vue never
3481
+ diffs the per-feature subtree so reactive state changes don't walk
3482
+ thousands of vnodes. `mapGroupRef` carries the zoom transform;
3483
+ `baseGroupRef` holds feature paths + the state-borders mesh and is
3484
+ the event-delegation target; `overlayGroupRef` is the always-on-top
3485
+ focus-overlay layer (separate group so hover-raised base paths
3486
+ can't cover an overlay).
3487
+ -->
3488
+ <!-- Background wash (theme.background). Outside the zoomed group so
3489
+ it always covers the viewport; canvas mode paints it in drawBase. -->
3490
+ <rect
3491
+ v-if="!isCanvas && resolvedTheme.background"
3492
+ class="choropleth-map-background"
3493
+ :width="width"
3494
+ :height="height"
3495
+ :fill="resolvedTheme.background"
3496
+ pointer-events="none"
3497
+ />
3498
+ <g ref="mapGroupRef">
3499
+ <g ref="baseGroupRef" />
3500
+ <g ref="overlayGroupRef" />
3501
+ </g>
3502
+ </svg>
3503
+ <!--
3504
+ City markers overlay in its OWN svg, layered above the interaction svg —
3505
+ and, crucially, above the canvas backend, which paints over the (empty)
3506
+ interaction svg (see the canvas comment above). Same viewBox + meet as the
3507
+ interaction svg and pinned to the same box (position: absolute; inset 0),
3508
+ so canonical coordinates map identically; renderCityLayer() applies the
3509
+ zoom transform per-point in JS, keeping it aligned with both the svg-mode
3510
+ map (mapGroupRef transform) and the canvas-mode map (canvas draws the
3511
+ zoom). pointer-events: none lets hover/click fall through to the map.
3512
+ -->
3513
+ <svg
3514
+ v-if="cities && cities.length"
3515
+ ref="cityOverlayRef"
3516
+ class="choropleth-city-overlay"
3517
+ :viewBox="`0 0 ${width} ${height}`"
3518
+ preserveAspectRatio="xMidYMid meet"
3519
+ aria-hidden="true"
3520
+ >
3521
+ <g
3522
+ ref="cityLayerRef"
3523
+ class="choropleth-cities"
3524
+ :style="cityLayerStyle"
3525
+ />
3526
+ </svg>
3527
+ <ChartZoomControls
3528
+ v-if="showZoomControls"
3529
+ :can-zoom-in="scaleK < maxScale"
3530
+ :can-zoom-out="scaleK > 1"
3531
+ :can-reset="isZoomed"
3532
+ :is-fullscreen="fullscreen.isFullscreen.value"
3533
+ @zoom-in="zoomBy(ZOOM_STEP)"
3534
+ @zoom-out="zoomBy(1 / ZOOM_STEP)"
3535
+ @reset="resetZoom"
3536
+ />
3537
+ <ChoroplethTooltip
3538
+ v-if="hasInteractiveTooltip"
3539
+ ref="tooltipChildRef"
3540
+ :mode="tooltipMode"
3541
+ >
3542
+ <template #default="raw">
3543
+ <slot name="tooltip" v-bind="narrowSlotProps(raw)">
3544
+ <span v-if="tooltipFormat" v-html="tooltipFormat(raw)" />
3545
+ <template v-else-if="raw.value == null">{{ raw.name }}</template>
3546
+ <template v-else>
3547
+ {{ raw.name }}: {{ formatTooltipValue(raw.value) }}
3548
+ </template>
3549
+ </slot>
3550
+ </template>
3551
+ </ChoroplethTooltip>
3552
+ </div>
3553
+ </Teleport>
3554
+ </template>
3555
+
3556
+ <style scoped>
3557
+ .choropleth-wrapper {
3558
+ /*
3559
+ * Override at the consumer level to change the legend/title panel fill:
3560
+ * .my-map { --choropleth-legend-bg: rgba(0, 0, 0, 0.6); }
3561
+ * Defaults to the theme's page background so the panel reads as a
3562
+ * floating extension of the page surface.
3563
+ */
3564
+ --choropleth-legend-bg: var(--color-bg-0, #fff);
3565
+
3566
+ position: relative;
3567
+ width: 100%;
3568
+ /* Size container so the zoom hint can reposition on narrow maps. */
3569
+ container-type: inline-size;
3570
+ }
3571
+
3572
+ .choropleth-wrapper svg {
3573
+ display: block;
3574
+ /* Fluid scaling via viewBox: the SVG fills its container's width and the
3575
+ * browser derives height from the viewBox aspect ratio. Overridden when
3576
+ * `props.width` / `props.height` are explicitly set on the component. */
3577
+ width: 100%;
3578
+ height: auto;
3579
+ }
3580
+
3581
+ .choropleth-wrapper.pannable svg {
3582
+ cursor: grab;
3583
+ }
3584
+
3585
+ /* While expanded the wrapper is a flex column (inline style); the SVG keeps
3586
+ its prop-driven size otherwise, so stretch it to fill the expanded box. */
3587
+ .choropleth-wrapper.is-fullscreen svg {
3588
+ flex: 1 1 auto;
3589
+ min-height: 0;
3590
+ height: 100%;
3591
+ width: 100%;
3592
+ }
3593
+
3594
+ .choropleth-wrapper.pannable svg:active {
3595
+ cursor: grabbing;
3596
+ }
3597
+
3598
+ /* Feature paths are created imperatively (rebuildPaths), so they carry no
3599
+ scoped data-v attribute — child rules for them need :deep() to match. */
3600
+ .choropleth-wrapper :deep(.state-path) {
3601
+ cursor: pointer;
3602
+ }
3603
+
3604
+ /* City overlay svg: its top/left/width/height are pinned to the interaction
3605
+ svg's box in JS (renderCityLayer), matching the canvas backend — the wrapper
3606
+ may have an in-flow header above the map, so the map svg isn't at top:0.
3607
+ Painted above the map + canvas; non-interactive so hover/click pass through. */
3608
+ .choropleth-city-overlay {
3609
+ position: absolute;
3610
+ top: 0;
3611
+ left: 0;
3612
+ pointer-events: none;
3613
+ }
3614
+
3615
+ /* City markers overlay. Default style is scheme-independent: dark dots and
3616
+ labels with a thin white halo read over any map fill, light or dark (a
3617
+ scheme-following halo left dark-mode labels haloed in near-black, barely
3618
+ separating them from dark fills). Override per instance with the custom
3619
+ properties below or the theme's marker keys. Marker/label sizes are set
3620
+ in JS (canonical units) so they stay a constant on-screen size under
3621
+ zoom. */
3622
+ .choropleth-cities {
3623
+ --choropleth-city-marker: #1a1a1a;
3624
+ --choropleth-city-halo: #fff;
3625
+ --choropleth-city-label-color: #1a1a1a;
3626
+ font-family: var(--font-family, system-ui, sans-serif);
3627
+ }
3628
+
3629
+ /* The dots/labels are created imperatively (renderCityLayer) and carry no
3630
+ scoped data-v attribute, so these rules need :deep() anchored on the
3631
+ templated group to match them. */
3632
+ .choropleth-cities :deep(.choropleth-city-dot) {
3633
+ fill: var(--choropleth-city-marker);
3634
+ stroke: var(--choropleth-city-halo);
3635
+ }
3636
+
3637
+ .choropleth-cities :deep(.choropleth-city-label) {
3638
+ fill: var(--choropleth-city-label-color);
3639
+ stroke: var(--choropleth-city-halo);
3640
+ paint-order: stroke fill;
3641
+ stroke-linejoin: round;
3642
+ font-weight: 500;
3643
+ }
3644
+
3645
+ .choropleth-canvas {
3646
+ position: absolute;
3647
+ top: 0;
3648
+ left: 0;
3649
+ pointer-events: none;
3650
+ /* Own compositor layer: without it WebKit repaints the surrounding
3651
+ page layer on every canvas frame (the same >100ms/frame pathology
3652
+ the svg renderer hit). Canvas mode implies interactivity, so the
3653
+ layer is always warranted. */
3654
+ will-change: transform;
3655
+ }
3656
+
3657
+ /* Overlays the top of the map: absolutely positioned with no `top`, the
3658
+ box keeps its static position — the top edge of the svg that follows it
3659
+ in the markup — without taking up flow space. The text carries a
3660
+ page-color halo so it stays legible over map fills. */
3661
+ .choropleth-zoom-hint {
3662
+ position: absolute;
3663
+ left: 0;
3664
+ right: 0;
3665
+ z-index: 1;
3666
+ padding-top: 6px;
3667
+ text-align: center;
3668
+ font-size: 12px;
3669
+ line-height: 1.4;
3670
+ color: var(--color-text-secondary, #777);
3671
+ opacity: 0.6;
3672
+ pointer-events: none;
3673
+ text-shadow:
3674
+ 1px 0 0 var(--color-bg-0, #fff),
3675
+ -1px 0 0 var(--color-bg-0, #fff),
3676
+ 0 1px 0 var(--color-bg-0, #fff),
3677
+ 0 -1px 0 var(--color-bg-0, #fff),
3678
+ 0 0 3px var(--color-bg-0, #fff);
3679
+ }
3680
+
3681
+ /* On narrow maps the overlay would cover content that matters — show the
3682
+ hint in flow above the map instead (the wrapper is the container). */
3683
+ @container (max-width: 480px) {
3684
+ .choropleth-zoom-hint {
3685
+ position: static;
3686
+ padding: 0;
3687
+ }
3688
+ }
3689
+
3690
+ /*
3691
+ * Title + legend overlay. Lives in HTML so its sizes are independent of
3692
+ * the SVG viewBox scaling — text stays at its declared px size at any
3693
+ * container width.
3694
+ */
3695
+ .choropleth-header {
3696
+ /*
3697
+ * In-flow above the map — the map gets its full canvas, no overlap to
3698
+ * worry about. Centered via `width: fit-content` + `margin: auto`.
3699
+ */
3700
+ display: flex;
3701
+ flex-direction: column;
3702
+ align-items: center;
3703
+ gap: 10px;
3704
+ width: fit-content;
3705
+ margin: 0 auto;
3706
+ padding: 8px 14px;
3707
+ border-radius: 4px;
3708
+ background: var(--choropleth-legend-bg);
3709
+ color: currentColor;
3710
+ }
3711
+
3712
+ .choropleth-title {
3713
+ font-size: 14px;
3714
+ font-weight: 600;
3715
+ line-height: 1.2;
3716
+ white-space: pre-line;
3717
+ }
3718
+
3719
+ .choropleth-legend {
3720
+ display: flex;
3721
+ align-items: center;
3722
+ gap: 14px;
3723
+ font-size: 13px;
3724
+ line-height: 1.2;
3725
+ }
3726
+
3727
+ .choropleth-legend-title {
3728
+ font-weight: 600;
3729
+ }
3730
+
3731
+ /* Continuous legend: the tick labels under the gradient unbalance the
3732
+ row's vertical centering, so anchor items to the top and line the title
3733
+ up with the gradient bar itself (line-height matches its 12px height). */
3734
+ .choropleth-legend:has(.choropleth-legend-continuous) {
3735
+ align-items: flex-start;
3736
+ }
3737
+
3738
+ .choropleth-legend:has(.choropleth-legend-continuous) .choropleth-legend-title {
3739
+ line-height: 12px;
3740
+ }
3741
+
3742
+ .choropleth-legend-item {
3743
+ display: inline-flex;
3744
+ align-items: center;
3745
+ gap: 6px;
3746
+ }
3747
+
3748
+ .choropleth-legend-swatch {
3749
+ width: 12px;
3750
+ height: 12px;
3751
+ border-radius: 3px;
3752
+ display: inline-block;
3753
+ }
3754
+
3755
+ .choropleth-legend-continuous {
3756
+ display: flex;
3757
+ flex-direction: column;
3758
+ width: 160px;
3759
+ }
3760
+
3761
+ .choropleth-legend-gradient {
3762
+ height: 12px;
3763
+ border-radius: 2px;
3764
+ }
3765
+
3766
+ .choropleth-legend-ticks {
3767
+ position: relative;
3768
+ height: 14px;
3769
+ margin-top: 4px;
3770
+ font-size: 11px;
3771
+ opacity: 0.7;
3772
+ }
3773
+
3774
+ .choropleth-legend-ticks > span {
3775
+ position: absolute;
3776
+ transform: translateX(-50%);
3777
+ }
3778
+ </style>