@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.
- package/dist/journey-map/index.d.ts +16 -1
- package/dist/journey-map/index.js +211 -54
- 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 +34 -2
- package/dist/styles/journey-map.css +3 -1
- package/dist/styles/markdown.css +2 -0
- package/dist/styles.css +5 -1
- package/package.json +1 -1
|
@@ -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 =
|
|
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
|
-
|
|
77
|
-
|
|
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
|
|
90
|
-
if (!
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
canvas
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
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(${
|
|
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 = () => {
|
|
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:
|
|
145
|
-
marker.on('click', () =>
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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: () =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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; }
|
package/dist/styles/markdown.css
CHANGED
|
@@ -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;
|
|
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; }
|