@lalalic/markcut 3.1.0 → 3.2.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/package.json +5 -1
- package/skills/markcut/SKILL.md +14 -17
- package/skills/markcut/docs/map-dynamic-camera.md +92 -8
- package/skills/markcut/docs/markdown-descriptive.md +1 -1
- package/skills/markcut/review.md +480 -0
- package/src/descriptive/compiler.ts +60 -1
- package/src/descriptive/dsl.ts +27 -7
- package/src/descriptive/markdown.ts +2 -1
- package/src/descriptive/resolve.test.ts +98 -0
- package/src/descriptive/resolve.ts +156 -12
- package/src/player/bundle/player.js +222961 -222169
- package/src/player/pipeline.mjs +255 -17
- package/src/schema/index.ts +4 -0
- package/src/types/Effect.tsx +12 -1
- package/src/types/Map.tsx +649 -75
- package/src/utils/directions.ts +101 -0
- package/src/utils/index.ts +11 -0
- package/src/utils/route-legs.ts +199 -0
- package/tests/dsl.test.ts +35 -0
- package/tests/evals/README.md +41 -0
- package/tests/evals/dataset.json +170 -0
- package/tests/evals/gen_dataset.py +63 -0
- package/tests/evals/metrics.py +70 -0
- package/tests/evals/openrouter_model.py +143 -0
- package/tests/evals/storyboard_app.py +55 -0
- package/tests/evals/test_storyboard.py +32 -0
- package/tests/fixtures/map-overlay.json +56 -0
- package/tests/fixtures/md/map-all-views.md +8 -1
- package/tests/fixtures/md/map-children.md +11 -0
- package/tests/fixtures/md/map-multimode.md +9 -0
- package/tests/fixtures/streetview-walk.json +36 -0
- package/tests/md-descriptive.test.ts +75 -0
- package/tests/render.test.ts +92 -0
- package/tests/route-legs.test.ts +178 -0
- package/tests/schema.test.ts +18 -0
- package/.vscode/settings.json +0 -3
package/src/types/Map.tsx
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* }
|
|
30
30
|
*/
|
|
31
31
|
import React from "react";
|
|
32
|
-
import { Sequence, useCurrentFrame, useVideoConfig, delayRender, continueRender } from "remotion";
|
|
32
|
+
import { Sequence, AbsoluteFill, useCurrentFrame, useVideoConfig, delayRender, continueRender } from "remotion";
|
|
33
33
|
import { useFrameEvents } from "../context/index";
|
|
34
34
|
import {
|
|
35
35
|
APIProvider, Map as GoogleMap, useMap, useMapsLibrary, useMap3D,
|
|
@@ -37,7 +37,15 @@ import {
|
|
|
37
37
|
} from "@vis.gl/react-google-maps";
|
|
38
38
|
import type { Map3DRef } from "@vis.gl/react-google-maps";
|
|
39
39
|
import { resolveTween } from "../utils/tween";
|
|
40
|
-
import
|
|
40
|
+
import {
|
|
41
|
+
makeSyntheticLeg,
|
|
42
|
+
isSyntheticMode,
|
|
43
|
+
routePositionAtLegs,
|
|
44
|
+
modeEmoji,
|
|
45
|
+
type RouteLeg,
|
|
46
|
+
type RouteStopWindow,
|
|
47
|
+
} from "../utils/route-legs";
|
|
48
|
+
import type { MapStream, Stream } from "../schema/index";
|
|
41
49
|
|
|
42
50
|
// API key is injected by the compiler onto the stream node (see compileLeaf in compiler.ts).
|
|
43
51
|
// This fallback handles the case where Map.tsx is used directly without the compiler.
|
|
@@ -60,10 +68,245 @@ function resolveMapLocale(language?: string, region?: string): { language?: stri
|
|
|
60
68
|
return { language, region };
|
|
61
69
|
}
|
|
62
70
|
|
|
71
|
+
// ============================================================
|
|
72
|
+
// MapRefContext — shares the live google.maps.Map instance
|
|
73
|
+
// between the view renderer and the overlay layer so anchored
|
|
74
|
+
// children can project lat/lng → screen pixel each frame.
|
|
75
|
+
// ============================================================
|
|
76
|
+
const MapRefContext = React.createContext<{
|
|
77
|
+
map: google.maps.Map | null;
|
|
78
|
+
setMap: (m: google.maps.Map | null) => void;
|
|
79
|
+
}>({ map: null, setMap: () => {} });
|
|
80
|
+
|
|
81
|
+
/** Invisible bridge rendered inside <GoogleMap> to capture the map instance. */
|
|
82
|
+
function MapBridge() {
|
|
83
|
+
const map = useMap();
|
|
84
|
+
const { setMap } = React.useContext(MapRefContext);
|
|
85
|
+
React.useEffect(() => {
|
|
86
|
+
setMap(map);
|
|
87
|
+
return () => setMap(null);
|
|
88
|
+
}, [map, setMap]);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ============================================================
|
|
93
|
+
// Pure Mercator projection — converts a lat/lng to the map
|
|
94
|
+
// container's screen pixel, given the live center/zoom/size.
|
|
95
|
+
//
|
|
96
|
+
// `map.getProjection()` returns null until the map finishes
|
|
97
|
+
// initializing, which never happens reliably during Remotion's
|
|
98
|
+
// per-frame renders. This replicates `fromLatLngToPoint` with
|
|
99
|
+
// the same 256px-at-zoom-0 world coordinates, so anchored
|
|
100
|
+
// overlays align with the map deterministically every frame.
|
|
101
|
+
// ============================================================
|
|
102
|
+
const PROJECTION_TILE = 256;
|
|
103
|
+
function worldPoint(lat: number, lng: number): { x: number; y: number } {
|
|
104
|
+
const sin = Math.sin((lat * Math.PI) / 180);
|
|
105
|
+
return {
|
|
106
|
+
x: PROJECTION_TILE * (0.5 + lng / 360),
|
|
107
|
+
y: PROJECTION_TILE * (0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Screen pixel of a lat/lng inside the map container (Mercator, no projection API). */
|
|
112
|
+
function latLngToScreen(
|
|
113
|
+
map: google.maps.Map,
|
|
114
|
+
lat: number,
|
|
115
|
+
lng: number,
|
|
116
|
+
): { x: number; y: number } | null {
|
|
117
|
+
const center = map.getCenter();
|
|
118
|
+
if (!center) return null;
|
|
119
|
+
const zoom = map.getZoom() ?? 10;
|
|
120
|
+
const container = map.getDiv();
|
|
121
|
+
const w = container.offsetWidth;
|
|
122
|
+
const h = container.offsetHeight;
|
|
123
|
+
if (!w || !h) return null;
|
|
124
|
+
const scale = Math.pow(2, zoom);
|
|
125
|
+
const wp = worldPoint(lat, lng);
|
|
126
|
+
const cp = worldPoint(center.lat(), center.lng());
|
|
127
|
+
return {
|
|
128
|
+
x: w / 2 + (wp.x - cp.x) * scale,
|
|
129
|
+
y: h / 2 + (wp.y - cp.y) * scale,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Default size of an anchored overlay box — a fraction of the map's smaller
|
|
135
|
+
* dimension. Children render at 100%×100% of this box (`objectFit` keeps the
|
|
136
|
+
* media proportional), so without explicit dimensions the wrapper would
|
|
137
|
+
* collapse to 0×0 and nothing would draw.
|
|
138
|
+
*/
|
|
139
|
+
function anchoredOverlaySize(map: google.maps.Map): number {
|
|
140
|
+
const d = map.getDiv();
|
|
141
|
+
const w = d.offsetWidth || 640;
|
|
142
|
+
const h = d.offsetHeight || 480;
|
|
143
|
+
return Math.round(Math.min(w, h) * 0.5);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ============================================================
|
|
147
|
+
// MapOverlays — renders the map's children as overlay layers.
|
|
148
|
+
// Children with `at:"Label"` are positioned at that waypoint's
|
|
149
|
+
// screen pixel (projected from the live map each frame).
|
|
150
|
+
// ============================================================
|
|
151
|
+
const LeafRenderers: Record<string, React.ComponentType<any>> = {};
|
|
152
|
+
async function ensureLeafRenderers() {
|
|
153
|
+
if (Object.keys(LeafRenderers).length) return;
|
|
154
|
+
const [v, a, i, c, e, f] = await Promise.all([
|
|
155
|
+
import("./Video"), import("./Audio"), import("./Image"), import("./Component"),
|
|
156
|
+
import("./Effect"), import("./Folder"),
|
|
157
|
+
]);
|
|
158
|
+
LeafRenderers.video = v.VideoLeaf;
|
|
159
|
+
LeafRenderers.audio = a.AudioLeaf;
|
|
160
|
+
LeafRenderers.image = i.ImageLeaf;
|
|
161
|
+
LeafRenderers.component = c.ComponentLeaf;
|
|
162
|
+
LeafRenderers.effect = e.EffectWrapper;
|
|
163
|
+
LeafRenderers.folder = f.FolderLeaf;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function MapOverlays({
|
|
167
|
+
children, waypoints,
|
|
168
|
+
}: {
|
|
169
|
+
children: Stream[];
|
|
170
|
+
waypoints: { lat: number; lng: number; label?: string }[];
|
|
171
|
+
}) {
|
|
172
|
+
const { fps } = useVideoConfig();
|
|
173
|
+
const frame = useCurrentFrame();
|
|
174
|
+
const { map } = React.useContext(MapRefContext);
|
|
175
|
+
const [renderersReady, setRenderersReady] = React.useState(Object.keys(LeafRenderers).length > 0);
|
|
176
|
+
|
|
177
|
+
React.useEffect(() => {
|
|
178
|
+
ensureLeafRenderers().then(() => setRenderersReady(true));
|
|
179
|
+
}, []);
|
|
180
|
+
|
|
181
|
+
if (!renderersReady || !children || children.length === 0) return null;
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<AbsoluteFill style={{ pointerEvents: "none" }}>
|
|
185
|
+
{children.map((child, i) => {
|
|
186
|
+
const childStart = child.start ?? 0;
|
|
187
|
+
const childEnd = child.end ?? childStart + (child.duration ?? 1);
|
|
188
|
+
const durFrames = Math.max(1, Math.floor(fps * (childEnd - childStart)));
|
|
189
|
+
const fromFrame = Math.floor(fps * childStart);
|
|
190
|
+
|
|
191
|
+
// Look up waypoint lat/lng for anchored children.
|
|
192
|
+
const anchorLabel = (child as any).at as string | undefined;
|
|
193
|
+
const wp = anchorLabel
|
|
194
|
+
? waypoints.find((w) => w.label === anchorLabel)
|
|
195
|
+
: undefined;
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<Sequence key={i} durationInFrames={durFrames} from={fromFrame} layout="none">
|
|
199
|
+
<MapChildPositioner map={map} anchorLatLng={wp ? { lat: wp.lat, lng: wp.lng } : undefined} frame={frame} fps={fps}>
|
|
200
|
+
<MapChildRender child={child} />
|
|
201
|
+
</MapChildPositioner>
|
|
202
|
+
</Sequence>
|
|
203
|
+
);
|
|
204
|
+
})}
|
|
205
|
+
</AbsoluteFill>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Positions a child overlay: full-screen or anchored to a lat/lng on the map. */
|
|
210
|
+
function MapChildPositioner({
|
|
211
|
+
map, anchorLatLng, frame, fps, children,
|
|
212
|
+
}: {
|
|
213
|
+
map: google.maps.Map | null;
|
|
214
|
+
anchorLatLng?: { lat: number; lng: number };
|
|
215
|
+
frame: number;
|
|
216
|
+
fps: number;
|
|
217
|
+
children: React.ReactNode;
|
|
218
|
+
}) {
|
|
219
|
+
// Unanchored: full-screen overlay
|
|
220
|
+
if (!anchorLatLng) {
|
|
221
|
+
return <AbsoluteFill>{children}</AbsoluteFill>;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Anchored: project lat/lng → container pixel each frame. Uses pure
|
|
225
|
+
// Mercator math (not map.getProjection(), which is null until the map
|
|
226
|
+
// initializes and never reliably during Remotion frame renders).
|
|
227
|
+
const overlay = React.useMemo(() => {
|
|
228
|
+
if (!map) return null;
|
|
229
|
+
return latLngToScreen(map, anchorLatLng.lat, anchorLatLng.lng);
|
|
230
|
+
}, [map, anchorLatLng?.lat, anchorLatLng?.lng, frame]);
|
|
231
|
+
|
|
232
|
+
if (!overlay) {
|
|
233
|
+
return <AbsoluteFill style={{ display: "none" }}>{children}</AbsoluteFill>;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const box = map ? anchoredOverlaySize(map) : 0;
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<div style={{
|
|
240
|
+
position: "absolute",
|
|
241
|
+
left: overlay.x,
|
|
242
|
+
top: overlay.y,
|
|
243
|
+
width: box,
|
|
244
|
+
height: box,
|
|
245
|
+
transform: "translate(-50%, -50%)",
|
|
246
|
+
}}>
|
|
247
|
+
{children}
|
|
248
|
+
</div>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Renders a single compiled child stream inside a map overlay. */
|
|
253
|
+
function MapChildRender({ child }: { child: Stream }): React.ReactElement | null {
|
|
254
|
+
const type = child.type;
|
|
255
|
+
|
|
256
|
+
// Effect: render its children with the animation applied. Uses `contained`
|
|
257
|
+
// so the animated box fills the anchored overlay box (not the whole canvas),
|
|
258
|
+
// and preloaded renderers (no React.lazy — lazy chunks never resolve before
|
|
259
|
+
// Remotion captures the frame).
|
|
260
|
+
if (type === "effect") {
|
|
261
|
+
const EW = LeafRenderers.effect as React.ComponentType<{ stream: any; contained?: boolean; children?: React.ReactNode }>;
|
|
262
|
+
if (!EW) return null;
|
|
263
|
+
const effectChildren = ((child as any).children ?? []) as Stream[];
|
|
264
|
+
return React.createElement(
|
|
265
|
+
EW,
|
|
266
|
+
{ stream: child, contained: true },
|
|
267
|
+
...effectChildren.map((c, i) =>
|
|
268
|
+
React.createElement(MapChildRender, { key: i, child: c }),
|
|
269
|
+
),
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (type === "folder") {
|
|
274
|
+
const FolderLeaf = LeafRenderers.folder;
|
|
275
|
+
if (!FolderLeaf) return null;
|
|
276
|
+
return React.createElement(FolderLeaf, { stream: child });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const Renderer = LeafRenderers[type];
|
|
280
|
+
if (!Renderer) return null;
|
|
281
|
+
return React.createElement(Renderer, { stream: child });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ============================================================
|
|
285
|
+
// Hide Google Maps attribution / UI text (Google logo, "Keyboard
|
|
286
|
+
// shortcuts", "Terms", "Report a map error", "Map data ©...") from the
|
|
287
|
+
// rendered output. Both the 2D map and Street View share the `.gm-style`
|
|
288
|
+
// DOM, so two rules cover every view. Injected once, globally.
|
|
289
|
+
// ============================================================
|
|
290
|
+
let googleMapsUiHidden = false;
|
|
291
|
+
function hideGoogleMapsUi(): void {
|
|
292
|
+
if (googleMapsUiHidden || typeof document === "undefined") return;
|
|
293
|
+
googleMapsUiHidden = true;
|
|
294
|
+
const style = document.createElement("style");
|
|
295
|
+
style.textContent = [
|
|
296
|
+
// Attribution / copyright blocks: "Map data ©Google", "Keyboard shortcuts",
|
|
297
|
+
// "Terms", "Report a map error" / "Report a problem".
|
|
298
|
+
".gm-style-cc { display: none !important; }",
|
|
299
|
+
// Google logo link ("Google" in the bottom-left).
|
|
300
|
+
'.gm-style a[href*="maps.google.com/maps"] { display: none !important; }',
|
|
301
|
+
].join("\n");
|
|
302
|
+
document.head.appendChild(style);
|
|
303
|
+
}
|
|
304
|
+
|
|
63
305
|
// ============================================================
|
|
64
306
|
// MapLeaf — entry point, dispatches to the view renderer
|
|
65
307
|
// ============================================================
|
|
66
308
|
export function MapLeaf({ stream }: { stream: MapStream }) {
|
|
309
|
+
const [mapInstance, setMapInstance] = React.useState<google.maps.Map | null>(null);
|
|
67
310
|
const { fps } = useVideoConfig();
|
|
68
311
|
const waypoints = stream.waypoints ?? [];
|
|
69
312
|
const start = stream.start ?? 0;
|
|
@@ -84,6 +327,9 @@ export function MapLeaf({ stream }: { stream: MapStream }) {
|
|
|
84
327
|
const mapLoadContinuedRef = React.useRef(false);
|
|
85
328
|
|
|
86
329
|
React.useEffect(() => {
|
|
330
|
+
// Strip Google Maps attribution / UI text from the rendered video.
|
|
331
|
+
hideGoogleMapsUi();
|
|
332
|
+
|
|
87
333
|
mapLoadHandleRef.current = delayRender("Waiting for map to load...");
|
|
88
334
|
mapLoadContinuedRef.current = false;
|
|
89
335
|
|
|
@@ -130,12 +376,15 @@ export function MapLeaf({ stream }: { stream: MapStream }) {
|
|
|
130
376
|
language={mapLocale.language}
|
|
131
377
|
region={mapLocale.region}
|
|
132
378
|
>
|
|
133
|
-
{
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
379
|
+
<MapRefContext.Provider value={{ map: mapInstance, setMap: setMapInstance }}>
|
|
380
|
+
{view === "overview" && <OverviewMap stream={stream} onTilesLoaded={handleMapReady} />}
|
|
381
|
+
{view === "cinematic" && (use3d
|
|
382
|
+
? <CinematicMap3D stream={stream} onReady={handleMapReady} />
|
|
383
|
+
: <CinematicMap stream={stream} onTilesLoaded={handleMapReady} />)}
|
|
384
|
+
{view === "streetview" && <StreetViewLeaf stream={stream} onPanoReady={handleMapReady} />}
|
|
385
|
+
{view === "route" && <RouteMap stream={stream} onTilesLoaded={handleMapReady} />}
|
|
386
|
+
<MapOverlays children={(stream.children as Stream[]) ?? []} waypoints={stream.waypoints} />
|
|
387
|
+
</MapRefContext.Provider>
|
|
139
388
|
</APIProvider>
|
|
140
389
|
</Sequence>
|
|
141
390
|
);
|
|
@@ -171,7 +420,9 @@ function OverviewMap({
|
|
|
171
420
|
zoomControl={false}
|
|
172
421
|
onTilesLoaded={onTilesLoaded}
|
|
173
422
|
style={{ width: "100%", height: "100%", position: "absolute" }}
|
|
174
|
-
|
|
423
|
+
>
|
|
424
|
+
<MapBridge />
|
|
425
|
+
</GoogleMap>
|
|
175
426
|
);
|
|
176
427
|
}
|
|
177
428
|
|
|
@@ -203,11 +454,15 @@ function RouteMap({
|
|
|
203
454
|
onTilesLoaded={onTilesLoaded}
|
|
204
455
|
style={{ width: "100%", height: "100%", position: "absolute" }}
|
|
205
456
|
>
|
|
206
|
-
<
|
|
457
|
+
<MapBridge />
|
|
458
|
+
<RouteWithMarkerLegs
|
|
207
459
|
waypoints={waypoints}
|
|
208
460
|
travelMode={stream.travelMode ?? "DRIVING"}
|
|
209
461
|
markerEmoji={stream.routeMarker ?? "🚗"}
|
|
210
462
|
actionDuration={end - start}
|
|
463
|
+
routeColor={stream.routeColor ?? "#4285F4"}
|
|
464
|
+
routeWeight={stream.routeWeight ?? 4}
|
|
465
|
+
children={stream.children as Stream[]}
|
|
211
466
|
/>
|
|
212
467
|
</GoogleMap>
|
|
213
468
|
);
|
|
@@ -368,24 +623,125 @@ function StreetViewLeaf({
|
|
|
368
623
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
369
624
|
const svLibrary = useMapsLibrary("streetView");
|
|
370
625
|
const [pano, setPano] = React.useState<google.maps.StreetViewPanorama | null>(null);
|
|
626
|
+
const panoRef = React.useRef<google.maps.StreetViewPanorama | null>(null);
|
|
627
|
+
// Tracks whether the current panorama's imagery has reached StreetViewStatus.OK.
|
|
628
|
+
const loadedRef = React.useRef(false);
|
|
629
|
+
// Id of the last panorama that reached StreetViewStatus.OK — used to hold a
|
|
630
|
+
// loaded view when a walk waypoint has no imagery (instead of flashing black).
|
|
631
|
+
const lastGoodPanoRef = React.useRef<string | null>(null);
|
|
632
|
+
// Index of the waypoint the walk's panorama is currently parked at (snap walk).
|
|
633
|
+
const lastWpIndexRef = React.useRef(-1);
|
|
634
|
+
|
|
635
|
+
// ── Persistent pano-load gate ────────────────────────────────────────────
|
|
636
|
+
// Unlike a per-frame delayRender, this wait SURVIVES per-frame effect
|
|
637
|
+
// re-runs. In the Player, frames advance faster than a pano loads, and a
|
|
638
|
+
// per-frame delayRender is continued by the next frame's cleanup before the
|
|
639
|
+
// imagery arrives — capturing black frames when a scene is entered by
|
|
640
|
+
// playback. The wait completes only when the pano actually reaches OK.
|
|
641
|
+
const waitHandleRef = React.useRef<number | null>(null);
|
|
642
|
+
const waitPollRef = React.useRef<ReturnType<typeof setInterval> | null>(null);
|
|
643
|
+
const waitCapRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
644
|
+
|
|
371
645
|
const { fps } = useVideoConfig();
|
|
372
646
|
const frame = useCurrentFrame();
|
|
373
647
|
const start = stream.start ?? 0;
|
|
374
648
|
const end = stream.end ?? start + (stream.duration ?? 1);
|
|
375
649
|
const sv = stream.streetView;
|
|
650
|
+
const svRadius = typeof sv?.radius === "number" && sv.radius > 0 ? sv.radius : 50;
|
|
651
|
+
|
|
652
|
+
const finishWait = React.useCallback(() => {
|
|
653
|
+
if (waitHandleRef.current != null) {
|
|
654
|
+
continueRender(waitHandleRef.current);
|
|
655
|
+
waitHandleRef.current = null;
|
|
656
|
+
}
|
|
657
|
+
if (waitPollRef.current != null) {
|
|
658
|
+
clearInterval(waitPollRef.current);
|
|
659
|
+
waitPollRef.current = null;
|
|
660
|
+
}
|
|
661
|
+
if (waitCapRef.current != null) {
|
|
662
|
+
clearTimeout(waitCapRef.current);
|
|
663
|
+
waitCapRef.current = null;
|
|
664
|
+
}
|
|
665
|
+
}, []);
|
|
666
|
+
|
|
667
|
+
const statusOk = React.useCallback(() => {
|
|
668
|
+
const p = panoRef.current;
|
|
669
|
+
return !!p && typeof p.getStatus === "function" && p.getStatus() === google.maps.StreetViewStatus.OK;
|
|
670
|
+
}, []);
|
|
671
|
+
|
|
672
|
+
const startWait = React.useCallback(() => {
|
|
673
|
+
if (waitHandleRef.current != null) return; // already waiting
|
|
674
|
+
waitHandleRef.current = delayRender("Street View pano");
|
|
675
|
+
waitPollRef.current = setInterval(() => {
|
|
676
|
+
if (!statusOk()) return;
|
|
677
|
+
const p = panoRef.current;
|
|
678
|
+
const panoId = p && typeof p.getPano === "function" ? p.getPano() : null;
|
|
679
|
+
// Status OK = imagery metadata ready, but the TILES may not have painted
|
|
680
|
+
// yet. When a NEW panorama just became OK, wait a grace for tiles; if the
|
|
681
|
+
// same (already painted) panorama is OK, finish immediately.
|
|
682
|
+
const isNewPano = panoId !== lastGoodPanoRef.current;
|
|
683
|
+
lastGoodPanoRef.current = panoId;
|
|
684
|
+
loadedRef.current = true;
|
|
685
|
+
if (isNewPano) {
|
|
686
|
+
clearInterval(waitPollRef.current ?? undefined);
|
|
687
|
+
waitPollRef.current = null;
|
|
688
|
+
waitCapRef.current = setTimeout(() => {
|
|
689
|
+
finishWait();
|
|
690
|
+
onPanoReady();
|
|
691
|
+
}, 1200);
|
|
692
|
+
} else {
|
|
693
|
+
finishWait();
|
|
694
|
+
onPanoReady();
|
|
695
|
+
}
|
|
696
|
+
}, 200);
|
|
697
|
+
// Generous cap for slow or rate-limited loads (Google 429s under load).
|
|
698
|
+
// On cap without OK, hold the last loaded panorama instead of showing black.
|
|
699
|
+
waitCapRef.current = setTimeout(() => {
|
|
700
|
+
if (statusOk()) {
|
|
701
|
+
const p = panoRef.current;
|
|
702
|
+
lastGoodPanoRef.current = p && typeof p.getPano === "function" ? p.getPano() : null;
|
|
703
|
+
loadedRef.current = true;
|
|
704
|
+
finishWait();
|
|
705
|
+
onPanoReady();
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
const fallbackId = lastGoodPanoRef.current;
|
|
709
|
+
const p = panoRef.current;
|
|
710
|
+
if (fallbackId && p && typeof p.setPano === "function") {
|
|
711
|
+
p.setPano(fallbackId);
|
|
712
|
+
setTimeout(() => {
|
|
713
|
+
finishWait();
|
|
714
|
+
onPanoReady();
|
|
715
|
+
}, 400);
|
|
716
|
+
} else {
|
|
717
|
+
finishWait();
|
|
718
|
+
onPanoReady();
|
|
719
|
+
}
|
|
720
|
+
}, 8000);
|
|
721
|
+
}, [statusOk, finishWait, onPanoReady]);
|
|
722
|
+
|
|
723
|
+
// Unmount: always finish any pending wait so Remotion never hangs.
|
|
724
|
+
React.useEffect(() => finishWait, [finishWait]);
|
|
376
725
|
|
|
377
726
|
// Create the panorama once (no React wrapper component in this library version).
|
|
378
727
|
React.useEffect(() => {
|
|
379
728
|
if (!svLibrary || !containerRef.current) return;
|
|
729
|
+
loadedRef.current = false;
|
|
730
|
+
lastWpIndexRef.current = -1;
|
|
380
731
|
const pan = new svLibrary.StreetViewPanorama(containerRef.current, {
|
|
381
732
|
disableDefaultUI: true,
|
|
382
733
|
});
|
|
734
|
+
panoRef.current = pan;
|
|
735
|
+
// setPosition(latLng, radius?) — radius (m) constrains the panorama search.
|
|
736
|
+
// The public typings only expose the 1-arg overload, so cast the call.
|
|
737
|
+
const panSetPosition = (loc: google.maps.LatLngLiteral) =>
|
|
738
|
+
(pan.setPosition as unknown as (l: google.maps.LatLngLiteral, r: number) => void)(loc, svRadius);
|
|
383
739
|
if (sv?.pano) {
|
|
384
740
|
pan.setPano(sv.pano);
|
|
385
741
|
} else if (sv?.location) {
|
|
386
|
-
|
|
742
|
+
panSetPosition(sv.location);
|
|
387
743
|
} else if (sv?.route?.length) {
|
|
388
|
-
|
|
744
|
+
panSetPosition(sv.route[0]!);
|
|
389
745
|
}
|
|
390
746
|
if (sv?.pov) {
|
|
391
747
|
pan.setPov({
|
|
@@ -396,19 +752,27 @@ function StreetViewLeaf({
|
|
|
396
752
|
if (typeof sv?.zoom === "number") {
|
|
397
753
|
pan.setZoom(sv.zoom);
|
|
398
754
|
}
|
|
755
|
+
// Keep loadedRef fresh whenever the API reports OK (the persistent wait's
|
|
756
|
+
// poll owns lastGoodPanoRef so it can detect when a NEW pano just loaded).
|
|
757
|
+
const onStatus = () => {
|
|
758
|
+
if (typeof pan.getStatus === "function" && pan.getStatus() === google.maps.StreetViewStatus.OK) {
|
|
759
|
+
loadedRef.current = true;
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
pan.addListener("status_changed", onStatus);
|
|
399
763
|
setPano(pan);
|
|
764
|
+
// Gate the initial load (persistent — see startWait).
|
|
765
|
+
startWait();
|
|
400
766
|
return () => {
|
|
767
|
+
loadedRef.current = false;
|
|
768
|
+
panoRef.current = null;
|
|
401
769
|
pan.setVisible(false);
|
|
402
770
|
};
|
|
403
|
-
}, [svLibrary, stream.id, sv?.pano, sv?.location, sv?.route, sv?.pov, sv?.zoom]);
|
|
404
|
-
|
|
405
|
-
// Per-frame POV + walk
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
// signaling ready on the metadata event alone captures dark frames. Each frame
|
|
409
|
-
// change delays render until the panorama's imagery is actually loaded (with a
|
|
410
|
-
// grace period for tile fetch), so every captured frame has visible content.
|
|
411
|
-
// POV-only changes (no position change → no new pano) settle quickly.
|
|
771
|
+
}, [svLibrary, stream.id, sv?.pano, sv?.location, sv?.route, sv?.pov, sv?.zoom, svRadius, startWait]);
|
|
772
|
+
|
|
773
|
+
// Per-frame POV + snap-walk position. The panorama only MOVES when the walk
|
|
774
|
+
// crosses a waypoint boundary; a new pano load is gated by the persistent
|
|
775
|
+
// startWait (never a per-frame finish, so playback can't capture black).
|
|
412
776
|
React.useEffect(() => {
|
|
413
777
|
if (!pano) return;
|
|
414
778
|
const pov = pano.getPov();
|
|
@@ -418,45 +782,25 @@ function StreetViewLeaf({
|
|
|
418
782
|
const zoom = resolveTween(frame, fps, sv?.zoom, start, end, pano.getZoom() ?? 0);
|
|
419
783
|
pano.setZoom(zoom);
|
|
420
784
|
|
|
421
|
-
let movedPosition = false;
|
|
422
785
|
if (sv?.route && sv.route.length > 1) {
|
|
786
|
+
// Discrete "snap walk": hold at the nearest waypoint and jump between
|
|
787
|
+
// waypoints. Continuous per-frame interpolation requests a (slightly
|
|
788
|
+
// different) panorama every frame — hundreds of Google Street View API
|
|
789
|
+
// calls per render, which gets rate-limited (429) and renders black.
|
|
423
790
|
const t = Math.min(Math.max((frame / fps) / Math.max(0.1, end - start), 0), 1);
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
791
|
+
const wpIndex = Math.min(Math.floor(t * sv.route.length), sv.route.length - 1);
|
|
792
|
+
if (wpIndex !== lastWpIndexRef.current) {
|
|
793
|
+
lastWpIndexRef.current = wpIndex;
|
|
794
|
+
const pt = sv.route[wpIndex]!;
|
|
795
|
+
(pano.setPosition as unknown as (l: google.maps.LatLngLiteral, r: number) => void)(pt, svRadius);
|
|
796
|
+
// New waypoint → new panorama may be needed; gate until it loads.
|
|
797
|
+
startWait();
|
|
798
|
+
}
|
|
799
|
+
} else if (!loadedRef.current) {
|
|
800
|
+
// Static/POV-only scene: keep the initial load gated until ready.
|
|
801
|
+
startWait();
|
|
434
802
|
}
|
|
435
|
-
|
|
436
|
-
// Gate this frame on the panorama's imagery actually loading.
|
|
437
|
-
const handle = delayRender(`Street View frame ${frame}`);
|
|
438
|
-
let done = false;
|
|
439
|
-
const finish = () => {
|
|
440
|
-
if (done) return;
|
|
441
|
-
done = true;
|
|
442
|
-
continueRender(handle);
|
|
443
|
-
onPanoReady();
|
|
444
|
-
};
|
|
445
|
-
// pano_changed fires when a new panorama's metadata+imagery request resolves.
|
|
446
|
-
// Add a grace period so tiles finish rendering before capture.
|
|
447
|
-
const graceMs = movedPosition ? 1200 : 400;
|
|
448
|
-
const onPano = () => setTimeout(finish, graceMs);
|
|
449
|
-
const lPano = pano.addListener("pano_changed", onPano);
|
|
450
|
-
// POV-only frames (no position change) settle fast; if no pano change fires,
|
|
451
|
-
// the imagery is already present — settle after a short buffer.
|
|
452
|
-
const fallback = setTimeout(finish, movedPosition ? 3000 : 600);
|
|
453
|
-
|
|
454
|
-
return () => {
|
|
455
|
-
google.maps.event.removeListener(lPano);
|
|
456
|
-
clearTimeout(fallback);
|
|
457
|
-
finish();
|
|
458
|
-
};
|
|
459
|
-
}, [pano, frame, fps, start, end, sv?.route, sv?.pov, sv?.zoom, onPanoReady]);
|
|
803
|
+
}, [pano, frame, fps, start, end, sv?.route, sv?.pov, sv?.zoom, svRadius, startWait]);
|
|
460
804
|
|
|
461
805
|
return (
|
|
462
806
|
<div
|
|
@@ -467,23 +811,13 @@ function StreetViewLeaf({
|
|
|
467
811
|
}
|
|
468
812
|
|
|
469
813
|
// ============================================================
|
|
470
|
-
//
|
|
471
|
-
// route line + waypoint markers (label or media thumbnail) +
|
|
472
|
-
// the animated traveling marker
|
|
814
|
+
// WaypointMarkers — one marker per waypoint (media thumbnail or label)
|
|
473
815
|
// ============================================================
|
|
474
|
-
function
|
|
475
|
-
waypoints,
|
|
816
|
+
function WaypointMarkers({
|
|
817
|
+
waypoints,
|
|
476
818
|
}: {
|
|
477
819
|
waypoints: { lat: number; lng: number; label?: string; media?: string }[];
|
|
478
|
-
travelMode: string;
|
|
479
|
-
markerEmoji: string;
|
|
480
|
-
actionDuration: number;
|
|
481
820
|
}) {
|
|
482
|
-
const leg = useRouteLeg(waypoints, travelMode);
|
|
483
|
-
|
|
484
|
-
// Compute animated marker position
|
|
485
|
-
const position = useAnimatedPosition({ leg, actionDuration, waypoints });
|
|
486
|
-
|
|
487
821
|
return (
|
|
488
822
|
<>
|
|
489
823
|
{waypoints.map((wp, i) => (
|
|
@@ -527,15 +861,255 @@ function RouteWithMarker({
|
|
|
527
861
|
) : null}
|
|
528
862
|
</AdvancedMarker>
|
|
529
863
|
))}
|
|
530
|
-
{position ? (
|
|
531
|
-
<AdvancedMarker position={position}>
|
|
532
|
-
<Pin glyphText={markerEmoji} scale={4} />
|
|
533
|
-
</AdvancedMarker>
|
|
534
|
-
) : null}
|
|
535
864
|
</>
|
|
536
865
|
);
|
|
537
866
|
}
|
|
538
867
|
|
|
868
|
+
// ============================================================
|
|
869
|
+
// TravelingMarker — the animated marker along the route
|
|
870
|
+
// ============================================================
|
|
871
|
+
function TravelingMarker({
|
|
872
|
+
position,
|
|
873
|
+
glyph,
|
|
874
|
+
}: {
|
|
875
|
+
position: { lat: number; lng: number } | null;
|
|
876
|
+
glyph: string;
|
|
877
|
+
}) {
|
|
878
|
+
if (!position) return null;
|
|
879
|
+
return (
|
|
880
|
+
<AdvancedMarker position={position}>
|
|
881
|
+
<Pin glyphText={glyph} scale={4} />
|
|
882
|
+
</AdvancedMarker>
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// ============================================================
|
|
887
|
+
// RoutePolylines — draws each leg's route line. Road legs are a
|
|
888
|
+
// solid line in the route color; synthetic legs (FLIGHT ✈️ / BOAT 🚢)
|
|
889
|
+
// are a dashed arc in a distinct color.
|
|
890
|
+
// ============================================================
|
|
891
|
+
function RoutePolylines({
|
|
892
|
+
legs, routeColor, routeWeight,
|
|
893
|
+
}: {
|
|
894
|
+
legs: RouteLeg[];
|
|
895
|
+
routeColor: string;
|
|
896
|
+
routeWeight: number;
|
|
897
|
+
}) {
|
|
898
|
+
const map = useMap();
|
|
899
|
+
|
|
900
|
+
React.useEffect(() => {
|
|
901
|
+
if (!map) return;
|
|
902
|
+
const created = legs.map((leg) => {
|
|
903
|
+
const path = leg.steps.flatMap((s) => s.path);
|
|
904
|
+
const synthetic = isSyntheticMode(leg.mode);
|
|
905
|
+
const color = synthetic
|
|
906
|
+
? leg.mode.toUpperCase() === "BOAT" ? "#00ACC1" : "#FBBC04"
|
|
907
|
+
: routeColor;
|
|
908
|
+
const opts: google.maps.PolylineOptions = {
|
|
909
|
+
map,
|
|
910
|
+
path,
|
|
911
|
+
strokeColor: color,
|
|
912
|
+
strokeWeight: synthetic ? Math.max(3, routeWeight - 1) : routeWeight,
|
|
913
|
+
strokeOpacity: 0.95,
|
|
914
|
+
zIndex: 1,
|
|
915
|
+
};
|
|
916
|
+
if (synthetic) {
|
|
917
|
+
opts.icons = [{
|
|
918
|
+
icon: { path: "M 0 -1 0 1", strokeColor: color, strokeWeight: 2, scale: 2 },
|
|
919
|
+
offset: "0",
|
|
920
|
+
repeat: "12px",
|
|
921
|
+
}];
|
|
922
|
+
}
|
|
923
|
+
return new google.maps.Polyline(opts);
|
|
924
|
+
});
|
|
925
|
+
return () => created.forEach((p) => p.setMap(null));
|
|
926
|
+
}, [map, legs, routeColor, routeWeight]);
|
|
927
|
+
|
|
928
|
+
return null;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// ============================================================
|
|
932
|
+
// RouteWithMarker — single-leg route (used by cinematic views).
|
|
933
|
+
// Fetches the Directions route, renders the route line + waypoint
|
|
934
|
+
// markers (label or media thumbnail) + the animated traveling marker
|
|
935
|
+
// ============================================================
|
|
936
|
+
function RouteWithMarker({
|
|
937
|
+
waypoints, travelMode, markerEmoji, actionDuration,
|
|
938
|
+
}: {
|
|
939
|
+
waypoints: { lat: number; lng: number; label?: string; media?: string }[];
|
|
940
|
+
travelMode: string;
|
|
941
|
+
markerEmoji: string;
|
|
942
|
+
actionDuration: number;
|
|
943
|
+
}) {
|
|
944
|
+
const leg = useRouteLeg(waypoints, travelMode);
|
|
945
|
+
|
|
946
|
+
// Compute animated marker position
|
|
947
|
+
const position = useAnimatedPosition({ leg, actionDuration, waypoints });
|
|
948
|
+
|
|
949
|
+
return (
|
|
950
|
+
<>
|
|
951
|
+
<WaypointMarkers waypoints={waypoints} />
|
|
952
|
+
<TravelingMarker position={position} glyph={markerEmoji} />
|
|
953
|
+
</>
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// ============================================================
|
|
958
|
+
// RouteWithMarkerLegs — multi-leg route (view:"route"). Each
|
|
959
|
+
// waypoint may tag its outgoing leg with a travel mode
|
|
960
|
+
// (waypoint.mode ?? map travelMode); FLIGHT/BOAT legs are synthetic
|
|
961
|
+
// dashed arcs. The traveling marker switches glyph per leg
|
|
962
|
+
// (✈️ 🚢 🚶 🚲 🚌 🚗).
|
|
963
|
+
// ============================================================
|
|
964
|
+
function RouteWithMarkerLegs({
|
|
965
|
+
waypoints, travelMode, markerEmoji, actionDuration, routeColor, routeWeight, children,
|
|
966
|
+
}: {
|
|
967
|
+
waypoints: { lat: number; lng: number; label?: string; media?: string; mode?: string }[];
|
|
968
|
+
travelMode: string;
|
|
969
|
+
markerEmoji: string;
|
|
970
|
+
actionDuration: number;
|
|
971
|
+
routeColor: string;
|
|
972
|
+
routeWeight: number;
|
|
973
|
+
children?: Stream[];
|
|
974
|
+
}) {
|
|
975
|
+
const legs = useRouteLegs(waypoints, travelMode);
|
|
976
|
+
const stops = useRouteStops(children, waypoints, legs);
|
|
977
|
+
const position = useAnimatedPositionLegs({ legs, actionDuration, stops });
|
|
978
|
+
const glyph = position ? modeEmoji(position.mode) : markerEmoji;
|
|
979
|
+
|
|
980
|
+
return (
|
|
981
|
+
<>
|
|
982
|
+
<RoutePolylines legs={legs} routeColor={routeColor} routeWeight={routeWeight} />
|
|
983
|
+
<WaypointMarkers waypoints={waypoints} />
|
|
984
|
+
<TravelingMarker position={position} glyph={glyph} />
|
|
985
|
+
</>
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// ============================================================
|
|
990
|
+
// useRouteStops — derives the pin's dwell windows from the map's
|
|
991
|
+
// overlay children (each has at:"Label" + start/end). The pin holds
|
|
992
|
+
// at that waypoint while its child plays. Windows at the same
|
|
993
|
+
// waypoint are merged.
|
|
994
|
+
// ============================================================
|
|
995
|
+
function useRouteStops(
|
|
996
|
+
children: Stream[] | undefined,
|
|
997
|
+
waypoints: { lat: number; lng: number; label?: string; mode?: string }[],
|
|
998
|
+
legs: RouteLeg[],
|
|
999
|
+
): RouteStopWindow[] {
|
|
1000
|
+
return React.useMemo(() => {
|
|
1001
|
+
if (!children || children.length === 0) return [];
|
|
1002
|
+
const byLabel = new Map<string, RouteStopWindow>();
|
|
1003
|
+
for (const child of children) {
|
|
1004
|
+
const label = (child as any).at as string | undefined;
|
|
1005
|
+
if (!label) continue;
|
|
1006
|
+
const wp = waypoints.find((w) => w.label === label);
|
|
1007
|
+
if (!wp) continue;
|
|
1008
|
+
const fromSec = child.start ?? 0;
|
|
1009
|
+
const toSec = child.end ?? fromSec + (child.duration ?? 1);
|
|
1010
|
+
// Mode of the leg that arrives at this waypoint (leg i → waypoint i+1).
|
|
1011
|
+
const idx = waypoints.findIndex((w) => w.label === label) - 1;
|
|
1012
|
+
const mode = legs[idx]?.mode ?? "DRIVING";
|
|
1013
|
+
const existing = byLabel.get(label);
|
|
1014
|
+
if (existing) {
|
|
1015
|
+
existing.fromSec = Math.min(existing.fromSec, fromSec);
|
|
1016
|
+
existing.toSec = Math.max(existing.toSec, toSec);
|
|
1017
|
+
} else {
|
|
1018
|
+
byLabel.set(label, { label, at: { lat: wp.lat, lng: wp.lng }, mode, fromSec, toSec });
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
return [...byLabel.values()];
|
|
1022
|
+
}, [children, waypoints, legs]);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
// ============================================================
|
|
1027
|
+
// useRouteLegs — loads one Directions leg per consecutive waypoint
|
|
1028
|
+
// pair (view:"route"). Each waypoint's `mode` tags its OUTGOING leg;
|
|
1029
|
+
// FLIGHT/BOAT legs are synthetic arcs (no Directions route). Time is
|
|
1030
|
+
// split across legs proportionally to each leg's duration.
|
|
1031
|
+
// ============================================================
|
|
1032
|
+
function useRouteLegs(
|
|
1033
|
+
waypoints: { lat: number; lng: number; mode?: string }[],
|
|
1034
|
+
travelMode: string,
|
|
1035
|
+
): RouteLeg[] {
|
|
1036
|
+
const map = useMap();
|
|
1037
|
+
const routesLibrary = useMapsLibrary("routes");
|
|
1038
|
+
const [legs, setLegs] = React.useState<RouteLeg[]>([]);
|
|
1039
|
+
const handle = React.useRef<number | null>(null);
|
|
1040
|
+
|
|
1041
|
+
React.useEffect(() => {
|
|
1042
|
+
if (!routesLibrary || !map || waypoints.length < 2) return;
|
|
1043
|
+
let active = true;
|
|
1044
|
+
const renderHandle = delayRender("Loading map directions...");
|
|
1045
|
+
handle.current = renderHandle;
|
|
1046
|
+
|
|
1047
|
+
const service = new routesLibrary.DirectionsService();
|
|
1048
|
+
|
|
1049
|
+
Promise.all(
|
|
1050
|
+
waypoints.slice(0, -1).map((wp, i) => {
|
|
1051
|
+
const to = waypoints[i + 1]!;
|
|
1052
|
+
const mode = (wp.mode ?? travelMode).toUpperCase();
|
|
1053
|
+
if (isSyntheticMode(mode)) {
|
|
1054
|
+
return Promise.resolve<RouteLeg | null>(makeSyntheticLeg(wp, to, mode));
|
|
1055
|
+
}
|
|
1056
|
+
return service
|
|
1057
|
+
.route({
|
|
1058
|
+
origin: wp,
|
|
1059
|
+
destination: to,
|
|
1060
|
+
travelMode: google.maps.TravelMode[mode as keyof typeof google.maps.TravelMode] ?? google.maps.TravelMode.DRIVING,
|
|
1061
|
+
provideRouteAlternatives: false,
|
|
1062
|
+
})
|
|
1063
|
+
.then((response) => {
|
|
1064
|
+
const leg = response.routes[0]?.legs[0];
|
|
1065
|
+
if (!leg) return null;
|
|
1066
|
+
const steps = (leg.steps ?? []).map((s) => ({
|
|
1067
|
+
path: (s.path ?? []).map((p) => ({ lat: p.lat(), lng: p.lng() })),
|
|
1068
|
+
durationSec: s.duration?.value ?? 1,
|
|
1069
|
+
}));
|
|
1070
|
+
return {
|
|
1071
|
+
mode,
|
|
1072
|
+
from: wp,
|
|
1073
|
+
to,
|
|
1074
|
+
durationSec: leg.duration?.value ?? (steps.reduce((sum, st) => sum + st.durationSec, 0) || 1),
|
|
1075
|
+
steps,
|
|
1076
|
+
} satisfies RouteLeg;
|
|
1077
|
+
})
|
|
1078
|
+
.catch(() => null);
|
|
1079
|
+
}),
|
|
1080
|
+
).then((resolved) => {
|
|
1081
|
+
if (!active) return;
|
|
1082
|
+
setLegs(resolved.filter((l): l is RouteLeg => l !== null));
|
|
1083
|
+
if (handle.current !== null) continueRender(handle.current);
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
return () => {
|
|
1087
|
+
active = false;
|
|
1088
|
+
};
|
|
1089
|
+
}, [routesLibrary, map, waypoints, travelMode]);
|
|
1090
|
+
|
|
1091
|
+
return legs;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// ============================================================
|
|
1095
|
+
// useAnimatedPositionLegs — leg-aware animated marker position
|
|
1096
|
+
// (view:"route"): returns { lat, lng, mode } for the current frame
|
|
1097
|
+
// ============================================================
|
|
1098
|
+
function useAnimatedPositionLegs({
|
|
1099
|
+
legs, actionDuration, stops = [],
|
|
1100
|
+
}: {
|
|
1101
|
+
legs: RouteLeg[];
|
|
1102
|
+
actionDuration: number;
|
|
1103
|
+
stops?: RouteStopWindow[];
|
|
1104
|
+
}) {
|
|
1105
|
+
const frame = useCurrentFrame();
|
|
1106
|
+
const { fps } = useVideoConfig();
|
|
1107
|
+
return React.useMemo(
|
|
1108
|
+
() => routePositionAtLegs(legs, actionDuration, frame / fps, stops),
|
|
1109
|
+
[legs, frame, fps, actionDuration, stops],
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
539
1113
|
// ============================================================
|
|
540
1114
|
// useRouteLeg — loads the Directions route (shared by RouteMap,
|
|
541
1115
|
// CinematicMap and CinematicMap3D). Uses the map instance from
|