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