@inkra/react 0.12.0 → 0.13.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,5 +1,7 @@
1
1
  import { type HTMLAttributes } from 'react';
2
2
  import type { GeoJsonObject } from 'geojson';
3
+ import { type JourneyMapRendering } from './rendering.js';
4
+ export type { JourneyMapRendering, JourneyMapRenderingOptions } from './rendering.js';
3
5
  export interface JourneyPlace {
4
6
  id: string;
5
7
  label: string;
@@ -22,12 +24,25 @@ export interface JourneyMapProps extends HTMLAttributes<HTMLDivElement> {
22
24
  attribution: string;
23
25
  attributionDisplay?: 'inline' | 'popover';
24
26
  height?: number;
27
+ /** Maximum zoom used by fit/reset; 0..20, default 12. Use 18 for street-scale data. */
28
+ maxFitZoom?: number;
29
+ /** Paper suits street diagrams without an ocean layer; default sea. */
30
+ background?: 'sea' | 'paper';
25
31
  /** Controls viewport gestures, not place selection or explicit zoom buttons. */
26
32
  interactive?: boolean;
27
33
  scrollWheelZoom?: boolean;
28
34
  showControls?: boolean;
29
35
  showPlaceList?: boolean;
36
+ /** Focus pin/list clicks and later selectedPlace changes; initial view stays fitted. */
30
37
  focusSelectedPlace?: boolean;
38
+ /** Magnification relative to the initial overview, 1..64; default 2. */
39
+ focusZoomScale?: number;
40
+ /** Animate programmatic view changes; reduced-motion preference always wins. */
41
+ animate?: boolean;
42
+ /** View transition duration in milliseconds, 0..5000; default 420. */
43
+ animationDuration?: number;
44
+ /** Auto uses bounded base caching, backing-store reuse and offscreen stroke rejection. */
45
+ rendering?: JourneyMapRendering;
31
46
  labels?: {
32
47
  zoomIn: string;
33
48
  zoomOut: string;
@@ -36,4 +51,4 @@ export interface JourneyMapProps extends HTMLAttributes<HTMLDivElement> {
36
51
  attribution?: string;
37
52
  };
38
53
  }
39
- export declare function JourneyMap({ places, routes, geography, landCover, water, selectedPlace, onPlaceSelect, attribution, attributionDisplay, height, interactive, scrollWheelZoom, showControls, showPlaceList, focusSelectedPlace, labels: labelOverrides, className, ...props }: JourneyMapProps): import("react").JSX.Element;
54
+ export declare function JourneyMap({ places, routes, geography, landCover, water, selectedPlace, onPlaceSelect, attribution, attributionDisplay, height, maxFitZoom, background, interactive, scrollWheelZoom, showControls, showPlaceList, focusSelectedPlace, focusZoomScale, animate, animationDuration, rendering, labels: labelOverrides, className, ...props }: JourneyMapProps): import("react").JSX.Element;
@@ -10,10 +10,12 @@ import { TravelIcon } from '../internal/travelIcons.js';
10
10
  import { paintTerrain, paintStop, paintSea } from './terrain.js';
11
11
  import { MapScale } from './MapScale.js';
12
12
  import { seedToNumber } from '../internal/random.js';
13
+ import { PENCIL_RENDERER_VERSION } from '../internal/cache.js';
14
+ import { JourneyBaseBitmap, journeyAnimationMs, journeyCanvasSize, journeyFocusZoom, journeyPanRegions, prepareJourneyCanvas, resolveJourneyMapRendering } from './rendering.js';
13
15
  const defaults = (t) => ({ zoomIn: t('common.zoomIn'), zoomOut: t('common.zoomOut'), reset: t('common.resetView'), unavailable: t('map.unavailable') });
14
16
  const noRoutes = [];
15
17
  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 }) {
18
+ export function JourneyMap({ places, routes = noRoutes, 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
19
  const { t } = useInkraLocale();
18
20
  const labels = { ...defaults(t), ...labelOverrides };
19
21
  const root = useRef(null);
@@ -21,6 +23,9 @@ export function JourneyMap({ places, routes = noRoutes, geography, landCover, wa
21
23
  const mapRef = useRef(null);
22
24
  const fitRef = useRef(() => { });
23
25
  const paintRef = useRef(() => { });
26
+ const viewRef = useRef(() => { });
27
+ const zoomRef = useRef(() => { });
28
+ const focusRef = useRef(() => { });
24
29
  const fitZoomRef = useRef(2);
25
30
  const selectionRef = useRef(onPlaceSelect);
26
31
  selectionRef.current = onPlaceSelect;
@@ -29,7 +34,20 @@ export function JourneyMap({ places, routes = noRoutes, geography, landCover, wa
29
34
  const [placeLabels, setPlaceLabels] = useState([]);
30
35
  const selectedRef = useRef(selectedPlace);
31
36
  selectedRef.current = selectedPlace;
37
+ const previousSelection = useRef({ place: selectedPlace, scale: focusZoomScale });
38
+ const viewOptions = useRef({ animate, animationDuration, focusSelectedPlace, focusZoomScale });
39
+ viewOptions.current = { animate, animationDuration, focusSelectedPlace, focusZoomScale };
40
+ const selectPlace = (place, fromList = false) => {
41
+ if (viewOptions.current.focusSelectedPlace)
42
+ focusRef.current(place);
43
+ else if (fromList && mapRef.current)
44
+ viewRef.current([place.latitude, place.longitude], mapRef.current.getZoom());
45
+ selectionRef.current?.(place);
46
+ };
32
47
  const markersRef = useRef([]);
48
+ const renderingOptions = resolveJourneyMapRendering(rendering);
49
+ const renderingRef = useRef(renderingOptions);
50
+ renderingRef.current = renderingOptions;
33
51
  useEffect(() => {
34
52
  let cancelled = false;
35
53
  let cleanup;
@@ -67,82 +85,217 @@ export function JourneyMap({ places, routes = noRoutes, geography, landCover, wa
67
85
  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
86
  const all = [...points.map(place => L.latLng(place.latitude, place.longitude)), ...boundaries.flat(), ...routePoints.flat()];
69
87
  let fitted = false;
70
- const fit = () => {
88
+ const fit = (animated = false) => {
71
89
  map.invalidateSize();
72
90
  const size = map.getSize();
73
91
  if (size.x <= 0 || size.y <= 0)
74
92
  return;
75
- if (all.length)
76
- map.fitBounds(L.latLngBounds(all), { padding: [44, 44], maxZoom: 12, animate: false });
77
- fitZoomRef.current = map.getZoom();
93
+ if (all.length) {
94
+ const bounds = L.latLngBounds(all);
95
+ const zoom = Math.min(map.getBoundsZoom(bounds, false, L.point(88, 88)), Number.isFinite(maxFitZoom) ? Math.max(0, Math.min(20, maxFitZoom)) : 12);
96
+ const center = map.unproject(map.project(bounds.getSouthWest(), zoom).add(map.project(bounds.getNorthEast(), zoom)).divideBy(2), zoom);
97
+ fitZoomRef.current = zoom;
98
+ if (animated)
99
+ viewRef.current(center, zoom);
100
+ else
101
+ map.setView(center, zoom, { animate: false });
102
+ }
103
+ else
104
+ fitZoomRef.current = map.getZoom();
78
105
  fitted = true;
79
106
  };
80
- fitRef.current = fit;
107
+ fitRef.current = () => fit(true);
81
108
  const canvas = document.createElement('canvas');
82
109
  canvas.className = 'inkra-journey-map__ink';
83
110
  canvas.setAttribute('aria-hidden', 'true');
84
111
  host.current.append(canvas);
112
+ const base = new JourneyBaseBitmap();
113
+ const pinSurfaces = [];
114
+ const paintPins = () => {
115
+ const style = getComputedStyle(host.current);
116
+ const ink = style.getPropertyValue('--inkra-accent-strong').trim();
117
+ const paper = style.getPropertyValue('--inkra-paper').trim();
118
+ const size = journeyCanvasSize(44, 44, window.devicePixelRatio || 1);
119
+ for (const pin of pinSurfaces) {
120
+ const selected = selectedRef.current === pin.place.id;
121
+ const key = JSON.stringify([PENCIL_RENDERER_VERSION, ink, paper, size.dpr, selected]);
122
+ if (pin.key === key)
123
+ continue;
124
+ const context = prepareJourneyCanvas(pin.canvas, size, true);
125
+ if (!context)
126
+ continue;
127
+ paintStop(context, 22, 22, seedToNumber(pin.place.id), ink, paper, selected);
128
+ pin.key = key;
129
+ }
130
+ };
85
131
  let frame = 0;
132
+ let animating = false, preparingView = false;
133
+ let activeView;
86
134
  let paintOrigin = map.containerPointToLatLng([0, 0]), paintZoom = map.getZoom();
135
+ let paintWidth = 0, paintHeight = 0, previousSignature = '', previousKey = '';
87
136
  const paint = () => {
88
137
  frame = 0;
89
- const { x: width, y: height } = map.getSize();
90
- if (!width || !height)
138
+ const viewport = map.getSize();
139
+ if (!viewport.x || !viewport.y)
140
+ return;
141
+ // A small reserve keeps ordinary pans on the compositor. Larger pans
142
+ // replenish only exposed strips, within the same backing-store budgets.
143
+ let padding = 64;
144
+ let size = journeyCanvasSize(viewport.x + padding * 2, viewport.y + padding * 2, window.devicePixelRatio || 1);
145
+ if (!size) {
146
+ padding = 0;
147
+ size = journeyCanvasSize(viewport.x, viewport.y, window.devicePixelRatio || 1);
148
+ }
149
+ if (!size) {
150
+ base.clear();
151
+ canvas.width = canvas.height = 0;
91
152
  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');
153
+ }
154
+ const { width, height } = size;
155
+ const options = renderingRef.current;
156
+ // Release the old snapshot before resizing the main canvas so the two
157
+ // live backing stores stay within the combined six-Mi-pixel budget.
158
+ if (!options.cacheBase || canvas.width !== size.pixelWidth || canvas.height !== size.pixelHeight)
159
+ base.clear();
160
+ const context = prepareJourneyCanvas(canvas, size, options.reuseCanvas);
98
161
  if (!context)
99
162
  return;
100
- canvas.style.transform = '';
101
- paintOrigin = map.containerPointToLatLng([0, 0]);
102
- paintZoom = map.getZoom();
103
- context.scale(dpr, dpr);
163
+ const zoom = map.getZoom();
104
164
  const style = getComputedStyle(host.current);
105
165
  const paper = style.getPropertyValue('--inkra-paper').trim();
106
166
  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 });
167
+ const border = style.getPropertyValue('--inkra-border-strong').trim();
168
+ const accent = style.getPropertyValue('--inkra-accent').trim();
169
+ const project = (position) => { const point = map.latLngToContainerPoint([position[1], position[0]]); return [point.x + padding, point.y + padding]; };
170
+ const origin = project([0, 0]);
171
+ // Data belongs to this effect instance. Its cleanup releases the bitmap;
172
+ // the remaining key covers every base-pixel input that can change in it.
173
+ const signature = JSON.stringify([PENCIL_RENDERER_VERSION, 'journey-base-v2', background, width, height, size.pixelWidth, size.pixelHeight, size.dpr,
174
+ zoom, paper, dark, border, accent, options.boundaryViewport, options.terrainViewport]);
175
+ const baseKey = signature + JSON.stringify(origin);
176
+ const draw = (line, color, seed, weight, x, y, w, h) => {
177
+ const projected = line.map(point => { const p = map.latLngToContainerPoint(point); return [p.x + padding - x, p.y + padding - y]; });
178
+ pencilStroke(context, projected, color, weight, seed, .3, 3, false, { grain: .7, breaks: .15, variation: .8, gain: 2.5, step: .8,
179
+ ...(options.boundaryViewport ? { viewport: [w, h], viewportTop: -8 } : {}) });
118
180
  };
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
- });
181
+ if (!options.cacheBase || !base.restore(context, baseKey)) {
182
+ const oldOrigin = map.latLngToContainerPoint(paintOrigin);
183
+ const offsetX = (oldOrigin.x + padding) * size.dpr, offsetY = (oldOrigin.y + padding) * size.dpr;
184
+ const pixelX = Math.round(offsetX), pixelY = Math.round(offsetY);
185
+ const dx = pixelX / size.dpr, dy = pixelY / size.dpr;
186
+ // Fractional backing-pixel copies would repeatedly blur pencil ink.
187
+ // Geographic round trips can add tiny floating-point residue.
188
+ const aligned = Math.abs(offsetX - pixelX) < 1e-6 && Math.abs(offsetY - pixelY) < 1e-6;
189
+ const shifted = options.cacheBase && previousSignature === signature && aligned
190
+ && Math.abs(dx) < width && Math.abs(dy) < height
191
+ && base.restore(context, previousKey, [pixelX, pixelY]);
192
+ const regions = shifted ? journeyPanRegions(width, height, dx, dy) : [[0, 0, width, height]];
193
+ for (const [x, y, w, h] of regions) {
194
+ context.save();
195
+ context.translate(x, y);
196
+ context.beginPath();
197
+ context.rect(0, 0, w, h);
198
+ context.clip();
199
+ const localProject = (position) => { const p = project(position); return [p[0] - x, p[1] - y]; };
200
+ if (background === 'sea')
201
+ paintSea(context, w, h, localProject([0, 0]));
202
+ if (geography)
203
+ paintTerrain(context, geography, localProject, w, h, dark, 'land', options.terrainViewport);
204
+ if (landCover)
205
+ paintTerrain(context, landCover, localProject, w, h, dark, 'land', options.terrainViewport);
206
+ if (water)
207
+ paintTerrain(context, water, localProject, w, h, dark, 'water', options.terrainViewport);
208
+ boundaries.forEach((line, i) => draw(line, border, i + 137, 1.2, x, y, w, h));
209
+ routePoints.forEach((line, i) => draw(line, accent, i + 717, 2, x, y, w, h));
210
+ context.restore();
211
+ }
212
+ if (options.cacheBase)
213
+ base.capture(canvas, size, baseKey);
214
+ }
215
+ previousSignature = signature;
216
+ previousKey = baseKey;
217
+ paintOrigin = map.containerPointToLatLng([-padding, -padding]);
218
+ paintZoom = zoom;
219
+ paintWidth = width;
220
+ paintHeight = height;
221
+ canvas.style.transformOrigin = '0 0';
222
+ canvas.style.transform = `translate(${-padding}px, ${-padding}px)`;
125
223
  };
126
- const schedule = () => { if (!frame)
224
+ const schedule = () => { paintPins(); if (!animating && !frame)
127
225
  frame = requestAnimationFrame(paint); };
128
226
  paintRef.current = schedule;
227
+ viewRef.current = (center, targetZoom) => {
228
+ const target = L.latLng(center);
229
+ const zoom = Math.max(map.getMinZoom(), Math.min(map.getMaxZoom(), targetZoom));
230
+ if (activeView?.center.equals(target) && activeView.zoom === zoom)
231
+ return;
232
+ map.stop();
233
+ animating = false;
234
+ activeView = undefined;
235
+ const settings = viewOptions.current;
236
+ const duration = journeyAnimationMs(settings.animate, settings.animationDuration, matchMedia('(prefers-reduced-motion: reduce)').matches);
237
+ if (!duration || (map.getCenter().equals(target) && map.getZoom() === zoom)) {
238
+ map.setView(target, zoom, { animate: false });
239
+ if (frame)
240
+ cancelAnimationFrame(frame);
241
+ paint();
242
+ return;
243
+ }
244
+ const start = map.getCenter(), startZoom = map.getZoom(), viewport = map.getSize();
245
+ const from = map.project(start, startZoom), to = map.project(target, startZoom);
246
+ const extent = Math.max(viewport.x, viewport.y);
247
+ // Bound Leaflet's flyTo camera envelope (rho² < 2.1), including its
248
+ // temporary zoom-out. Rasterize it once, then transform that bitmap.
249
+ const span = extent * (1 + map.getZoomScale(startZoom, zoom)) + 2.1 * from.distanceTo(to);
250
+ 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);
251
+ const overview = map.unproject(from.add(to).divideBy(2), startZoom);
252
+ animating = true;
253
+ preparingView = true;
254
+ if (frame)
255
+ cancelAnimationFrame(frame);
256
+ frame = 0;
257
+ map.setView(overview, startZoom - Math.log2(scale), { animate: false });
258
+ paint();
259
+ map.setView(start, startZoom, { animate: false });
260
+ preparingView = false;
261
+ activeView = { center: target, zoom };
262
+ map.flyTo(target, zoom, { animate: true, duration: duration / 1000 });
263
+ };
264
+ zoomRef.current = delta => viewRef.current(activeView?.center ?? map.getCenter(), (activeView?.zoom ?? map.getZoom()) + delta);
265
+ focusRef.current = place => viewRef.current([place.latitude, place.longitude], journeyFocusZoom(fitZoomRef.current, viewOptions.current.focusZoomScale));
266
+ const restore = () => { base.clear(); schedule(); };
267
+ canvas.addEventListener('contextrestored', restore);
129
268
  // Reuse the existing raster during a pan; repaint geographical detail on rest.
130
269
  const translate = () => {
131
270
  const point = map.latLngToContainerPoint(paintOrigin);
271
+ const scale = map.getZoomScale(map.getZoom(), paintZoom);
132
272
  canvas.style.transformOrigin = '0 0';
133
- canvas.style.transform = `translate(${point.x}px, ${point.y}px) scale(${map.getZoomScale(map.getZoom(), paintZoom)})`;
273
+ canvas.style.transform = `translate(${point.x}px, ${point.y}px) scale(${scale})`;
274
+ const viewport = map.getSize();
275
+ if (!animating && (point.x > 0 || point.y > 0 || point.x + paintWidth * scale < viewport.x || point.y + paintHeight * scale < viewport.y))
276
+ schedule();
134
277
  };
135
- const settle = () => { canvas.style.transform = ''; paintOrigin = map.containerPointToLatLng([0, 0]); paintZoom = map.getZoom(); schedule(); };
278
+ const settle = () => { if (preparingView)
279
+ return; animating = false; activeView = undefined; schedule(); };
136
280
  map.on('move', translate);
137
281
  map.on('moveend zoomend resize', settle);
138
- map.on('dragstart', () => map.stop());
282
+ map.on('dragstart', () => { map.stop(); animating = false; activeView = undefined; });
139
283
  const labelNodes = [];
140
284
  markersRef.current = points.map(place => {
285
+ const symbol = document.createElement('span');
286
+ symbol.className = 'inkra-journey-map__symbol';
287
+ const ink = document.createElement('canvas');
288
+ ink.className = 'inkra-journey-map__stop';
289
+ ink.setAttribute('aria-hidden', 'true');
141
290
  const node = document.createElement('span');
142
291
  node.className = 'inkra-journey-map__pin';
292
+ symbol.append(ink, node);
293
+ const pin = { place, canvas: ink, key: '', restore: () => { pin.key = ''; paintPins(); } };
294
+ ink.addEventListener('contextrestored', pin.restore);
295
+ pinSurfaces.push(pin);
143
296
  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));
297
+ 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);
298
+ marker.on('click', () => selectPlace(place));
146
299
  marker.getElement()?.setAttribute('aria-pressed', String(selectedRef.current === place.id));
147
300
  return { place, marker };
148
301
  });
@@ -156,31 +309,35 @@ export function JourneyMap({ places, routes = noRoutes, geography, landCover, wa
156
309
  }
157
310
  const scheme = matchMedia('(prefers-color-scheme: dark)');
158
311
  scheme.addEventListener('change', schedule);
312
+ let resolution;
313
+ const watchResolution = () => {
314
+ resolution?.removeEventListener('change', resolutionChanged);
315
+ resolution = matchMedia(`(resolution: ${window.devicePixelRatio || 1}dppx)`);
316
+ resolution.addEventListener('change', resolutionChanged);
317
+ };
318
+ const resolutionChanged = () => { watchResolution(); schedule(); };
319
+ watchResolution();
159
320
  fit();
160
321
  schedule();
161
322
  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 = () => { }; };
323
+ 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 = []; paintRef.current = () => { }; viewRef.current = () => { }; zoomRef.current = () => { }; focusRef.current = () => { }; };
163
324
  }).catch(() => { if (!cancelled) {
164
325
  setFailed(true);
165
326
  cleanup?.();
166
327
  cleanup = undefined;
167
328
  } });
168
329
  return () => { cancelled = true; cleanup?.(); };
169
- }, [places, routes, geography, landCover, water]);
330
+ }, [places, routes, geography, landCover, water, maxFitZoom, background]);
170
331
  useEffect(() => {
171
332
  markersRef.current.forEach(({ place, marker }) => marker.getElement()?.setAttribute('aria-pressed', String(selectedPlace === place.id)));
172
333
  paintRef.current();
334
+ const changed = previousSelection.current.place !== selectedPlace || previousSelection.current.scale !== focusZoomScale;
335
+ previousSelection.current = { place: selectedPlace, scale: focusZoomScale };
173
336
  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 });
