@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.
- package/charts/BarChart/BarChart.md +8 -1
- package/charts/BarChart/BarChart.vue +18 -3
- package/charts/ChartMenu/ChartMenu.vue +11 -4
- package/charts/ChoroplethMap/ChoroplethMap.md +160 -0
- package/charts/ChoroplethMap/ChoroplethMap.vue +600 -274
- package/charts/ChoroplethMap/ChoroplethTooltip.vue +49 -0
- package/charts/DataTable/DataTable.md +39 -9
- package/charts/DataTable/DataTable.vue +45 -59
- package/charts/LineChart/LineChart.md +3 -2
- package/charts/LineChart/LineChart.vue +18 -3
- package/index.json +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
117
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
231
|
+
tooltipObserver?.disconnect();
|
|
232
|
+
if (pendingMoveFrame) cancelAnimationFrame(pendingMoveFrame);
|
|
166
233
|
teardownZoom();
|
|
167
234
|
teardownInteraction();
|
|
168
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
277
|
-
[width.value, height.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
|
|
297
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
const
|
|
372
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
if (
|
|
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
|
-
|
|
382
|
-
return interpolateColor(t);
|
|
492
|
+
return interpolateColor(((value as number) - min) / (max - min));
|
|
383
493
|
}
|
|
384
494
|
|
|
385
|
-
|
|
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
|
-
):
|
|
392
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
440
|
-
|
|
561
|
+
lastTooltipSize.width,
|
|
562
|
+
lastTooltipSize.height,
|
|
441
563
|
props.tooltipClamp,
|
|
442
564
|
chartRect,
|
|
443
565
|
);
|
|
444
|
-
|
|
445
|
-
|
|
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 (
|
|
450
|
-
|
|
451
|
-
|
|
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
|
|
457
|
-
|
|
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 (
|
|
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
|
|
484
|
-
if (!
|
|
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(
|
|
493
|
-
if (
|
|
494
|
-
|
|
495
|
-
|
|
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
|
|
504
|
-
|
|
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 =
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
return
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
<
|
|
668
|
-
v-
|
|
669
|
-
:
|
|
670
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
686
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
<
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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>
|