@cfasim-ui/charts 0.7.7 → 0.8.0

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