180
- else
181
- map?.flyTo([place.latitude, place.longitude], targetZoom, { duration: .85 });
182
- }
183
- }, [selectedPlace, focusSelectedPlace, ready]);
337
+ if (focusSelectedPlace && changed && place)
338
+ focusRef.current(place);
339
+ }, [selectedPlace, focusSelectedPlace, focusZoomScale, ready]);
340
+ useEffect(() => { paintRef.current(); }, [renderingOptions.boundaryViewport, renderingOptions.terrainViewport, renderingOptions.reuseCanvas, renderingOptions.cacheBase]);
184
341
  useEffect(() => {
185
342
  const map = mapRef.current;
186
343
  if (!map || !ready)
@@ -196,5 +353,5 @@ export function JourneyMap({ places, routes = noRoutes, geography, landCover, wa
196
353
  else
197
354
  map.scrollWheelZoom.disable();
198
355
  }, [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)) })] });
356
+ 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: () => 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)) })] });
200
357
  }
@@ -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
+ }
@@ -0,0 +1,144 @@
1
+ export function journeyAnimationMs(animate, duration, reducedMotion) {
2
+ if (!animate || reducedMotion)
3
+ return 0;
4
+ return Number.isFinite(duration) ? Math.max(0, Math.min(duration, 5000)) : 420;
5
+ }
6
+ export function journeyFocusZoom(overviewZoom, scale) {
7
+ const ratio = Number.isFinite(scale) && scale >= 1 ? Math.min(scale, 64) : 2;
8
+ return overviewZoom + Math.log2(ratio);
9
+ }
10
+ export function resolveJourneyMapRendering(rendering = 'auto') {
11
+ if (rendering === 'legacy')
12
+ return { boundaryViewport: false, terrainViewport: false, reuseCanvas: false, cacheBase: false };
13
+ const options = typeof rendering === 'object' ? rendering : {};
14
+ return {
15
+ boundaryViewport: options.boundaryViewport ?? true,
16
+ terrainViewport: options.terrainViewport ?? true,
17
+ reuseCanvas: options.reuseCanvas ?? true,
18
+ cacheBase: options.cacheBase ?? true,
19
+ };
20
+ }
21
+ const MAX_SIDE = 8192;
22
+ const MAX_LAYER_PIXELS = 4 * 1024 * 1024;
23
+ // One base bitmap per map; together with the visible bitmap, at most 6 Mi pixels.
24
+ const MAX_BASE_PIXELS = 3 * 1024 * 1024;
25
+ /** Non-overlapping newly exposed strips after moving an existing base bitmap. */
26
+ export function journeyPanRegions(width, height, dx, dy) {
27
+ if (Math.abs(dx) >= width || Math.abs(dy) >= height)
28
+ return [[0, 0, width, height]];
29
+ const regions = [];
30
+ if (dx)
31
+ regions.push([dx > 0 ? 0 : width + dx, 0, Math.abs(dx), height]);
32
+ if (dy)
33
+ regions.push([Math.max(0, dx), dy > 0 ? 0 : height + dy, width - Math.abs(dx), Math.abs(dy)]);
34
+ return regions;
35
+ }
36
+ /** Pure and SSR safe: browser DPR is supplied by the caller. */
37
+ export function journeyCanvasSize(width, height, requestedDpr) {
38
+ if (![width, height].every(value => Number.isFinite(value) && value > 0))
39
+ return;
40
+ const requested = Number.isFinite(requestedDpr) ? Math.max(1, Math.min(requestedDpr, 2)) : 1;
41
+ const dpr = Math.min(requested, MAX_SIDE / width, MAX_SIDE / height, Math.sqrt(MAX_LAYER_PIXELS / (width * height)));
42
+ if (dpr < 1)
43
+ return;
44
+ const pixelWidth = Math.round(width * dpr), pixelHeight = Math.round(height * dpr);
45
+ // Round-down adjustment prevents rounding the two sides over the pixel budget.
46
+ const boundedDpr = pixelWidth * pixelHeight > MAX_LAYER_PIXELS
47
+ ? Math.min(dpr, Math.floor(width * dpr) / width, Math.floor(height * dpr) / height) : dpr;
48
+ return { width, height, pixelWidth: Math.round(width * boundedDpr), pixelHeight: Math.round(height * boundedDpr), dpr: boundedDpr };
49
+ }
50
+ function releaseCanvas(canvas) {
51
+ canvas.width = 0;
52
+ canvas.height = 0;
53
+ }
54
+ export function prepareJourneyCanvas(canvas, size, reuse) {
55
+ const changed = canvas.width !== size.pixelWidth || canvas.height !== size.pixelHeight;
56
+ if (changed)
57
+ releaseCanvas(canvas); // Avoid an oversized intermediate width/height swap.
58
+ if (changed || !reuse) {
59
+ canvas.width = size.pixelWidth;
60
+ canvas.height = size.pixelHeight;
61
+ }
62
+ canvas.style.width = `${size.width}px`;
63
+ canvas.style.height = `${size.height}px`;
64
+ const context = canvas.getContext('2d');
65
+ if (!context || context.isContextLost?.())
66
+ return null;
67
+ // Never accumulate DPR transforms when the backing store is reused.
68
+ context.setTransform(1, 0, 0, 1, 0, 0);
69
+ context.globalAlpha = 1;
70
+ context.globalCompositeOperation = 'source-over';
71
+ context.clearRect(0, 0, size.pixelWidth, size.pixelHeight);
72
+ context.setTransform(size.dpr, 0, 0, size.dpr, 0, 0);
73
+ return context;
74
+ }
75
+ /** Per-map, memory-only, single-entry cache. No DOM or allocation until capture. */
76
+ export class JourneyBaseBitmap {
77
+ constructor() {
78
+ this.invalidate = () => { this.key = undefined; };
79
+ }
80
+ restore(context, key, offset = [0, 0]) {
81
+ if (key !== this.key || !this.canvas)
82
+ return false;
83
+ const cachedContext = this.canvas.getContext('2d');
84
+ if (!cachedContext || cachedContext.isContextLost?.()) {
85
+ this.clear();
86
+ return false;
87
+ }
88
+ try {
89
+ context.save();
90
+ context.setTransform(1, 0, 0, 1, 0, 0);
91
+ context.drawImage(this.canvas, offset[0], offset[1]);
92
+ return true;
93
+ }
94
+ catch {
95
+ this.clear();
96
+ return false;
97
+ }
98
+ finally {
99
+ context.restore();
100
+ }
101
+ }
102
+ capture(source, size, key) {
103
+ if (size.pixelWidth * size.pixelHeight > MAX_BASE_PIXELS) {
104
+ this.clear();
105
+ return;
106
+ }
107
+ try {
108
+ const canvas = this.canvas ?? document.createElement('canvas');
109
+ if (!this.canvas) {
110
+ canvas.addEventListener('contextlost', this.invalidate);
111
+ canvas.addEventListener('contextrestored', this.invalidate);
112
+ this.canvas = canvas;
113
+ }
114
+ this.key = undefined;
115
+ if (canvas.width !== size.pixelWidth || canvas.height !== size.pixelHeight) {
116
+ releaseCanvas(canvas);
117
+ canvas.width = size.pixelWidth;
118
+ canvas.height = size.pixelHeight;
119
+ }
120
+ const context = canvas.getContext('2d');
121
+ if (!context || context.isContextLost?.()) {
122
+ this.clear();
123
+ return;
124
+ }
125
+ context.setTransform(1, 0, 0, 1, 0, 0);
126
+ context.clearRect(0, 0, size.pixelWidth, size.pixelHeight);
127
+ context.drawImage(source, 0, 0);
128
+ this.key = key;
129
+ }
130
+ catch {
131
+ // Allocation/copy failures affect optimization, never the already drawn map.
132
+ this.clear();
133
+ }
134
+ }
135
+ clear() {
136
+ this.key = undefined;
137
+ if (!this.canvas)
138
+ return;
139
+ this.canvas.removeEventListener('contextlost', this.invalidate);
140
+ this.canvas.removeEventListener('contextrestored', this.invalidate);
141
+ releaseCanvas(this.canvas);
142
+ this.canvas = undefined;
143
+ }
144
+ }
@@ -3,6 +3,9 @@ import { type PencilPoint } from '../internal/stroke.js';
3
3
  type Project = (position: Position) => PencilPoint;
