@inkra/react 0.12.0 → 0.14.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/i18n/catalog.d.ts +1 -1
- package/dist/i18n/catalog.js +1 -0
- package/dist/journey-map/annotations.d.ts +40 -0
- package/dist/journey-map/annotations.js +94 -0
- package/dist/journey-map/index.d.ts +26 -11
- package/dist/journey-map/index.js +318 -56
- package/dist/journey-map/rendering.d.ts +36 -0
- package/dist/journey-map/rendering.js +144 -0
- package/dist/journey-map/terrain.d.ts +4 -1
- package/dist/journey-map/terrain.js +35 -3
- package/dist/journey-map/trackpad.d.ts +3 -0
- package/dist/journey-map/trackpad.js +110 -0
- package/dist/styles/journey-map.css +14 -1
- package/dist/styles/markdown.css +2 -0
- package/dist/styles.css +16 -1
- package/package.json +1 -1
|
@@ -1,26 +1,40 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useInkraLocale } from '../i18n/index.js';
|
|
3
|
-
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
4
4
|
import { createPortal } from 'react-dom';
|
|
5
5
|
import { Button } from '../Button.js';
|
|
6
6
|
import { Popover } from '../overlay/index.js';
|
|
7
|
+
import { PencilSurface } from '../PencilSurface.js';
|
|
7
8
|
import { PencilText } from '../PencilText.js';
|
|
8
9
|
import { pencilStroke } from '../internal/stroke.js';
|
|
9
10
|
import { TravelIcon } from '../internal/travelIcons.js';
|
|
10
11
|
import { paintTerrain, paintStop, paintSea } from './terrain.js';
|
|
11
12
|
import { MapScale } from './MapScale.js';
|
|
12
13
|
import { seedToNumber } from '../internal/random.js';
|
|
13
|
-
|
|
14
|
+
import { PENCIL_RENDERER_VERSION } from '../internal/cache.js';
|
|
15
|
+
import { JourneyBaseBitmap, journeyAnimationMs, journeyCanvasSize, journeyFocusZoom, journeyPanRegions, prepareJourneyCanvas, resolveJourneyMapRendering } from './rendering.js';
|
|
16
|
+
import { attachJourneyTrackpad } from './trackpad.js';
|
|
17
|
+
import { areaPolygons, journeyAreaLabel, paintJourneyArea, validJourneyArea } from './annotations.js';
|
|
18
|
+
const defaults = (t) => ({ zoomIn: t('common.zoomIn'), zoomOut: t('common.zoomOut'), reset: t('common.resetView'), unavailable: t('map.unavailable'), features: t('map.features') });
|
|
14
19
|
const noRoutes = [];
|
|
20
|
+
const noAreas = [];
|
|
15
21
|
const valid = (place) => Number.isFinite(place.latitude) && Number.isFinite(place.longitude) && Math.abs(place.latitude) <= 85 && Math.abs(place.longitude) <= 180;
|
|
16
|
-
export function JourneyMap({ places, routes = noRoutes, geography, landCover, water, selectedPlace, onPlaceSelect, attribution, attributionDisplay = 'inline', height = 480, interactive = true, scrollWheelZoom = false, showControls = true, showPlaceList = false, focusSelectedPlace =
|
|
22
|
+
export function JourneyMap({ places, routes = noRoutes, areas = noAreas, selectedRoute, onRouteSelect, selectedArea, onAreaSelect, showFeatureList = false, geography, landCover, water, selectedPlace, onPlaceSelect, attribution, attributionDisplay = 'inline', height = 480, maxFitZoom = 12, background = 'sea', interactive = true, scrollWheelZoom = false, showControls = true, showPlaceList = false, focusSelectedPlace = true, focusZoomScale = 2, animate = true, animationDuration = 420, rendering = 'auto', labels: labelOverrides, className, ...props }) {
|
|
17
23
|
const { t } = useInkraLocale();
|
|
18
24
|
const labels = { ...defaults(t), ...labelOverrides };
|
|
25
|
+
const usableAreas = useMemo(() => areas.filter(validJourneyArea), [areas]);
|
|
26
|
+
const annotationSelection = useRef({ selectedRoute, onRouteSelect, selectedArea, onAreaSelect });
|
|
27
|
+
annotationSelection.current = { selectedRoute, onRouteSelect, selectedArea, onAreaSelect };
|
|
28
|
+
const featureHits = useRef([]);
|
|
29
|
+
const [featureLabels, setFeatureLabels] = useState([]);
|
|
19
30
|
const root = useRef(null);
|
|
20
31
|
const host = useRef(null);
|
|
21
32
|
const mapRef = useRef(null);
|
|
22
33
|
const fitRef = useRef(() => { });
|
|
23
34
|
const paintRef = useRef(() => { });
|
|
35
|
+
const viewRef = useRef(() => { });
|
|
36
|
+
const zoomRef = useRef(() => { });
|
|
37
|
+
const focusRef = useRef(() => { });
|
|
24
38
|
const fitZoomRef = useRef(2);
|
|
25
39
|
const selectionRef = useRef(onPlaceSelect);
|
|
26
40
|
selectionRef.current = onPlaceSelect;
|
|
@@ -29,13 +43,28 @@ export function JourneyMap({ places, routes = noRoutes, geography, landCover, wa
|
|
|
29
43
|
const [placeLabels, setPlaceLabels] = useState([]);
|
|
30
44
|
const selectedRef = useRef(selectedPlace);
|
|
31
45
|
selectedRef.current = selectedPlace;
|
|
46
|
+
const previousSelection = useRef({ place: selectedPlace, scale: focusZoomScale });
|
|
47
|
+
const viewOptions = useRef({ animate, animationDuration, focusSelectedPlace, focusZoomScale });
|
|
48
|
+
viewOptions.current = { animate, animationDuration, focusSelectedPlace, focusZoomScale };
|
|
49
|
+
const selectPlace = (place, fromList = false) => {
|
|
50
|
+
if (viewOptions.current.focusSelectedPlace)
|
|
51
|
+
focusRef.current(place);
|
|
52
|
+
else if (fromList && mapRef.current)
|
|
53
|
+
viewRef.current([place.latitude, place.longitude], mapRef.current.getZoom());
|
|
54
|
+
selectionRef.current?.(place);
|
|
55
|
+
};
|
|
32
56
|
const markersRef = useRef([]);
|
|
57
|
+
const renderingOptions = resolveJourneyMapRendering(rendering);
|
|
58
|
+
const renderingRef = useRef(renderingOptions);
|
|
59
|
+
renderingRef.current = renderingOptions;
|
|
33
60
|
useEffect(() => {
|
|
34
61
|
let cancelled = false;
|
|
35
62
|
let cleanup;
|
|
36
63
|
setReady(false);
|
|
37
64
|
setFailed(false);
|
|
38
65
|
setPlaceLabels([]);
|
|
66
|
+
setFeatureLabels([]);
|
|
67
|
+
featureHits.current = [];
|
|
39
68
|
void import('leaflet').then(L => {
|
|
40
69
|
if (cancelled || !host.current)
|
|
41
70
|
return;
|
|
@@ -65,84 +94,287 @@ export function JourneyMap({ places, routes = noRoutes, geography, landCover, wa
|
|
|
65
94
|
layer.eachLayer(collect);
|
|
66
95
|
}
|
|
67
96
|
const routePoints = routes.map(route => route.coordinates.filter(([lat, lng]) => Number.isFinite(lat) && Number.isFinite(lng) && Math.abs(lat) <= 85 && Math.abs(lng) <= 180).map(([lat, lng]) => L.latLng(lat, lng)));
|
|
68
|
-
const all = [...points.map(place => L.latLng(place.latitude, place.longitude)), ...boundaries.flat(), ...routePoints.flat()];
|
|
97
|
+
const all = [...points.map(place => L.latLng(place.latitude, place.longitude)), ...boundaries.flat(), ...routePoints.flat(), ...usableAreas.flatMap(area => areaPolygons(area.geometry).flat(2).map(([lng, lat]) => L.latLng(lat, lng)))];
|
|
98
|
+
const areaLayers = usableAreas.map(area => L.geoJSON(area.geometry, { style: { color: area.color || 'var(--inkra-accent)', weight: 1, opacity: 0, fillOpacity: 0 }, bubblingMouseEvents: false }));
|
|
99
|
+
const routeLayers = routePoints.map((line, i) => L.polyline(line, { color: routes[i].color || 'var(--inkra-accent)', weight: 18, opacity: 0, bubblingMouseEvents: false }));
|
|
100
|
+
const captions = [];
|
|
101
|
+
const caption = (kind, id, label, position) => {
|
|
102
|
+
const node = document.createElement('span');
|
|
103
|
+
node.className = 'inkra-journey-map__caption';
|
|
104
|
+
const marker = L.marker(position, { interactive: false, keyboard: false, icon: L.divIcon({ html: node, className: 'inkra-journey-map__caption-marker', iconSize: [0, 0] }) }).addTo(map);
|
|
105
|
+
marker.getElement()?.setAttribute('aria-hidden', 'true');
|
|
106
|
+
captions.push({ kind, id, label, node });
|
|
107
|
+
};
|
|
108
|
+
const hit = (layer, kind, id, label, select) => {
|
|
109
|
+
const path = layer.getElement();
|
|
110
|
+
if (!path)
|
|
111
|
+
return;
|
|
112
|
+
path.classList.add('inkra-journey-map__feature-hit');
|
|
113
|
+
featureHits.current.push({ kind, id, label, path });
|
|
114
|
+
layer.on('click', select);
|
|
115
|
+
path.addEventListener('keydown', event => {
|
|
116
|
+
const key = event.key;
|
|
117
|
+
if (key === 'Enter' || key === ' ') {
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
event.stopPropagation();
|
|
120
|
+
select();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
areaLayers.forEach((group, i) => {
|
|
125
|
+
const area = usableAreas[i];
|
|
126
|
+
group.addTo(map);
|
|
127
|
+
group.eachLayer(layer => { if (layer instanceof L.Path)
|
|
128
|
+
hit(layer, 'area', area.id, area.label, () => annotationSelection.current.onAreaSelect?.(area)); });
|
|
129
|
+
const [lng, lat] = journeyAreaLabel(area);
|
|
130
|
+
caption('area', area.id, area.label, [lat, lng]);
|
|
131
|
+
});
|
|
132
|
+
routeLayers.forEach((layer, i) => {
|
|
133
|
+
const route = routes[i];
|
|
134
|
+
layer.addTo(map);
|
|
135
|
+
if (routePoints[i].length < 2)
|
|
136
|
+
return;
|
|
137
|
+
hit(layer, 'route', route.id, route.label || route.id, () => annotationSelection.current.onRouteSelect?.(route));
|
|
138
|
+
if (route.label)
|
|
139
|
+
caption('route', route.id, route.label, routePoints[i][Math.floor((routePoints[i].length - 1) / 2)]);
|
|
140
|
+
});
|
|
141
|
+
setFeatureLabels(captions);
|
|
69
142
|
let fitted = false;
|
|
70
|
-
const fit = () => {
|
|
143
|
+
const fit = (animated = false) => {
|
|
71
144
|
map.invalidateSize();
|
|
72
145
|
const size = map.getSize();
|
|
73
146
|
if (size.x <= 0 || size.y <= 0)
|
|
74
147
|
return;
|
|
75
|
-
if (all.length)
|
|
76
|
-
|
|
77
|
-
|
|
148
|
+
if (all.length) {
|
|
149
|
+
const bounds = L.latLngBounds(all);
|
|
150
|
+
const zoom = Math.min(map.getBoundsZoom(bounds, false, L.point(88, 88)), Number.isFinite(maxFitZoom) ? Math.max(0, Math.min(20, maxFitZoom)) : 12);
|
|
151
|
+
const center = map.unproject(map.project(bounds.getSouthWest(), zoom).add(map.project(bounds.getNorthEast(), zoom)).divideBy(2), zoom);
|
|
152
|
+
fitZoomRef.current = zoom;
|
|
153
|
+
if (animated)
|
|
154
|
+
viewRef.current(center, zoom);
|
|
155
|
+
else
|
|
156
|
+
map.setView(center, zoom, { animate: false });
|
|
157
|
+
}
|
|
158
|
+
else
|
|
159
|
+
fitZoomRef.current = map.getZoom();
|
|
78
160
|
fitted = true;
|
|
79
161
|
};
|
|
80
|
-
fitRef.current = fit;
|
|
162
|
+
fitRef.current = () => fit(true);
|
|
81
163
|
const canvas = document.createElement('canvas');
|
|
82
164
|
canvas.className = 'inkra-journey-map__ink';
|
|
83
165
|
canvas.setAttribute('aria-hidden', 'true');
|
|
84
166
|
host.current.append(canvas);
|
|
167
|
+
const base = new JourneyBaseBitmap();
|
|
168
|
+
const pinSurfaces = [];
|
|
169
|
+
const paintPins = () => {
|
|
170
|
+
const style = getComputedStyle(host.current);
|
|
171
|
+
const defaultInk = style.getPropertyValue('--inkra-accent-strong').trim();
|
|
172
|
+
const paper = style.getPropertyValue('--inkra-paper').trim();
|
|
173
|
+
const size = journeyCanvasSize(44, 44, window.devicePixelRatio || 1);
|
|
174
|
+
for (const pin of pinSurfaces) {
|
|
175
|
+
if (pin.place.icon !== undefined && pin.place.icon !== null && pin.place.icon !== false)
|
|
176
|
+
continue;
|
|
177
|
+
const selected = selectedRef.current === pin.place.id;
|
|
178
|
+
pin.canvas.style.color = defaultInk;
|
|
179
|
+
if (pin.place.color)
|
|
180
|
+
pin.canvas.style.color = pin.place.color;
|
|
181
|
+
const ink = getComputedStyle(pin.canvas).color;
|
|
182
|
+
const key = JSON.stringify([PENCIL_RENDERER_VERSION, ink, paper, size.dpr, selected]);
|
|
183
|
+
if (pin.key === key)
|
|
184
|
+
continue;
|
|
185
|
+
const context = prepareJourneyCanvas(pin.canvas, size, true);
|
|
186
|
+
if (!context)
|
|
187
|
+
continue;
|
|
188
|
+
paintStop(context, 22, 22, seedToNumber(pin.place.id), ink, paper, selected);
|
|
189
|
+
pin.key = key;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
85
192
|
let frame = 0;
|
|
193
|
+
let animating = false, preparingView = false;
|
|
194
|
+
let activeView;
|
|
86
195
|
let paintOrigin = map.containerPointToLatLng([0, 0]), paintZoom = map.getZoom();
|
|
196
|
+
let paintWidth = 0, paintHeight = 0, previousSignature = '', previousKey = '';
|
|
87
197
|
const paint = () => {
|
|
88
198
|
frame = 0;
|
|
89
|
-
const
|
|
90
|
-
if (!
|
|
199
|
+
const viewport = map.getSize();
|
|
200
|
+
if (!viewport.x || !viewport.y)
|
|
91
201
|
return;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
202
|
+
// A small reserve keeps ordinary pans on the compositor. Larger pans
|
|
203
|
+
// replenish only exposed strips, within the same backing-store budgets.
|
|
204
|
+
let padding = 64;
|
|
205
|
+
let size = journeyCanvasSize(viewport.x + padding * 2, viewport.y + padding * 2, window.devicePixelRatio || 1);
|
|
206
|
+
if (!size) {
|
|
207
|
+
padding = 0;
|
|
208
|
+
size = journeyCanvasSize(viewport.x, viewport.y, window.devicePixelRatio || 1);
|
|
209
|
+
}
|
|
210
|
+
if (!size) {
|
|
211
|
+
base.clear();
|
|
212
|
+
canvas.width = canvas.height = 0;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const { width, height } = size;
|
|
216
|
+
const options = renderingRef.current;
|
|
217
|
+
// Release the old snapshot before resizing the main canvas so the two
|
|
218
|
+
// live backing stores stay within the combined six-Mi-pixel budget.
|
|
219
|
+
if (!options.cacheBase || canvas.width !== size.pixelWidth || canvas.height !== size.pixelHeight)
|
|
220
|
+
base.clear();
|
|
221
|
+
const context = prepareJourneyCanvas(canvas, size, options.reuseCanvas);
|
|
98
222
|
if (!context)
|
|
99
223
|
return;
|
|
100
|
-
|
|
101
|
-
paintOrigin = map.containerPointToLatLng([0, 0]);
|
|
102
|
-
paintZoom = map.getZoom();
|
|
103
|
-
context.scale(dpr, dpr);
|
|
224
|
+
const zoom = map.getZoom();
|
|
104
225
|
const style = getComputedStyle(host.current);
|
|
105
226
|
const paper = style.getPropertyValue('--inkra-paper').trim();
|
|
106
227
|
const dark = Number.parseInt(paper.replace('#', '').slice(0, 2), 16) < 100;
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
paintTerrain(context, water, project, width, height, dark, 'water');
|
|
115
|
-
const draw = (line, color, seed, weight) => {
|
|
116
|
-
const projected = line.map(point => { const p = map.latLngToContainerPoint(point); return [p.x, p.y]; });
|
|
117
|
-
pencilStroke(context, projected, color, weight, seed, .3, 3, false, { grain: .7, breaks: .15, variation: .8, gain: 2.5, step: .8 });
|
|
228
|
+
const border = style.getPropertyValue('--inkra-border-strong').trim();
|
|
229
|
+
const accent = style.getPropertyValue('--inkra-accent').trim();
|
|
230
|
+
const colors = featureHits.current.map(item => getComputedStyle(item.path).stroke || accent);
|
|
231
|
+
const colorLookup = new Map(featureHits.current.map((item, i) => [`${item.kind}:${item.id}`, colors[i]]));
|
|
232
|
+
const featureColor = (kind, id) => {
|
|
233
|
+
const color = colorLookup.get(`${kind}:${id}`);
|
|
234
|
+
return color && color !== 'none' ? color : accent;
|
|
118
235
|
};
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
236
|
+
const selection = annotationSelection.current;
|
|
237
|
+
const project = (position) => { const point = map.latLngToContainerPoint([position[1], position[0]]); return [point.x + padding, point.y + padding]; };
|
|
238
|
+
const origin = project([0, 0]);
|
|
239
|
+
// Data belongs to this effect instance. Its cleanup releases the bitmap;
|
|
240
|
+
// the remaining key covers every base-pixel input that can change in it.
|
|
241
|
+
const signature = JSON.stringify([PENCIL_RENDERER_VERSION, 'journey-base-v2', background, width, height, size.pixelWidth, size.pixelHeight, size.dpr,
|
|
242
|
+
zoom, paper, dark, border, accent, colors, selection.selectedRoute, selection.selectedArea, options.boundaryViewport, options.terrainViewport]);
|
|
243
|
+
const baseKey = signature + JSON.stringify(origin);
|
|
244
|
+
const draw = (line, color, seed, weight, x, y, w, h) => {
|
|
245
|
+
const projected = line.map(point => { const p = map.latLngToContainerPoint(point); return [p.x + padding - x, p.y + padding - y]; });
|
|
246
|
+
pencilStroke(context, projected, color, weight, seed, .3, 3, false, { grain: .7, breaks: .15, variation: .8, gain: 2.5, step: .8,
|
|
247
|
+
...(options.boundaryViewport ? { viewport: [w, h], viewportTop: -8 } : {}) });
|
|
248
|
+
};
|
|
249
|
+
if (!options.cacheBase || !base.restore(context, baseKey)) {
|
|
250
|
+
const oldOrigin = map.latLngToContainerPoint(paintOrigin);
|
|
251
|
+
const offsetX = (oldOrigin.x + padding) * size.dpr, offsetY = (oldOrigin.y + padding) * size.dpr;
|
|
252
|
+
const pixelX = Math.round(offsetX), pixelY = Math.round(offsetY);
|
|
253
|
+
const dx = pixelX / size.dpr, dy = pixelY / size.dpr;
|
|
254
|
+
// Fractional backing-pixel copies would repeatedly blur pencil ink.
|
|
255
|
+
// Geographic round trips can add tiny floating-point residue.
|
|
256
|
+
const aligned = Math.abs(offsetX - pixelX) < 1e-6 && Math.abs(offsetY - pixelY) < 1e-6;
|
|
257
|
+
const shifted = options.cacheBase && previousSignature === signature && aligned
|
|
258
|
+
&& Math.abs(dx) < width && Math.abs(dy) < height
|
|
259
|
+
&& base.restore(context, previousKey, [pixelX, pixelY]);
|
|
260
|
+
const regions = shifted ? journeyPanRegions(width, height, dx, dy) : [[0, 0, width, height]];
|
|
261
|
+
for (const [x, y, w, h] of regions) {
|
|
262
|
+
context.save();
|
|
263
|
+
context.translate(x, y);
|
|
264
|
+
context.beginPath();
|
|
265
|
+
context.rect(0, 0, w, h);
|
|
266
|
+
context.clip();
|
|
267
|
+
const localProject = (position) => { const p = project(position); return [p[0] - x, p[1] - y]; };
|
|
268
|
+
if (background === 'sea')
|
|
269
|
+
paintSea(context, w, h, localProject([0, 0]));
|
|
270
|
+
if (geography)
|
|
271
|
+
paintTerrain(context, geography, localProject, w, h, dark, 'land', options.terrainViewport);
|
|
272
|
+
if (landCover)
|
|
273
|
+
paintTerrain(context, landCover, localProject, w, h, dark, 'land', options.terrainViewport);
|
|
274
|
+
if (water)
|
|
275
|
+
paintTerrain(context, water, localProject, w, h, dark, 'water', options.terrainViewport);
|
|
276
|
+
boundaries.forEach((line, i) => draw(line, border, i + 137, 1.2, x, y, w, h));
|
|
277
|
+
[...usableAreas].sort((a, b) => Number(a.id === selection.selectedArea) - Number(b.id === selection.selectedArea)).forEach(area => paintJourneyArea(context, area, localProject, w, h, featureColor('area', area.id), selection.selectedArea === area.id));
|
|
278
|
+
routePoints.map((line, i) => ({ line, route: routes[i] })).sort((a, b) => Number(a.route.id === selection.selectedRoute) - Number(b.route.id === selection.selectedRoute)).forEach(({ line, route }) => {
|
|
279
|
+
const width = Number.isFinite(route.width) ? Math.max(.5, Math.min(8, route.width)) : 2;
|
|
280
|
+
draw(line, featureColor('route', route.id), seedToNumber(route.id), width + (route.id === selection.selectedRoute ? 1 : 0), x, y, w, h);
|
|
281
|
+
});
|
|
282
|
+
context.restore();
|
|
283
|
+
}
|
|
284
|
+
if (options.cacheBase)
|
|
285
|
+
base.capture(canvas, size, baseKey);
|
|
286
|
+
}
|
|
287
|
+
previousSignature = signature;
|
|
288
|
+
previousKey = baseKey;
|
|
289
|
+
paintOrigin = map.containerPointToLatLng([-padding, -padding]);
|
|
290
|
+
paintZoom = zoom;
|
|
291
|
+
paintWidth = width;
|
|
292
|
+
paintHeight = height;
|
|
293
|
+
canvas.style.transformOrigin = '0 0';
|
|
294
|
+
canvas.style.transform = `translate(${-padding}px, ${-padding}px)`;
|
|
125
295
|
};
|
|
126
|
-
const schedule = () => { if (!frame)
|
|
296
|
+
const schedule = () => { paintPins(); if (!animating && !frame)
|
|
127
297
|
frame = requestAnimationFrame(paint); };
|
|
128
298
|
paintRef.current = schedule;
|
|
299
|
+
viewRef.current = (center, targetZoom) => {
|
|
300
|
+
const target = L.latLng(center);
|
|
301
|
+
const zoom = Math.max(map.getMinZoom(), Math.min(map.getMaxZoom(), targetZoom));
|
|
302
|
+
if (activeView?.center.equals(target) && activeView.zoom === zoom)
|
|
303
|
+
return;
|
|
304
|
+
map.stop();
|
|
305
|
+
animating = false;
|
|
306
|
+
activeView = undefined;
|
|
307
|
+
const settings = viewOptions.current;
|
|
308
|
+
const duration = journeyAnimationMs(settings.animate, settings.animationDuration, matchMedia('(prefers-reduced-motion: reduce)').matches);
|
|
309
|
+
if (!duration || (map.getCenter().equals(target) && map.getZoom() === zoom)) {
|
|
310
|
+
map.setView(target, zoom, { animate: false });
|
|
311
|
+
if (frame)
|
|
312
|
+
cancelAnimationFrame(frame);
|
|
313
|
+
paint();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const start = map.getCenter(), startZoom = map.getZoom(), viewport = map.getSize();
|
|
317
|
+
const from = map.project(start, startZoom), to = map.project(target, startZoom);
|
|
318
|
+
const extent = Math.max(viewport.x, viewport.y);
|
|
319
|
+
// Bound Leaflet's flyTo camera envelope (rho² < 2.1), including its
|
|
320
|
+
// temporary zoom-out. Rasterize it once, then transform that bitmap.
|
|
321
|
+
const span = extent * (1 + map.getZoomScale(startZoom, zoom)) + 2.1 * from.distanceTo(to);
|
|
322
|
+
const scale = Math.max((Math.abs(to.x - from.x) + viewport.x * span / extent) / viewport.x, (Math.abs(to.y - from.y) + viewport.y * span / extent) / viewport.y);
|
|
323
|
+
const overview = map.unproject(from.add(to).divideBy(2), startZoom);
|
|
324
|
+
animating = true;
|
|
325
|
+
preparingView = true;
|
|
326
|
+
if (frame)
|
|
327
|
+
cancelAnimationFrame(frame);
|
|
328
|
+
frame = 0;
|
|
329
|
+
map.setView(overview, startZoom - Math.log2(scale), { animate: false });
|
|
330
|
+
paint();
|
|
331
|
+
map.setView(start, startZoom, { animate: false });
|
|
332
|
+
preparingView = false;
|
|
333
|
+
activeView = { center: target, zoom };
|
|
334
|
+
map.flyTo(target, zoom, { animate: true, duration: duration / 1000 });
|
|
335
|
+
};
|
|
336
|
+
zoomRef.current = delta => viewRef.current(activeView?.center ?? map.getCenter(), (activeView?.zoom ?? map.getZoom()) + delta);
|
|
337
|
+
focusRef.current = place => viewRef.current([place.latitude, place.longitude], journeyFocusZoom(fitZoomRef.current, viewOptions.current.focusZoomScale));
|
|
338
|
+
const restore = () => { base.clear(); schedule(); };
|
|
339
|
+
canvas.addEventListener('contextrestored', restore);
|
|
129
340
|
// Reuse the existing raster during a pan; repaint geographical detail on rest.
|
|
130
341
|
const translate = () => {
|
|
131
342
|
const point = map.latLngToContainerPoint(paintOrigin);
|
|
343
|
+
const scale = map.getZoomScale(map.getZoom(), paintZoom);
|
|
132
344
|
canvas.style.transformOrigin = '0 0';
|
|
133
|
-
canvas.style.transform = `translate(${point.x}px, ${point.y}px) scale(${
|
|
345
|
+
canvas.style.transform = `translate(${point.x}px, ${point.y}px) scale(${scale})`;
|
|
346
|
+
const viewport = map.getSize();
|
|
347
|
+
if (!animating && (point.x > 0 || point.y > 0 || point.x + paintWidth * scale < viewport.x || point.y + paintHeight * scale < viewport.y))
|
|
348
|
+
schedule();
|
|
134
349
|
};
|
|
135
|
-
const settle = () => {
|
|
350
|
+
const settle = () => { if (preparingView)
|
|
351
|
+
return; animating = false; activeView = undefined; schedule(); };
|
|
136
352
|
map.on('move', translate);
|
|
137
353
|
map.on('moveend zoomend resize', settle);
|
|
138
|
-
map.on('dragstart', () => map.stop());
|
|
354
|
+
map.on('dragstart', () => { map.stop(); animating = false; activeView = undefined; });
|
|
139
355
|
const labelNodes = [];
|
|
140
356
|
markersRef.current = points.map(place => {
|
|
357
|
+
const symbol = document.createElement('span');
|
|
358
|
+
symbol.className = 'inkra-journey-map__symbol';
|
|
359
|
+
const ink = document.createElement('canvas');
|
|
360
|
+
ink.className = 'inkra-journey-map__stop';
|
|
361
|
+
ink.setAttribute('aria-hidden', 'true');
|
|
141
362
|
const node = document.createElement('span');
|
|
142
363
|
node.className = 'inkra-journey-map__pin';
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
364
|
+
const icon = document.createElement('span');
|
|
365
|
+
icon.className = 'inkra-journey-map__custom-icon';
|
|
366
|
+
if (place.color)
|
|
367
|
+
icon.style.color = place.color;
|
|
368
|
+
const custom = place.icon !== undefined && place.icon !== null && place.icon !== false;
|
|
369
|
+
ink.hidden = custom;
|
|
370
|
+
icon.hidden = !custom;
|
|
371
|
+
symbol.append(ink, icon, node);
|
|
372
|
+
const pin = { place, canvas: ink, key: '', restore: () => { pin.key = ''; paintPins(); } };
|
|
373
|
+
ink.addEventListener('contextrestored', pin.restore);
|
|
374
|
+
pinSurfaces.push(pin);
|
|
375
|
+
labelNodes.push({ place, node, icon });
|
|
376
|
+
const marker = L.marker([place.latitude, place.longitude], { icon: L.divIcon({ html: symbol, className: 'inkra-journey-map__marker', iconSize: [44, 44], iconAnchor: [22, 22] }), keyboard: true, title: place.label, alt: place.label }).addTo(map);
|
|
377
|
+
marker.on('click', () => selectPlace(place));
|
|
146
378
|
marker.getElement()?.setAttribute('aria-pressed', String(selectedRef.current === place.id));
|
|
147
379
|
return { place, marker };
|
|
148
380
|
});
|
|
@@ -156,31 +388,59 @@ export function JourneyMap({ places, routes = noRoutes, geography, landCover, wa
|
|
|
156
388
|
}
|
|
157
389
|
const scheme = matchMedia('(prefers-color-scheme: dark)');
|
|
158
390
|
scheme.addEventListener('change', schedule);
|
|
391
|
+
let resolution;
|
|
392
|
+
const watchResolution = () => {
|
|
393
|
+
resolution?.removeEventListener('change', resolutionChanged);
|
|
394
|
+
resolution = matchMedia(`(resolution: ${window.devicePixelRatio || 1}dppx)`);
|
|
395
|
+
resolution.addEventListener('change', resolutionChanged);
|
|
396
|
+
};
|
|
397
|
+
const resolutionChanged = () => { watchResolution(); schedule(); };
|
|
398
|
+
watchResolution();
|
|
159
399
|
fit();
|
|
160
400
|
schedule();
|
|
161
401
|
setReady(true);
|
|
162
|
-
cleanup = () => { cancelAnimationFrame(frame); observer.disconnect(); appearance.disconnect(); scheme.removeEventListener('change', schedule); map.remove(); canvas.remove(); mapRef.current = null; markersRef.current = []; paintRef.current = () => { }; };
|
|
402
|
+
cleanup = () => { cancelAnimationFrame(frame); observer.disconnect(); appearance.disconnect(); scheme.removeEventListener('change', schedule); resolution.removeEventListener('change', resolutionChanged); canvas.removeEventListener('contextrestored', restore); base.clear(); pinSurfaces.forEach(pin => { pin.canvas.removeEventListener('contextrestored', pin.restore); pin.canvas.width = pin.canvas.height = 0; }); map.remove(); canvas.width = canvas.height = 0; canvas.remove(); mapRef.current = null; markersRef.current = []; featureHits.current = []; paintRef.current = () => { }; viewRef.current = () => { }; zoomRef.current = () => { }; focusRef.current = () => { }; };
|
|
163
403
|
}).catch(() => { if (!cancelled) {
|
|
164
404
|
setFailed(true);
|
|
165
405
|
cleanup?.();
|
|
166
406
|
cleanup = undefined;
|
|
167
407
|
} });
|
|
168
408
|
return () => { cancelled = true; cleanup?.(); };
|
|
169
|
-
}, [places, routes, geography, landCover, water]);
|
|
409
|
+
}, [places, routes, areas, geography, landCover, water, maxFitZoom, background]);
|
|
170
410
|
useEffect(() => {
|
|
171
411
|
markersRef.current.forEach(({ place, marker }) => marker.getElement()?.setAttribute('aria-pressed', String(selectedPlace === place.id)));
|
|
172
412
|
paintRef.current();
|
|
413
|
+
const changed = previousSelection.current.place !== selectedPlace || previousSelection.current.scale !== focusZoomScale;
|
|
414
|
+
previousSelection.current = { place: selectedPlace, scale: focusZoomScale };
|
|
173
415
|
const place = markersRef.current.find(item => item.place.id === selectedPlace)?.place;
|
|
174
|
-
if (focusSelectedPlace && place)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
416
|
+
if (focusSelectedPlace && changed && place)
|
|
417
|
+
focusRef.current(place);
|
|
418
|
+
}, [selectedPlace, focusSelectedPlace, focusZoomScale, ready]);
|
|
419
|
+
useEffect(() => {
|
|
420
|
+
for (const item of featureHits.current) {
|
|
421
|
+
const enabled = item.kind === 'route' ? Boolean(onRouteSelect) : Boolean(onAreaSelect);
|
|
422
|
+
const selected = item.id === (item.kind === 'route' ? selectedRoute : selectedArea);
|
|
423
|
+
item.path.style.pointerEvents = enabled ? 'auto' : 'none';
|
|
424
|
+
item.path.setAttribute('role', enabled ? 'button' : 'img');
|
|
425
|
+
item.path.setAttribute('tabindex', enabled ? '0' : '-1');
|
|
426
|
+
item.path.setAttribute('aria-label', item.label);
|
|
427
|
+
if (enabled)
|
|
428
|
+
item.path.setAttribute('aria-pressed', String(selected));
|
|
180
429
|
else
|
|
181
|
-
|
|
430
|
+
item.path.removeAttribute('aria-pressed');
|
|
182
431
|
}
|
|
183
|
-
|
|
432
|
+
// Keep native hit order consistent with the painted selected-on-top order.
|
|
433
|
+
const order = [...featureHits.current].sort((a, b) => {
|
|
434
|
+
if (a.kind !== b.kind)
|
|
435
|
+
return a.kind === 'area' ? -1 : 1;
|
|
436
|
+
const selected = a.kind === 'area' ? selectedArea : selectedRoute;
|
|
437
|
+
return Number(a.id === selected) - Number(b.id === selected);
|
|
438
|
+
});
|
|
439
|
+
for (const item of order)
|
|
440
|
+
item.path.parentNode?.appendChild(item.path);
|
|
441
|
+
paintRef.current();
|
|
442
|
+
}, [selectedRoute, selectedArea, Boolean(onRouteSelect), Boolean(onAreaSelect), ready]);
|
|
443
|
+
useEffect(() => { paintRef.current(); }, [renderingOptions.boundaryViewport, renderingOptions.terrainViewport, renderingOptions.reuseCanvas, renderingOptions.cacheBase]);
|
|
184
444
|
useEffect(() => {
|
|
185
445
|
const map = mapRef.current;
|
|
186
446
|
if (!map || !ready)
|
|
@@ -195,6 +455,8 @@ export function JourneyMap({ places, routes = noRoutes, geography, landCover, wa
|
|
|
195
455
|
map.scrollWheelZoom.enable();
|
|
196
456
|
else
|
|
197
457
|
map.scrollWheelZoom.disable();
|
|
458
|
+
if (interactive && host.current)
|
|
459
|
+
return attachJourneyTrackpad(host.current, map);
|
|
198
460
|
}, [interactive, scrollWheelZoom, ready]);
|
|
199
|
-
return _jsxs("div", { ...props, ref: root, className: ['inkra-journey-map', className].filter(Boolean).join(' '), children: [placeLabels.map(({ place, node }) => createPortal(_jsx(PencilText, { children: place.label }), node, place.id)), _jsxs("div", { className: "inkra-journey-map__viewport", style: { height: Math.max(240, Math.min(800, height)) }, children: [attributionDisplay === 'popover' && _jsx("div", { className: "inkra-journey-map__credits", children: _jsxs(Popover.Root, { children: [_jsx(Popover.Trigger, { asChild: true, children: _jsx(Button, { size: "icon", variant: "ghost", "aria-label": labels.attribution ?? t('map.credits'), title: labels.attribution ?? t('map.credits'), children: _jsx(TravelIcon, { name: "info" }) }) }), _jsx(Popover.Portal, { children: _jsxs(Popover.Content, { align: "end", sideOffset: 8, collisionPadding: 20, className: "inkra-journey-map__credits-content", children: [_jsx(Popover.Title, { children: labels.attribution ?? t('map.credits') }), _jsx(Popover.Description, { children: attribution })] }) })] }) }), _jsx("div", { ref: host, className: "inkra-journey-map__host", "aria-label": props['aria-label'] ?? t('map.label') }), showControls && _jsxs("div", { className: "inkra-journey-map__tools", children: [_jsx(Button, { size: "icon", variant: "ghost", disabled: !ready, "aria-label": labels.zoomIn, title: labels.zoomIn, onClick: () =>
|
|
461
|
+
return _jsxs("div", { ...props, ref: root, className: ['inkra-journey-map', className].filter(Boolean).join(' '), children: [placeLabels.map(({ place, node, icon }) => _jsxs("span", { className: "inkra-journey-map__portal-owner", children: [createPortal(_jsx(PencilText, { children: place.label }), node), createPortal(place.icon, icon)] }, place.id)), featureLabels.map(({ kind, id, label, node }) => createPortal(_jsx(PencilSurface, { variant: "paper", radius: 4, className: "inkra-journey-map__feature-label", "data-selected": id === (kind === 'area' ? selectedArea : selectedRoute), children: _jsx(PencilText, { children: label }) }), node, `${kind}:${id}`)), _jsxs("div", { className: "inkra-journey-map__viewport", style: { height: Math.max(240, Math.min(800, height)) }, children: [attributionDisplay === 'popover' && _jsx("div", { className: "inkra-journey-map__credits", children: _jsxs(Popover.Root, { children: [_jsx(Popover.Trigger, { asChild: true, children: _jsx(Button, { size: "icon", variant: "ghost", "aria-label": labels.attribution ?? t('map.credits'), title: labels.attribution ?? t('map.credits'), children: _jsx(TravelIcon, { name: "info" }) }) }), _jsx(Popover.Portal, { children: _jsxs(Popover.Content, { align: "end", sideOffset: 8, collisionPadding: 20, className: "inkra-journey-map__credits-content", children: [_jsx(Popover.Title, { children: labels.attribution ?? t('map.credits') }), _jsx(Popover.Description, { children: attribution })] }) })] }) }), _jsx("div", { ref: host, className: "inkra-journey-map__host", "aria-label": props['aria-label'] ?? t('map.label') }), showControls && _jsxs("div", { className: "inkra-journey-map__tools", children: [_jsx(Button, { size: "icon", variant: "ghost", disabled: !ready, "aria-label": labels.zoomIn, title: labels.zoomIn, onClick: () => zoomRef.current(1), children: _jsx(TravelIcon, { name: "plus" }) }), _jsx(Button, { size: "icon", variant: "ghost", disabled: !ready, "aria-label": labels.zoomOut, title: labels.zoomOut, onClick: () => zoomRef.current(-1), children: _jsx(TravelIcon, { name: "minus" }) }), _jsx(Button, { size: "icon", variant: "ghost", disabled: !ready, "aria-label": labels.reset, title: labels.reset, onClick: () => { mapRef.current?.stop(); fitRef.current(); }, children: _jsx(TravelIcon, { name: "reset" }) })] }), ready && _jsx(MapScale, { map: mapRef.current }), failed && _jsx("p", { role: "status", children: labels.unavailable })] }), attributionDisplay === 'inline' && _jsx("p", { className: "inkra-journey-map__attribution", children: attribution }), (showPlaceList || !ready || failed) && _jsx("ol", { className: "inkra-journey-map__places", children: places.filter(valid).map((place, index) => _jsx("li", { children: _jsxs("button", { type: "button", "aria-pressed": selectedPlace === place.id, onClick: () => selectPlace(place, true), children: [_jsx("span", { "aria-hidden": "true", children: index + 1 }), _jsx(PencilText, { children: place.label })] }) }, place.id)) }), (showFeatureList || !ready || failed) && (routes.length > 0 || usableAreas.length > 0) && _jsxs("ol", { className: "inkra-journey-map__features", "aria-label": labels.features, children: [routes.map(route => _jsx("li", { children: onRouteSelect ? _jsx("button", { type: "button", "aria-pressed": selectedRoute === route.id, onClick: () => onRouteSelect(route), children: _jsx(PencilText, { children: route.label || route.id }) }) : _jsx(PencilText, { children: route.label || route.id }) }, `route:${route.id}`)), usableAreas.map(area => _jsx("li", { children: onAreaSelect ? _jsx("button", { type: "button", "aria-pressed": selectedArea === area.id, onClick: () => onAreaSelect(area), children: _jsx(PencilText, { children: area.label }) }) : _jsx(PencilText, { children: area.label }) }, `area:${area.id}`))] })] });
|
|
200
462
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Independent rendering strategies; omitted options inherit the auto defaults. */
|
|
2
|
+
export interface JourneyMapRenderingOptions {
|
|
3
|
+
/** Reject offscreen boundary/route stroke segments without changing their samples. */
|
|
4
|
+
boundaryViewport?: boolean;
|
|
5
|
+
/** Skip terrain cells whose entire ink envelope misses the polygon row band. */
|
|
6
|
+
terrainViewport?: boolean;
|
|
7
|
+
/** Keep an unchanged backing size and explicitly clear/reset the drawing transform. */
|
|
8
|
+
reuseCanvas?: boolean;
|
|
9
|
+
/** Reuse one bounded base bitmap when only place selection changes. */
|
|
10
|
+
cacheBase?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export type JourneyMapRendering = 'auto' | 'legacy' | JourneyMapRenderingOptions;
|
|
13
|
+
export declare function journeyAnimationMs(animate: boolean, duration: number, reducedMotion: boolean): number;
|
|
14
|
+
export declare function journeyFocusZoom(overviewZoom: number, scale: number): number;
|
|
15
|
+
export declare function resolveJourneyMapRendering(rendering?: JourneyMapRendering): Required<JourneyMapRenderingOptions>;
|
|
16
|
+
export interface JourneyCanvasSize {
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
pixelWidth: number;
|
|
20
|
+
pixelHeight: number;
|
|
21
|
+
dpr: number;
|
|
22
|
+
}
|
|
23
|
+
/** Non-overlapping newly exposed strips after moving an existing base bitmap. */
|
|
24
|
+
export declare function journeyPanRegions(width: number, height: number, dx: number, dy: number): readonly (readonly [number, number, number, number])[];
|
|
25
|
+
/** Pure and SSR safe: browser DPR is supplied by the caller. */
|
|
26
|
+
export declare function journeyCanvasSize(width: number, height: number, requestedDpr: number): JourneyCanvasSize | undefined;
|
|
27
|
+
export declare function prepareJourneyCanvas(canvas: HTMLCanvasElement, size: JourneyCanvasSize, reuse: boolean): CanvasRenderingContext2D | null;
|
|
28
|
+
/** Per-map, memory-only, single-entry cache. No DOM or allocation until capture. */
|
|
29
|
+
export declare class JourneyBaseBitmap {
|
|
30
|
+
private canvas;
|
|
31
|
+
private key;
|
|
32
|
+
private readonly invalidate;
|
|
33
|
+
restore(context: CanvasRenderingContext2D, key: string, offset?: readonly [number, number]): boolean;
|
|
34
|
+
capture(source: HTMLCanvasElement, size: JourneyCanvasSize, key: string): void;
|
|
35
|
+
clear(): void;
|
|
36
|
+
}
|