@cfasim-ui/docs 0.4.0 → 0.4.2

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