@cfasim-ui/docs 0.4.1 → 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.
@@ -1,3 +1,18 @@
1
+ <script setup>
2
+ import { computed } from "vue";
3
+ import countiesTopoForPerf from "us-atlas/counties-10m.json";
4
+
5
+ // Build one row per county (~3,143) with a deterministic-ish value so the
6
+ // perf example can render every region with a custom tooltip.
7
+ const denseCountyData = computed(() => {
8
+ const geoms = countiesTopoForPerf.objects.counties.geometries;
9
+ return geoms.map((g, i) => ({
10
+ id: String(g.id).padStart(5, "0"),
11
+ value: (i * 37) % 100,
12
+ }));
13
+ });
14
+ </script>
15
+
1
16
  # ChoroplethMap
2
17
 
3
18
  A US choropleth map using D3's Albers USA projection, which repositions Alaska and Hawaii to the bottom left. Supports state-level, county-level, and HSA-level (Health Service Areas) rendering via the `geoType` prop.
@@ -325,9 +340,8 @@ Set `geoType="hsas"` to render Health Service Area boundaries. HSAs are dissolve
325
340
  ### Custom tooltip number format
326
341
 
327
342
  Pass `tooltip-value-format` to format numeric values shown in the tooltip
328
- (both the native SVG `<title>` and the interactive HTML tooltip when
329
- `tooltip-trigger` is set). Use `tooltip-format` instead if you want full
330
- control over the tooltip's HTML.
343
+ (both the native SVG `<title>` and the interactive HTML tooltip). Use the
344
+ `#tooltip` slot if you want full control over the tooltip's content.
331
345
 
332
346
  <ComponentDemo>
333
347
  <ChoroplethMap
@@ -363,6 +377,110 @@ control over the tooltip's HTML.
363
377
  </template>
364
378
  </ComponentDemo>
365
379
 
380
+ ### Dense county map (~3,143 features) for tooltip perf profiling
381
+
382
+ Renders every US county with a value and a custom tooltip slot. Useful as a
383
+ manual perf harness — open DevTools Performance, record a hover/sweep across
384
+ many counties, and inspect tooltip update cost. The tooltip element is
385
+ mounted once and patched in place; `mousemove` writes the position
386
+ straight to the DOM.
387
+
388
+ <ComponentDemo>
389
+ <ChoroplethMap
390
+ :topology="countiesTopo"
391
+ geo-type="counties"
392
+ :data="denseCountyData"
393
+ :pan="true"
394
+ :zoom="true"
395
+ :color-scale="{ min: '#f0f5ff', max: '#08306b' }"
396
+ title="All US counties — tooltip perf demo"
397
+ :height="500"
398
+ >
399
+ <template #tooltip="{ id, name, value }">
400
+ <div style="font-weight: 600">{{ name }}</div>
401
+ <div style="opacity: 0.7; font-size: 0.85em">FIPS {{ id }}</div>
402
+ <div>Value: {{ value }}</div>
403
+ </template>
404
+ </ChoroplethMap>
405
+
406
+ <template #code>
407
+
408
+ ```vue
409
+ <script setup>
410
+ import countiesTopo from "us-atlas/counties-10m.json";
411
+
412
+ // One row per county
413
+ const data = countiesTopo.objects.counties.geometries.map((g, i) => ({
414
+ id: String(g.id).padStart(5, "0"),
415
+ value: (i * 37) % 100,
416
+ }));
417
+ </script>
418
+
419
+ <ChoroplethMap
420
+ :topology="countiesTopo"
421
+ geo-type="counties"
422
+ :data="data"
423
+ pan
424
+ zoom
425
+ >
426
+ <template #tooltip="{ id, name, value }">
427
+ <div style="font-weight: 600">{{ name }}</div>
428
+ <div style="opacity: 0.7; font-size: 0.85em">FIPS {{ id }}</div>
429
+ <div>Value: {{ value }}</div>
430
+ </template>
431
+ </ChoroplethMap>
432
+ ```
433
+
434
+ </template>
435
+ </ComponentDemo>
436
+
437
+ ### Custom tooltip content (`#tooltip` slot)
438
+
439
+ Use the `#tooltip` slot to render any Vue template — components, scoped
440
+ styles, multi-line layouts — instead of the default `name: value`. The slot
441
+ receives `{ id, name, value, feature }` for the hovered region. Providing the
442
+ slot automatically enables interactive (HTML) tooltips, so you don't need to
443
+ set `tooltip-trigger`.
444
+
445
+ <ComponentDemo>
446
+ <ChoroplethMap
447
+ :topology="statesTopo"
448
+ :data="[
449
+ { id: '06', value: 39538223 },
450
+ { id: '48', value: 29145505 },
451
+ { id: '12', value: 21538187 },
452
+ { id: '36', value: 20201249 },
453
+ { id: '17', value: 12812508 },
454
+ ]"
455
+ title="US population (2020)"
456
+ :height="300"
457
+ >
458
+ <template #tooltip="{ name, value }">
459
+ <div style="font-weight:600">{{ name }}</div>
460
+ <div v-if="typeof value === 'number'">
461
+ Pop: {{ value.toLocaleString('en-US') }}
462
+ </div>
463
+ <div v-else style="opacity:0.6">No data</div>
464
+ </template>
465
+ </ChoroplethMap>
466
+
467
+ <template #code>
468
+
469
+ ```vue
470
+ <ChoroplethMap :topology="statesTopo" :data="data" title="US population (2020)">
471
+ <template #tooltip="{ name, value }">
472
+ <div style="font-weight: 600">{{ name }}</div>
473
+ <div v-if="typeof value === 'number'">
474
+ Pop: {{ value.toLocaleString("en-US") }}
475
+ </div>
476
+ <div v-else style="opacity: 0.6">No data</div>
477
+ </template>
478
+ </ChoroplethMap>
479
+ ```
480
+
481
+ </template>
482
+ </ComponentDemo>
483
+
366
484
  ## Props
