@cfasim-ui/docs 0.4.1 → 0.4.3

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