4
4
  export declare function paintSea(ctx: CanvasRenderingContext2D, width: number, height: number, anchor: PencilPoint): void;
5
5
  export declare function mapFeatures(data: GeoJsonObject): Feature[];
6
- export declare function paintTerrain(ctx: CanvasRenderingContext2D, data: GeoJsonObject, project: Project, width: number, height: number, dark: boolean, defaultKind?: string): void;
6
+ /** Conservative x bounds of polygon edges inside a horizontal strip.
7
+ * Hole rings may widen the bounds, but never remove visible fill. */
8
+ export declare function terrainRowBounds(rings: readonly (readonly PencilPoint[])[], top: number, bottom: number): readonly [number, number] | undefined;
9
+ export declare function paintTerrain(ctx: CanvasRenderingContext2D, data: GeoJsonObject, project: Project, width: number, height: number, dark: boolean, defaultKind?: string, terrainViewport?: boolean): void;
7
10
  export declare function paintStop(ctx: CanvasRenderingContext2D, x: number, y: number, seed: number, color: string, paper: string, selected: boolean): void;
8
11
  export {};
@@ -45,7 +45,31 @@ export function mapFeatures(data) {
45
45
  return [data];
46
46
  return [{ type: 'Feature', properties: {}, geometry: data }];
47
47
  }