367
485
 
368
486
  | Prop | Type | Required | Default |
@@ -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,7 +79,12 @@ 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;
@@ -119,21 +127,67 @@ const emit = defineEmits<{
119
127
  ): void;
120
128
  }>();
121
129
 
122
- const uid = useId();
123
- 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
+
124
154
  const containerRef = ref<HTMLElement | null>(null);
125
155
  const svgRef = ref<SVGSVGElement | null>(null);
126
156
  const mapGroupRef = ref<SVGGElement | null>(null);
127
- 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;
128
172
  let hoveredEl: SVGPathElement | null = null;
129
- let tooltipEl: HTMLDivElement | null = null;
130
173
  let isZooming = false;
131
174
  // TODO: map hover/tooltip causes performance issues on mobile (SVG stroke-width
132
175
  // changes + compositing layers degrade zoom/pan). Disabled on touch devices.
133
176
  const isTouchDevice = typeof window !== "undefined" && "ontouchstart" in window;
134
- 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;
135
181
  let zoomBehavior: ReturnType<typeof d3Zoom<SVGSVGElement, unknown>> | null =
136
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;
137
191
 
138
192
  function setupInteraction() {
139
193
  if (isTouchDevice) return;
@@ -154,24 +208,34 @@ function teardownInteraction() {
154
208
  g.removeEventListener("mouseout", onDelegatedMouseOut);
155
209
  }
156
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
+
157
218
  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
219
  setupZoom();
167
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 });
168
228
  });
169
229
 
170
230
  onUnmounted(() => {
171
- observer?.disconnect();
231
+ tooltipObserver?.disconnect();
232
+ if (pendingMoveFrame) cancelAnimationFrame(pendingMoveFrame);
172
233
  teardownZoom();
173
234
  teardownInteraction();
174
- hideTooltip();
235
+ window.removeEventListener("scroll", dismissOnViewportChange, {
236
+ capture: true,
237
+ });
238
+ window.removeEventListener("resize", dismissOnViewportChange);
175
239
  });
