@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.
@@ -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
- const defaults = (t) => ({ zoomIn: t('common.zoomIn'), zoomOut: t('common.zoomOut'), reset: t('common.resetView'), unavailable: t('map.unavailable') });
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 = false, labels: labelOverrides, className, ...props }) {
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
- map.fitBounds(L.latLngBounds(all), { padding: [44, 44], maxZoom: 12, animate: false });
77
- fitZoomRef.current = map.getZoom();
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 { x: width, y: height } = map.getSize();
90
- if (!width || !height)
199
+ const viewport = map.getSize();
200
+ if (!viewport.x || !viewport.y)
91
201
  return;
92
- const dpr = Math.min(window.devicePixelRatio || 1, 2);
93
- canvas.width = Math.round(width * dpr);
94
- canvas.height = Math.round(height * dpr);
95
- canvas.style.width = `${width}px`;
96
- canvas.style.height = `${height}px`;
97
- const context = canvas.getContext('2d');
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
- canvas.style.transform = '';
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 project = (position) => { const point = map.latLngToContainerPoint([position[1], position[0]]); return [point.x, point.y]; };
108
- paintSea(context, width, height, project([0, 0]));
109
- if (geography)
110
- paintTerrain(context, geography, project, width, height, dark);
111
- if (landCover)
112
- paintTerrain(context, landCover, project, width, height, dark);
113
- if (water)
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
- boundaries.forEach((line, i) => draw(line, style.getPropertyValue('--inkra-border-strong').trim(), i + 137, 1.2));
120
- routePoints.forEach((line, i) => draw(line, style.getPropertyValue('--inkra-accent').trim(), i + 717, 2));
121
- points.forEach(place => {
122
- const p = map.latLngToContainerPoint([place.latitude, place.longitude]);
123
- paintStop(context, p.x, p.y, seedToNumber(place.id), style.getPropertyValue('--inkra-accent-strong').trim(), paper, selectedRef.current === place.id);
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(${map.getZoomScale(map.getZoom(), paintZoom)})`;
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 = () => { canvas.style.transform = ''; paintOrigin = map.containerPointToLatLng([0, 0]); paintZoom = map.getZoom(); schedule(); };
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
- labelNodes.push({ place, node });
144
- const marker = L.marker([place.latitude, place.longitude], { icon: L.divIcon({ html: node, className: 'inkra-journey-map__marker', iconSize: [44, 44], iconAnchor: [22, 22] }), keyboard: true, title: place.label, alt: place.label }).addTo(map);
145
- marker.on('click', () => selectionRef.current?.(place));
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
- const map = mapRef.current;
176
- map?.stop();
177
- const targetZoom = fitZoomRef.current + Math.log2(10);
178
- if (matchMedia('(prefers-reduced-motion: reduce)').matches)
179
- map?.setView([place.latitude, place.longitude], targetZoom, { animate: false });
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
- map?.flyTo([place.latitude, place.longitude], targetZoom, { duration: .85 });
430
+ item.path.removeAttribute('aria-pressed');
182
431
  }
183
- }, [selectedPlace, focusSelectedPlace, ready]);
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: () => { mapRef.current?.stop(); mapRef.current?.zoomIn(); }, children: _jsx(TravelIcon, { name: "plus" }) }), _jsx(Button, { size: "icon", variant: "ghost", disabled: !ready, "aria-label": labels.zoomOut, title: labels.zoomOut, onClick: () => { mapRef.current?.stop(); mapRef.current?.zoomOut(); }, 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: () => { mapRef.current?.panTo([place.latitude, place.longitude], { animate: false }); onPlaceSelect?.(place); }, children: [_jsx("span", { "aria-hidden": "true", children: index + 1 }), _jsx(PencilText, { children: place.label })] }) }, place.id)) })] });
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
+ }