48
- export function paintTerrain(ctx, data, project, width, height, dark, defaultKind = 'land') {
48
+ /** Conservative x bounds of polygon edges inside a horizontal strip.
49
+ * Hole rings may widen the bounds, but never remove visible fill. */
50
+ export function terrainRowBounds(rings, top, bottom) {
51
+ let left = Infinity, right = -Infinity;
52
+ for (const ring of rings)
53
+ for (let i = 0; i < ring.length; i++) {
54
+ const [ax, ay] = ring[i], [bx, by] = ring[(i + 1) % ring.length];
55
+ if ((ay < top && by < top) || (ay > bottom && by > bottom))
56
+ continue;
57
+ if (!Number.isFinite(ax) || !Number.isFinite(ay) || !Number.isFinite(bx) || !Number.isFinite(by))
58
+ return [-Infinity, Infinity];
59
+ if (ay === by) {
60
+ left = Math.min(left, ax, bx);
61
+ right = Math.max(right, ax, bx);
62
+ continue;
63
+ }
64
+ const low = Math.max(0, Math.min(1, (top - ay) / (by - ay)));
65
+ const high = Math.max(0, Math.min(1, (bottom - ay) / (by - ay)));
66
+ const x1 = ax + (bx - ax) * low, x2 = ax + (bx - ax) * high;
67
+ left = Math.min(left, x1, x2);
68
+ right = Math.max(right, x1, x2);
69
+ }
70
+ return left <= right ? [left, right] : undefined;
71
+ }
72
+ export function paintTerrain(ctx, data, project, width, height, dark, defaultKind = 'land', terrainViewport = false) {
49
73
  for (const [featureIndex, feature] of mapFeatures(data).entries()) {
50
74
  const geometry = feature.geometry;
51
75
  if (!geometry)
@@ -87,7 +111,15 @@ export function paintTerrain(ctx, data, project, width, height, dark, defaultKin
87
111
  const spacing = kind === 'forest' ? 9 : 13;
88
112
  const seed = 21000 + featureIndex * 43 + polygonIndex;
89
113
  for (let row = Math.floor((top - origin[1]) / spacing); row <= Math.ceil((bottom - origin[1]) / spacing); row++) {
90
- for (let col = Math.floor((left - origin[0]) / 36); col <= Math.ceil((right - origin[0]) / 36); col++) {
114
+ // Jitter, the 44px hatch, category glyphs and pencil bleed all fit this
115
+ // envelope. Keep the original world-grid indices and per-cell seeds.
116
+ const rowY = origin[1] + row * spacing;
117
+ const band = terrainViewport ? terrainRowBounds(rings, rowY - 25, rowY + 20) : [left, right];
118
+ if (!band)
119
+ continue;
120
+ const first = Math.max(Math.floor((left - origin[0]) / 36), terrainViewport ? Math.ceil((band[0] - origin[0] - 68) / 36) : -Infinity);
121
+ const last = Math.min(Math.ceil((right - origin[0]) / 36), terrainViewport ? Math.floor((band[1] - origin[0] + 17) / 36) : Infinity);
122
+ for (let col = first; col <= last; col++) {
91
123
  const cell = (seed ^ Math.imul(col, 73856093) ^ Math.imul(row, 19349663)) >>> 0;
92
124
  const rng = seededRandom(cell);
93
125
  if (rng() < .2)
@@ -1,9 +1,11 @@
1
1
  .inkra-journey-map { min-width: 0; width: 100%; color: var(--inkra-ink); }
2
2
  .inkra-journey-map__viewport { position: relative; overflow: hidden; isolation: isolate; background: var(--inkra-paper); }
3
3
  .inkra-journey-map__host { position: absolute; inset: 0; background: transparent; font: inherit; }
4
- .inkra-journey-map__ink { position: absolute; inset: 0; pointer-events: none; z-index: 350; }
4
+ .inkra-journey-map__ink { position: absolute; top: 0; left: 0; pointer-events: none; z-index: 350; }
5
5
  .inkra-journey-map__tools { position: absolute; z-index: 600; inset-block-end: 9px; inset-inline-start: 9px; display: grid; gap: 4px; }
6
6
  .inkra-journey-map__marker { display: grid; place-items: center; }
7
+ .inkra-journey-map__symbol, .inkra-journey-map__stop { display: block; width: 44px; height: 44px; }
8
+ .inkra-journey-map__stop { pointer-events: none; }
7
9
  .inkra-journey-map__pin { position: absolute; top: 37px; left: 50%; transform: translateX(-50%); width: max-content; max-width: 140px; text-align: center; color: var(--inkra-ink); font: 14px/1.4 var(--inkra-font-hand); }
8
10
  .inkra-journey-map__marker[aria-pressed="true"] .inkra-journey-map__pin { color: var(--inkra-accent-strong); }
9
11
  .inkra-journey-map__orientation { position: absolute; bottom: 18px; right: 18px; z-index: 600; display: flex; align-items: end; gap: 12px; pointer-events: none; }
@@ -1,4 +1,5 @@
1
1
  .inkra-markdown {
2
+ inline-size: 100%;
2
3
  color: var(--inkra-ink);
3
4
  font-family: var(--inkra-font-body);
4
5
  font-size: var(--inkra-text-md);
@@ -203,6 +204,7 @@
203
204
  .inkra-markdown__code-text > .inkra-pencil-text__source { font-family: inherit; }
204
205
 
205
206
  .inkra-markdown__code-frame {
207
+ inline-size: 100%;
206
208
  margin-block: 0 var(--inkra-space-4);
207
209
  max-width: 100%;
208
210
  min-width: 0;
package/dist/styles.css CHANGED
@@ -8781,6 +8781,7 @@ div.inkra-choice-grid > .inkra-radio-group {
8781
8781
  }
8782
8782
 
8783
8783
  .inkra-markdown {
8784
+ inline-size: 100%;
8784
8785
  color: var(--inkra-ink);
8785
8786
  font-family: var(--inkra-font-body);
8786
8787
  font-size: var(--inkra-text-md);
@@ -8985,6 +8986,7 @@ div.inkra-choice-grid > .inkra-radio-group {
8985
8986
  .inkra-markdown__code-text > .inkra-pencil-text__source { font-family: inherit; }
8986
8987
 
8987
8988
  .inkra-markdown__code-frame {
8989
+ inline-size: 100%;
8988
8990
  margin-block: 0 var(--inkra-space-4);
8989
8991
  max-width: 100%;
8990
8992
  min-width: 0;
@@ -10018,9 +10020,11 @@ svg.leaflet-image-layer.leaflet-interactive path {
10018
10020
  .inkra-journey-map { min-width: 0; width: 100%; color: var(--inkra-ink); }
10019
10021
  .inkra-journey-map__viewport { position: relative; overflow: hidden; isolation: isolate; background: var(--inkra-paper); }
10020
10022
  .inkra-journey-map__host { position: absolute; inset: 0; background: transparent; font: inherit; }
10021
- .inkra-journey-map__ink { position: absolute; inset: 0; pointer-events: none; z-index: 350; }
10023
+ .inkra-journey-map__ink { position: absolute; top: 0; left: 0; pointer-events: none; z-index: 350; }
10022
10024
  .inkra-journey-map__tools { position: absolute; z-index: 600; inset-block-end: 9px; inset-inline-start: 9px; display: grid; gap: 4px; }
10023
10025
  .inkra-journey-map__marker { display: grid; place-items: center; }
10026
+ .inkra-journey-map__symbol, .inkra-journey-map__stop { display: block; width: 44px; height: 44px; }
10027
+ .inkra-journey-map__stop { pointer-events: none; }
10024
10028
  .inkra-journey-map__pin { position: absolute; top: 37px; left: 50%; transform: translateX(-50%); width: max-content; max-width: 140px; text-align: center; color: var(--inkra-ink); font: 14px/1.4 var(--inkra-font-hand); }
10025
10029
  .inkra-journey-map__marker[aria-pressed="true"] .inkra-journey-map__pin { color: var(--inkra-accent-strong); }
10026
10030
  .inkra-journey-map__orientation { position: absolute; bottom: 18px; right: 18px; z-index: 600; display: flex; align-items: end; gap: 12px; pointer-events: none; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inkra/react",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Hand-drawn React controls with native semantics and deterministic decoration.",
5
5
  "type": "module",
6
6
  "license": "MIT",