176
240
 
177
241
  function setupZoom() {
@@ -189,6 +253,8 @@ function setupZoom() {
189
253
  if (mapGroupRef.value) {
190
254
  mapGroupRef.value.setAttribute("transform", event.transform);
191
255
  }
256
+ const t = event.transform;
257
+ isZoomed.value = t.k !== 1 || t.x !== 0 || t.y !== 0;
192
258
  })
193
259
  .on("end", () => {
194
260
  isZooming = false;
@@ -210,6 +276,13 @@ function teardownZoom() {
210
276
  }
211
277
  }
212
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
+
213
286
  watch(
214
287
  () => [props.zoom, props.pan],
215
288
  () => {
@@ -220,12 +293,23 @@ watch(
220
293
  },
221
294
  );
222
295
 
223
- 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;
224
302
  const aspectRatio = computed(() => {
225
303
  if (props.width && props.height) return props.height / props.width;
226
304
  return 0.625;
227
305
  });
228
- 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.
229
313
 
230
314
  type NamedGeometry = GeometryCollection<{ name: string }>;
231
315
  type StatesTopo = Topology<{ states: NamedGeometry }>;
@@ -279,8 +363,8 @@ const stateBordersPath = computed(() => {
279
363
  const projection = computed(() =>
280
364
  geoAlbersUsa().fitExtent(
281
365
  [
282
- [0, topOffset.value],
283
- [width.value, height.value + topOffset.value],
366
+ [0, 0],
367
+ [width.value, height.value],
284
368
  ],
285
369
  featuresGeo.value,
286
370
  ),
@@ -294,15 +378,26 @@ const effectiveStrokeWidth = computed(() =>
294
378
  : props.strokeWidth,
295
379
  );
296
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
+
297
393
  const dataMap = computed(() => {
298
394
  const map = new Map<string, number | string>();
299
395
  if (!props.data) return map;
396
+ const nameIdx = nameToFeatureId.value;
300
397
  for (const d of props.data) {
301
398
  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);
399
+ const fid = nameIdx.get(d.id);
400
+ if (fid) map.set(fid, d.value);
306
401
  }
307
402
  return map;
308
403
  });
@@ -362,41 +457,44 @@ function interpolateColor(t: number): string {
362
457
  return `rgb(${r},${g},${b})`;
363
458
  }
364
459
 
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
- }
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
+ );
374
469
 
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
- }
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
+ });
380
477
 
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);
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
+ }
386
491
  const { min, max } = extent.value;
387
- const t = ((value as number) - min) / (max - min);
388
- return interpolateColor(t);
389
- }
390
-
391
- function stateName(feat: (typeof featuresGeo.value.features)[number]): string {
392
- return feat.properties?.name ?? String(feat.id);
492
+ return interpolateColor(((value as number) - min) / (max - min));
393
493
  }
394
494
 
395
- function stateValue(
495
+ const featureName = (
396
496
  feat: (typeof featuresGeo.value.features)[number],
397
- ): number | string | undefined {
398
- return dataMap.value.get(String(feat.id));
399
- }
497
+ ): string => feat.properties?.name ?? String(feat.id);
400
498
 
401
499
  function formatTooltipValue(value: number | string | undefined): string {
402
500
  if (value == null) return "";
@@ -406,77 +504,112 @@ function formatTooltipValue(value: number | string | undefined): string {
406
504
  return String(value);
407
505
  }
408
506
 
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
- });
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
+ }
414
511
 
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 };
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);
427
548
  }
428
549
 
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
- }
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.
451
557
  const chartRect = containerRef.value?.getBoundingClientRect();
452
558
  const { left, top } = placeTooltip(
453
559
  clientX,
454
560
  clientY,
455
- tooltipEl.offsetWidth,
456
- tooltipEl.offsetHeight,
561
+ lastTooltipSize.width,
562
+ lastTooltipSize.height,
457
563
  props.tooltipClamp,
458
564
  chartRect,
459
565
  );
460
- tooltipEl.style.left = `${left}px`;
461
- 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
+ });
462
595
  }
463
596
 
464
597
  function hideTooltip() {
465
- if (tooltipEl) {
466
- tooltipEl.remove();
467
- tooltipEl = null;
468
- }
598
+ if (!tooltipVisible) return;
599
+ tooltipVisible = false;
600
+ lastPointer = null;
601
+ const el = tooltipChildRef.value?.getEl();
602
+ if (el) el.style.visibility = "hidden";
469
603
  }
470
604
 
471
- function setHover(
472
- pathEl: SVGPathElement,
473
- feat: (typeof featuresGeo.value.features)[number],
474
- ) {
475
- if (hoveredEl && hoveredEl !== pathEl) {
605
+ function setHover(pathEl: SVGPathElement) {
606
+ if (hoveredEl === pathEl) return;
607
+ if (hoveredEl) {
476
608
  hoveredEl.setAttribute("stroke-width", String(effectiveStrokeWidth.value));
477
609
  hoveredEl.setAttribute("stroke", props.strokeColor);
478
610
  }
479
611
  hoveredEl = pathEl;
612
+ // Bring hovered path to top so its thicker border is not clipped by neighbors.
480
613
  pathEl.parentNode?.appendChild(pathEl);
481
614
  pathEl.setAttribute("stroke-width", String(effectiveStrokeWidth.value + 1));
482
615
  pathEl.setAttribute("stroke", "#555");
@@ -492,33 +625,35 @@ function clearHover() {
492
625
  hideTooltip();
493
626
  }
494
627
 
495
- // 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
+
496
636
  function onDelegatedEvent(event: Event) {
497
637
  if (isZooming) return;
498
638
  const me = event as MouseEvent;
499
- const hit = resolveTarget(me.target as Element);
500
- 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 };
501
644
  if (event.type === "click") {
502
- emit("stateClick", {
503
- id: String(hit.feat.id),
504
- name: stateName(hit.feat),
505
- value: stateValue(hit.feat),
506
- });
645
+ emit("stateClick", payload);
507
646
  } 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
- });
647
+ setHover(pathsByFeatureId.get(featId)!);
648
+ if (hasInteractiveTooltip.value)
649
+ showTooltip(featId, me.clientX, me.clientY);
650
+ emit("stateHover", payload);
515
651
  }
516
652
  }
517
653
 
518
654
  function onDelegatedMouseMove(event: MouseEvent) {
519
- if (isZooming || !tooltipEl) return;
520
- tooltipEl.style.left = `${event.clientX + 16}px`;
521
- tooltipEl.style.top = `${event.clientY}px`;
655
+ if (isZooming) return;
656
+ moveTooltip(event.clientX, event.clientY);
522
657
  }
523
658
 
524
659
  function onDelegatedMouseOut(event: MouseEvent) {
@@ -527,6 +662,109 @@ function onDelegatedMouseOut(event: MouseEvent) {
527
662
  clearHover();
528
663
  }
529
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
+
530
768
  function menuFilename() {
531
769
  return typeof props.menu === "string" ? props.menu : "choropleth";
532
770
  }
@@ -540,14 +778,6 @@ const sortedThresholdStops = computed(() =>
540
778
  (props.colorScale as ThresholdStop[]).slice().sort((a, b) => a.min - b.min),
541
779
  );
542
780
 
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
781
  const gradientStops = computed(() => {
552
782
  const steps = 10;
553
783
  const result: { offset: string; color: string }[] = [];
@@ -561,6 +791,13 @@ const gradientStops = computed(() => {
561
791
  return result;
562
792
  });
563
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
+
564
801
  const continuousTicks = computed(() => {
565
802
  const { min, max } = extent.value;
566
803
  const range = max - min;
@@ -569,9 +806,12 @@ const continuousTicks = computed(() => {
569
806
  for (let i = 1; i <= count; i++) {
570
807
  const t = i / (count + 1);
571
808
  const v = min + range * t;
572
- const formatted = Number.isInteger(v)
573
- ? String(v)
574
- : 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$/, "");
575
815
  ticks.push({ value: formatted, pct: t * 100 });
576
816
  }
577
817
  return ticks;
@@ -595,32 +835,13 @@ const discreteLegendItems = computed(() => {
595
835
  return items;
596
836
  });
597
837
 
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;
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})`;
624
845
  });
625
846
 
626
847
  const menuItems = computed<ChartMenuItem[]>(() => {
@@ -640,145 +861,133 @@ const menuItems = computed<ChartMenuItem[]>(() => {
640
861
  },
641
862
  ];
642
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
+ );
643
886
  </script>
644
887
 
645
888
  <template>
646
889
  <div ref="containerRef" :class="['choropleth-wrapper', { pannable: pan }]">
647
890
  <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 -->
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>
686
902
  <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"
903
+ <span
904
+ v-for="item in discreteLegendItems"
905
+ :key="item.key"
906
+ class="choropleth-legend-item"
693
907
  >
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"
908
+ <span
909
+ class="choropleth-legend-swatch"
910
+ :style="{ background: item.color }"
704
911
  />
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>
912
+ {{ item.label }}
913
+ </span>
714
914
  </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})`"
915
+ <div v-else class="choropleth-legend-continuous">
916
+ <div
917
+ class="choropleth-legend-gradient"
918
+ :style="{ background: gradientCss }"
743
919
  />
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>
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" />
772
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>
773
965
  </div>
774
966
  </template>
775
967
 
776
968
  <style scoped>
777
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
+
778
978
  position: relative;
779
979
  width: 100%;
780
980
  }
781
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
+
782
991
  .choropleth-wrapper.pannable svg {
783
992
  cursor: grab;
784
993
  }
@@ -794,4 +1003,101 @@ const menuItems = computed<ChartMenuItem[]>(() => {
794
1003
  .state-path {
795
1004
  cursor: pointer;
796
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
+ }
797
1103
  </style>
@@ -0,0 +1,49 @@
1
+ <script setup lang="ts">
2
+ import { ref, useTemplateRef } from "vue";
3
+
4
+ export interface ChoroplethTooltipData {
5
+ id: string;
6
+ name: string;
7
+ value?: number | string;
8
+ feature: unknown;
9
+ }
10
+
11
+ defineSlots<{
12
+ default?(props: ChoroplethTooltipData): unknown;
13
+ }>();
14
+
15
+ // Local reactive state. Held inside the child so the parent's render scope
16
+ // never subscribes to it — hover updates re-render only this small tree,
17
+ // not the parent's 3,000+ paths.
18
+ const data = ref<ChoroplethTooltipData | null>(null);
19
+ const rootRef = useTemplateRef<HTMLDivElement>("root");
20
+
21
+ defineExpose({
22
+ setData(next: ChoroplethTooltipData | null) {
23
+ data.value = next;
24
+ },
25
+ getEl(): HTMLDivElement | null {
26
+ return rootRef.value;
27
+ },
28
+ });
29
+ </script>
30
+
31
+ <template>
32
+ <Teleport to="body">
33
+ <div
34
+ ref="root"
35
+ class="chart-tooltip-content"
36
+ style="
37
+ position: fixed;
38
+ left: 0;
39
+ top: 0;
40
+ visibility: hidden;
41
+ will-change: transform;
42
+ pointer-events: none;
43
+ transform: translateY(-50%);
44
+ "
45
+ >
46
+ <slot v-if="data" v-bind="data" />
47
+ </div>
48
+ </Teleport>
49
+ </template>
package/index.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.1",
2
+ "version": "0.4.2",
3
3
  "package": "@cfasim-ui/docs",
4
4
  "content": {
5
5
  "components": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/docs",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "LLM-friendly component and chart documentation for cfasim-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {