@lalalic/markcut 3.0.0 → 3.1.1
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 +1 -1
- package/skills/markcut/SKILL.md +7 -0
- package/skills/markcut/docs/map-dynamic-camera.md +328 -0
- package/skills/markcut/docs/markdown-descriptive.md +2 -0
- package/src/descriptive/compiler.ts +101 -1
- package/src/descriptive/dsl.ts +64 -7
- package/src/descriptive/markdown.ts +7 -1
- package/src/descriptive/resolve.test.ts +103 -5
- package/src/descriptive/resolve.ts +207 -24
- package/src/player/bundle/player.js +223296 -222155
- package/src/player/pipeline.mjs +314 -25
- package/src/player/pipeline.ts +5 -4
- package/src/player/server.mjs +22 -42
- package/src/render/cli.mjs +54 -3
- package/src/render/validate-assets.mjs +140 -0
- package/src/schema/index.ts +60 -1
- package/src/spots/cli.mjs +266 -0
- package/src/types/Effect.tsx +12 -1
- package/src/types/Map.tsx +1078 -130
- package/src/utils/directions.ts +101 -0
- package/src/utils/index.ts +11 -0
- package/src/utils/route-legs.ts +199 -0
- package/src/utils/tween.ts +49 -1
- package/tests/dsl.test.ts +78 -0
- package/tests/fixtures/map-dynamic.json +52 -0
- package/tests/fixtures/map-overlay.json +56 -0
- package/tests/fixtures/md/animate-diagrams.md +9 -7
- package/tests/fixtures/md/map-all-views.md +35 -0
- 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 +133 -0
- package/tests/render.test.ts +93 -0
- package/tests/route-legs.test.ts +178 -0
- package/tests/schema.test.ts +76 -1
- package/tests/validate-assets.test.ts +106 -0
- package/B] +0 -2
- package/tests/tmp/vision-1785081637127-video/videos/.normalized/segments/test-clip_0to3_seg_1100to3000.mp4 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_0to3.mp4 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_audio.mp3 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/metadata.json +0 -9
- package/tests/tmp/vision-1785081637127-video/videos/test-clip.mp4 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/test-clip.vtt +0 -5
package/src/types/Map.tsx
CHANGED
|
@@ -1,31 +1,51 @@
|
|
|
1
|
+
/// <reference types="google.maps" />
|
|
1
2
|
/**
|
|
2
|
-
* Map stream type —
|
|
3
|
+
* Map stream type — Google Maps visualizations with dynamic, movie-like cameras.
|
|
4
|
+
*
|
|
5
|
+
* Four views, selected by `view`:
|
|
6
|
+
* - overview: static or dolly camera over the map (mapType: satellite for a city shot)
|
|
7
|
+
* - route: animated marker traveling the Directions route (default)
|
|
8
|
+
* - cinematic: chase/flyover camera — 2D tilt+heading follow (default), or
|
|
9
|
+
* experimental Map3D flyTo/orbit when `cinematic.fallback:"none"`
|
|
10
|
+
* - streetview: immersive StreetViewPanorama with animated POV/position
|
|
11
|
+
*
|
|
12
|
+
* Camera values are written with `tween(from, to, easing?)` in the descriptive
|
|
13
|
+
* layer and resolved per frame via `resolveTween` (utils/tween.ts) — every
|
|
14
|
+
* frame renders deterministically from `useCurrentFrame()`.
|
|
3
15
|
*
|
|
4
|
-
* Renders a Google Map with Directions API route between waypoints and an
|
|
5
|
-
* animated marker that travels along the path in sync with the current frame.
|
|
6
16
|
* Uses @vis.gl/react-google-maps (Google Maps JS API wrapper) — no separate
|
|
7
17
|
* API key management needed beyond what's embedded in the engine build.
|
|
8
18
|
*
|
|
9
|
-
* Adapted from qili-ai studio's map component.
|
|
10
|
-
*
|
|
11
19
|
* Usage in stream tree:
|
|
12
20
|
* {
|
|
13
21
|
* type: "map",
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
22
|
+
* view: "route", // overview | route | cinematic | streetview
|
|
23
|
+
* waypoints: [{ lat, lng, label?, media? }],
|
|
24
|
+
* travelMode: "DRIVING", // DRIVING | WALKING | BICYCLING
|
|
25
|
+
* mapType: "roadmap", // roadmap | satellite | hybrid | terrain
|
|
26
|
+
* camera: { zoom: { __tween: [6, 12, "easeInOut"] } }, // tween(6,12,easeInOut)
|
|
27
|
+
* routeMarker: "🚗", // emoji/char for animated pin
|
|
18
28
|
* start: 0, end: 5
|
|
19
29
|
* }
|
|
20
30
|
*/
|
|
21
31
|
import React from "react";
|
|
22
|
-
import { Sequence, useCurrentFrame, useVideoConfig, delayRender, continueRender } from "remotion";
|
|
32
|
+
import { Sequence, AbsoluteFill, useCurrentFrame, useVideoConfig, delayRender, continueRender } from "remotion";
|
|
23
33
|
import { useFrameEvents } from "../context/index";
|
|
24
34
|
import {
|
|
25
|
-
APIProvider, Map as GoogleMap, useMap, useMapsLibrary,
|
|
26
|
-
AdvancedMarker, Pin,
|
|
35
|
+
APIProvider, Map as GoogleMap, useMap, useMapsLibrary, useMap3D,
|
|
36
|
+
AdvancedMarker, Pin, Map3D, Marker3D,
|
|
27
37
|
} from "@vis.gl/react-google-maps";
|
|
28
|
-
import type {
|
|
38
|
+
import type { Map3DRef } from "@vis.gl/react-google-maps";
|
|
39
|
+
import { resolveTween } from "../utils/tween";
|
|
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";
|
|
29
49
|
|
|
30
50
|
// API key is injected by the compiler onto the stream node (see compileLeaf in compiler.ts).
|
|
31
51
|
// This fallback handles the case where Map.tsx is used directly without the compiler.
|
|
@@ -49,24 +69,256 @@ function resolveMapLocale(language?: string, region?: string): { language?: stri
|
|
|
49
69
|
}
|
|
50
70
|
|
|
51
71
|
// ============================================================
|
|
52
|
-
//
|
|
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
|
+
|
|
305
|
+
// ============================================================
|
|
306
|
+
// MapLeaf — entry point, dispatches to the view renderer
|
|
53
307
|
// ============================================================
|
|
54
308
|
export function MapLeaf({ stream }: { stream: MapStream }) {
|
|
309
|
+
const [mapInstance, setMapInstance] = React.useState<google.maps.Map | null>(null);
|
|
55
310
|
const { fps } = useVideoConfig();
|
|
56
311
|
const waypoints = stream.waypoints ?? [];
|
|
57
312
|
const start = stream.start ?? 0;
|
|
58
313
|
const end = stream.end ?? start + (stream.duration ?? 1);
|
|
59
314
|
const totalDur = stream.durationInSeconds ?? end;
|
|
60
315
|
const apiKey = resolveApiKey(stream);
|
|
316
|
+
const view = stream.view ?? "route";
|
|
61
317
|
useFrameEvents(stream.on, Math.max(1, Math.floor(totalDur * fps)));
|
|
62
|
-
|
|
318
|
+
// route/cinematic animate along waypoints; overview/streetview are standalone views.
|
|
319
|
+
if ((view === "route" || view === "cinematic") && waypoints.length === 0) return null;
|
|
63
320
|
|
|
64
321
|
const durFrames = Math.max(1, Math.floor(fps * (end - start)));
|
|
65
|
-
const center = stream.center ?? { lat: waypoints[0].lat, lng: waypoints[0].lng };
|
|
66
|
-
const zoom = stream.zoom ?? 10;
|
|
67
|
-
const mapType = stream.mapType ?? "roadmap";
|
|
68
|
-
const travelMode = stream.travelMode ?? "DRIVING";
|
|
69
|
-
const markerEmoji = stream.routeMarker ?? "🚗";
|
|
70
322
|
const mapLocale = React.useMemo(
|
|
71
323
|
() => resolveMapLocale(stream.language, stream.region),
|
|
72
324
|
[stream.language, stream.region],
|
|
@@ -75,10 +327,13 @@ export function MapLeaf({ stream }: { stream: MapStream }) {
|
|
|
75
327
|
const mapLoadContinuedRef = React.useRef(false);
|
|
76
328
|
|
|
77
329
|
React.useEffect(() => {
|
|
78
|
-
|
|
330
|
+
// Strip Google Maps attribution / UI text from the rendered video.
|
|
331
|
+
hideGoogleMapsUi();
|
|
332
|
+
|
|
333
|
+
mapLoadHandleRef.current = delayRender("Waiting for map to load...");
|
|
79
334
|
mapLoadContinuedRef.current = false;
|
|
80
335
|
|
|
81
|
-
// Avoid hanging indefinitely when
|
|
336
|
+
// Avoid hanging indefinitely when tiles/pano/3D fail to load.
|
|
82
337
|
const fallbackTimer = window.setTimeout(() => {
|
|
83
338
|
if (!mapLoadContinuedRef.current && mapLoadHandleRef.current !== null) {
|
|
84
339
|
continueRender(mapLoadHandleRef.current);
|
|
@@ -96,13 +351,20 @@ export function MapLeaf({ stream }: { stream: MapStream }) {
|
|
|
96
351
|
};
|
|
97
352
|
}, [stream.id, start, end]);
|
|
98
353
|
|
|
99
|
-
const
|
|
354
|
+
const handleMapReady = React.useCallback(() => {
|
|
100
355
|
if (!mapLoadContinuedRef.current && mapLoadHandleRef.current !== null) {
|
|
101
356
|
continueRender(mapLoadHandleRef.current);
|
|
102
357
|
mapLoadContinuedRef.current = true;
|
|
103
358
|
}
|
|
104
359
|
}, []);
|
|
105
360
|
|
|
361
|
+
// Experimental Map3D is opt-in via `cinematic.fallback:"none"` (requires the
|
|
362
|
+
// Google Maps 3D preview API). Default renders the safe 2D chase camera.
|
|
363
|
+
const use3d =
|
|
364
|
+
view === "cinematic" &&
|
|
365
|
+
stream.cinematic?.fallback === "none" &&
|
|
366
|
+
(stream.cinematic.mode === "flyTo" || stream.cinematic.mode === "orbit");
|
|
367
|
+
|
|
106
368
|
return (
|
|
107
369
|
<Sequence
|
|
108
370
|
durationInFrames={durFrames}
|
|
@@ -114,46 +376,752 @@ export function MapLeaf({ stream }: { stream: MapStream }) {
|
|
|
114
376
|
language={mapLocale.language}
|
|
115
377
|
region={mapLocale.region}
|
|
116
378
|
>
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
onTilesLoaded={handleTilesLoaded}
|
|
127
|
-
style={{ width: "100%", height: "100%", position: "absolute" }}
|
|
128
|
-
>
|
|
129
|
-
<RouteWithMarker
|
|
130
|
-
waypoints={waypoints}
|
|
131
|
-
travelMode={travelMode}
|
|
132
|
-
markerEmoji={markerEmoji}
|
|
133
|
-
actionDuration={end - start}
|
|
134
|
-
/>
|
|
135
|
-
</GoogleMap>
|
|
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>
|
|
136
388
|
</APIProvider>
|
|
137
389
|
</Sequence>
|
|
138
390
|
);
|
|
139
391
|
}
|
|
140
392
|
|
|
141
393
|
// ============================================================
|
|
142
|
-
//
|
|
143
|
-
//
|
|
394
|
+
// OverviewMap — static or dolly camera (far → near) over the map
|
|
395
|
+
// ============================================================
|
|
396
|
+
function OverviewMap({
|
|
397
|
+
stream, onTilesLoaded,
|
|
398
|
+
}: { stream: MapStream; onTilesLoaded: () => void }) {
|
|
399
|
+
const { fps } = useVideoConfig();
|
|
400
|
+
const frame = useCurrentFrame();
|
|
401
|
+
const start = stream.start ?? 0;
|
|
402
|
+
const end = stream.end ?? start + (stream.duration ?? 1);
|
|
403
|
+
const fallbackCenter = stream.center ?? { lat: 37.7749, lng: -122.4194 };
|
|
404
|
+
const center = {
|
|
405
|
+
lat: resolveTween(frame, fps, stream.camera?.center?.lat, start, end, fallbackCenter.lat),
|
|
406
|
+
lng: resolveTween(frame, fps, stream.camera?.center?.lng, start, end, fallbackCenter.lng),
|
|
407
|
+
};
|
|
408
|
+
const zoom = resolveTween(frame, fps, stream.camera?.zoom, start, end, stream.zoom ?? 10);
|
|
409
|
+
const heading = resolveTween(frame, fps, stream.camera?.heading, start, end, 0);
|
|
410
|
+
const tilt = resolveTween(frame, fps, stream.camera?.tilt, start, end, 0);
|
|
411
|
+
return (
|
|
412
|
+
<GoogleMap
|
|
413
|
+
mapId={String(stream.id ?? "map-overview")}
|
|
414
|
+
center={center}
|
|
415
|
+
zoom={zoom}
|
|
416
|
+
heading={heading}
|
|
417
|
+
tilt={tilt}
|
|
418
|
+
mapTypeId={stream.mapType ?? "roadmap"}
|
|
419
|
+
disableDefaultUI
|
|
420
|
+
zoomControl={false}
|
|
421
|
+
onTilesLoaded={onTilesLoaded}
|
|
422
|
+
style={{ width: "100%", height: "100%", position: "absolute" }}
|
|
423
|
+
>
|
|
424
|
+
<MapBridge />
|
|
425
|
+
</GoogleMap>
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ============================================================
|
|
430
|
+
// RouteMap — classic animated route (view:"route", the default)
|
|
431
|
+
// ============================================================
|
|
432
|
+
function RouteMap({
|
|
433
|
+
stream, onTilesLoaded,
|
|
434
|
+
}: { stream: MapStream; onTilesLoaded: () => void }) {
|
|
435
|
+
const { fps } = useVideoConfig();
|
|
436
|
+
const frame = useCurrentFrame();
|
|
437
|
+
const waypoints = stream.waypoints ?? [];
|
|
438
|
+
const start = stream.start ?? 0;
|
|
439
|
+
const end = stream.end ?? start + (stream.duration ?? 1);
|
|
440
|
+
const fallbackCenter = stream.center ?? { lat: waypoints[0]!.lat, lng: waypoints[0]!.lng };
|
|
441
|
+
const center = {
|
|
442
|
+
lat: resolveTween(frame, fps, stream.camera?.center?.lat, start, end, fallbackCenter.lat),
|
|
443
|
+
lng: resolveTween(frame, fps, stream.camera?.center?.lng, start, end, fallbackCenter.lng),
|
|
444
|
+
};
|
|
445
|
+
const zoom = resolveTween(frame, fps, stream.camera?.zoom, start, end, stream.zoom ?? 10);
|
|
446
|
+
return (
|
|
447
|
+
<GoogleMap
|
|
448
|
+
mapId={String(stream.id ?? "map-route")}
|
|
449
|
+
center={center}
|
|
450
|
+
zoom={zoom}
|
|
451
|
+
mapTypeId={stream.mapType ?? "roadmap"}
|
|
452
|
+
disableDefaultUI
|
|
453
|
+
zoomControl={false}
|
|
454
|
+
onTilesLoaded={onTilesLoaded}
|
|
455
|
+
style={{ width: "100%", height: "100%", position: "absolute" }}
|
|
456
|
+
>
|
|
457
|
+
<MapBridge />
|
|
458
|
+
<RouteWithMarkerLegs
|
|
459
|
+
waypoints={waypoints}
|
|
460
|
+
travelMode={stream.travelMode ?? "DRIVING"}
|
|
461
|
+
markerEmoji={stream.routeMarker ?? "🚗"}
|
|
462
|
+
actionDuration={end - start}
|
|
463
|
+
routeColor={stream.routeColor ?? "#4285F4"}
|
|
464
|
+
routeWeight={stream.routeWeight ?? 4}
|
|
465
|
+
children={stream.children as Stream[]}
|
|
466
|
+
/>
|
|
467
|
+
</GoogleMap>
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ============================================================
|
|
472
|
+
// CinematicMap — 2D movie flyover (view:"cinematic")
|
|
473
|
+
//
|
|
474
|
+
// flyAlong (default): drone chase — center follows the marker,
|
|
475
|
+
// heading = route bearing, tilt 45°
|
|
476
|
+
// flyTo: dolly from first waypoint toward the destination
|
|
477
|
+
// orbit: rotate heading around a fixed center
|
|
478
|
+
// ============================================================
|
|
479
|
+
function CinematicMap({
|
|
480
|
+
stream, onTilesLoaded,
|
|
481
|
+
}: { stream: MapStream; onTilesLoaded: () => void }) {
|
|
482
|
+
const { fps } = useVideoConfig();
|
|
483
|
+
const frame = useCurrentFrame();
|
|
484
|
+
const waypoints = stream.waypoints ?? [];
|
|
485
|
+
const start = stream.start ?? 0;
|
|
486
|
+
const end = stream.end ?? start + (stream.duration ?? 1);
|
|
487
|
+
const actionDuration = Math.max(0.1, end - start);
|
|
488
|
+
const mode = stream.cinematic?.mode ?? "flyAlong";
|
|
489
|
+
const headingFollow = stream.cinematic?.headingFollow ?? true;
|
|
490
|
+
const leg = useRouteLeg(waypoints, stream.travelMode ?? "DRIVING");
|
|
491
|
+
|
|
492
|
+
const seconds = frame / fps;
|
|
493
|
+
const pos = routePositionAt(leg, waypoints, actionDuration, seconds);
|
|
494
|
+
const lookahead = routePositionAt(leg, waypoints, actionDuration, seconds + 0.4);
|
|
495
|
+
const first = waypoints[0]!;
|
|
496
|
+
const fallbackCenter = stream.center ?? first;
|
|
497
|
+
|
|
498
|
+
let center: { lat: number; lng: number };
|
|
499
|
+
if (mode === "flyAlong" && pos) {
|
|
500
|
+
center = pos;
|
|
501
|
+
} else if (mode === "flyTo") {
|
|
502
|
+
center = {
|
|
503
|
+
lat: resolveTween(frame, fps, stream.camera?.center?.lat, start, end, first.lat),
|
|
504
|
+
lng: resolveTween(frame, fps, stream.camera?.center?.lng, start, end, first.lng),
|
|
505
|
+
};
|
|
506
|
+
} else {
|
|
507
|
+
center = fallbackCenter;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
let heading = resolveTween(frame, fps, stream.camera?.heading, start, end, 0);
|
|
511
|
+
if (headingFollow && pos && lookahead) {
|
|
512
|
+
heading = bearing(pos, lookahead);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const tilt = resolveTween(frame, fps, stream.cinematic?.tilt, start, end, 45);
|
|
516
|
+
const zoom = resolveTween(frame, fps, stream.camera?.zoom, start, end, stream.zoom ?? 13);
|
|
517
|
+
|
|
518
|
+
return (
|
|
519
|
+
<GoogleMap
|
|
520
|
+
mapId={String(stream.id ?? "map-cinematic")}
|
|
521
|
+
center={center}
|
|
522
|
+
zoom={zoom}
|
|
523
|
+
heading={heading}
|
|
524
|
+
tilt={tilt}
|
|
525
|
+
mapTypeId={stream.mapType ?? "roadmap"}
|
|
526
|
+
disableDefaultUI
|
|
527
|
+
zoomControl={false}
|
|
528
|
+
onTilesLoaded={onTilesLoaded}
|
|
529
|
+
style={{ width: "100%", height: "100%", position: "absolute" }}
|
|
530
|
+
>
|
|
531
|
+
<RouteWithMarker
|
|
532
|
+
waypoints={waypoints}
|
|
533
|
+
travelMode={stream.travelMode ?? "DRIVING"}
|
|
534
|
+
markerEmoji={stream.routeMarker ?? "🚗"}
|
|
535
|
+
actionDuration={actionDuration}
|
|
536
|
+
/>
|
|
537
|
+
</GoogleMap>
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ============================================================
|
|
542
|
+
// CinematicMap3D — experimental Map3D flyover (opt-in via fallback:"none")
|
|
543
|
+
//
|
|
544
|
+
// flyTo: controlled `range` tween (far → near) over the route
|
|
545
|
+
// orbit: controlled heading/roll tween around a fixed center
|
|
546
|
+
// Route drawn with a raw <gmp-polyline-3d> element; markers via <Marker3D>.
|
|
547
|
+
// ============================================================
|
|
548
|
+
function CinematicMap3D({
|
|
549
|
+
stream, onReady,
|
|
550
|
+
}: { stream: MapStream; onReady: () => void }) {
|
|
551
|
+
const { fps } = useVideoConfig();
|
|
552
|
+
const frame = useCurrentFrame();
|
|
553
|
+
const waypoints = stream.waypoints ?? [];
|
|
554
|
+
const start = stream.start ?? 0;
|
|
555
|
+
const end = stream.end ?? start + (stream.duration ?? 1);
|
|
556
|
+
const actionDuration = Math.max(0.1, end - start);
|
|
557
|
+
const first = waypoints[0] ?? { lat: 37.7749, lng: -122.4194 };
|
|
558
|
+
const map3dRef = React.useRef<Map3DRef>(null);
|
|
559
|
+
const map3d = useMap3D();
|
|
560
|
+
const leg = useRouteLeg(waypoints, stream.travelMode ?? "DRIVING");
|
|
561
|
+
|
|
562
|
+
const range = resolveTween(frame, fps, stream.cinematic?.range, start, end, 2000);
|
|
563
|
+
const tilt = resolveTween(frame, fps, stream.cinematic?.tilt, start, end, 60);
|
|
564
|
+
const roll = resolveTween(frame, fps, stream.cinematic?.roll, start, end, 0);
|
|
565
|
+
const heading = resolveTween(frame, fps, stream.camera?.heading, start, end, 0);
|
|
566
|
+
const center = {
|
|
567
|
+
lat: resolveTween(frame, fps, stream.camera?.center?.lat, start, end, first.lat),
|
|
568
|
+
lng: resolveTween(frame, fps, stream.camera?.center?.lng, start, end, first.lng),
|
|
569
|
+
altitude: stream.cinematic?.altitude ?? 100,
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
const seconds = frame / fps;
|
|
573
|
+
const pos = routePositionAt(leg, waypoints, actionDuration, seconds);
|
|
574
|
+
|
|
575
|
+
// Route line via the raw gmp-polyline-3d custom element (no wrapper in 1.8.x).
|
|
576
|
+
const legPath = React.useMemo(() => {
|
|
577
|
+
if (!leg) return null;
|
|
578
|
+
const pts: Array<{ lat: number; lng: number; altitude: number }> = [];
|
|
579
|
+
for (const step of leg.steps ?? []) {
|
|
580
|
+
for (const p of step.path ?? []) pts.push({ lat: p.lat(), lng: p.lng(), altitude: 0 });
|
|
581
|
+
}
|
|
582
|
+
return pts.length ? pts : null;
|
|
583
|
+
}, [leg]);
|
|
584
|
+
|
|
585
|
+
React.useEffect(() => {
|
|
586
|
+
if (!map3d || !legPath) return;
|
|
587
|
+
const el = document.createElement("gmp-polyline-3d") as unknown as google.maps.maps3d.Polyline3DElement;
|
|
588
|
+
el.coordinates = legPath;
|
|
589
|
+
el.strokeColor = stream.routeColor ?? "#4285F4";
|
|
590
|
+
el.strokeWidth = stream.routeWeight ?? 4;
|
|
591
|
+
map3d.appendChild(el);
|
|
592
|
+
return () => {
|
|
593
|
+
el.remove();
|
|
594
|
+
};
|
|
595
|
+
}, [map3d, legPath, stream.routeColor, stream.routeWeight]);
|
|
596
|
+
|
|
597
|
+
return (
|
|
598
|
+
<Map3D
|
|
599
|
+
ref={map3dRef}
|
|
600
|
+
mode={stream.mapType === "hybrid" ? "HYBRID" : "SATELLITE"}
|
|
601
|
+
center={center}
|
|
602
|
+
range={range}
|
|
603
|
+
heading={heading}
|
|
604
|
+
tilt={tilt}
|
|
605
|
+
roll={roll}
|
|
606
|
+
onSteadyChange={onReady}
|
|
607
|
+
onAnimationEnd={onReady}
|
|
608
|
+
style={{ width: "100%", height: "100%", position: "absolute" }}
|
|
609
|
+
>
|
|
610
|
+
{pos ? (
|
|
611
|
+
<Marker3D position={{ lat: pos.lat, lng: pos.lng, altitude: 0 }} />
|
|
612
|
+
) : null}
|
|
613
|
+
</Map3D>
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ============================================================
|
|
618
|
+
// StreetViewLeaf — immersive StreetViewPanorama with animated POV/position
|
|
619
|
+
// ============================================================
|
|
620
|
+
function StreetViewLeaf({
|
|
621
|
+
stream, onPanoReady,
|
|
622
|
+
}: { stream: MapStream; onPanoReady: () => void }) {
|
|
623
|
+
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
624
|
+
const svLibrary = useMapsLibrary("streetView");
|
|
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
|
+
|
|
645
|
+
const { fps } = useVideoConfig();
|
|
646
|
+
const frame = useCurrentFrame();
|
|
647
|
+
const start = stream.start ?? 0;
|
|
648
|
+
const end = stream.end ?? start + (stream.duration ?? 1);
|
|
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]);
|
|
725
|
+
|
|
726
|
+
// Create the panorama once (no React wrapper component in this library version).
|
|
727
|
+
React.useEffect(() => {
|
|
728
|
+
if (!svLibrary || !containerRef.current) return;
|
|
729
|
+
loadedRef.current = false;
|
|
730
|
+
lastWpIndexRef.current = -1;
|
|
731
|
+
const pan = new svLibrary.StreetViewPanorama(containerRef.current, {
|
|
732
|
+
disableDefaultUI: true,
|
|
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);
|
|
739
|
+
if (sv?.pano) {
|
|
740
|
+
pan.setPano(sv.pano);
|
|
741
|
+
} else if (sv?.location) {
|
|
742
|
+
panSetPosition(sv.location);
|
|
743
|
+
} else if (sv?.route?.length) {
|
|
744
|
+
panSetPosition(sv.route[0]!);
|
|
745
|
+
}
|
|
746
|
+
if (sv?.pov) {
|
|
747
|
+
pan.setPov({
|
|
748
|
+
heading: typeof sv.pov.heading === "number" ? sv.pov.heading : 0,
|
|
749
|
+
pitch: typeof sv.pov.pitch === "number" ? sv.pov.pitch : 0,
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
if (typeof sv?.zoom === "number") {
|
|
753
|
+
pan.setZoom(sv.zoom);
|
|
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);
|
|
763
|
+
setPano(pan);
|
|
764
|
+
// Gate the initial load (persistent — see startWait).
|
|
765
|
+
startWait();
|
|
766
|
+
return () => {
|
|
767
|
+
loadedRef.current = false;
|
|
768
|
+
panoRef.current = null;
|
|
769
|
+
pan.setVisible(false);
|
|
770
|
+
};
|
|
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).
|
|
776
|
+
React.useEffect(() => {
|
|
777
|
+
if (!pano) return;
|
|
778
|
+
const pov = pano.getPov();
|
|
779
|
+
const heading = resolveTween(frame, fps, sv?.pov?.heading, start, end, pov.heading);
|
|
780
|
+
const pitch = resolveTween(frame, fps, sv?.pov?.pitch, start, end, pov.pitch);
|
|
781
|
+
pano.setPov({ heading, pitch });
|
|
782
|
+
const zoom = resolveTween(frame, fps, sv?.zoom, start, end, pano.getZoom() ?? 0);
|
|
783
|
+
pano.setZoom(zoom);
|
|
784
|
+
|
|
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.
|
|
790
|
+
const t = Math.min(Math.max((frame / fps) / Math.max(0.1, end - start), 0), 1);
|
|
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();
|
|
802
|
+
}
|
|
803
|
+
}, [pano, frame, fps, start, end, sv?.route, sv?.pov, sv?.zoom, svRadius, startWait]);
|
|
804
|
+
|
|
805
|
+
return (
|
|
806
|
+
<div
|
|
807
|
+
ref={containerRef}
|
|
808
|
+
style={{ width: "100%", height: "100%", position: "absolute" }}
|
|
809
|
+
/>
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// ============================================================
|
|
814
|
+
// WaypointMarkers — one marker per waypoint (media thumbnail or label)
|
|
815
|
+
// ============================================================
|
|
816
|
+
function WaypointMarkers({
|
|
817
|
+
waypoints,
|
|
818
|
+
}: {
|
|
819
|
+
waypoints: { lat: number; lng: number; label?: string; media?: string }[];
|
|
820
|
+
}) {
|
|
821
|
+
return (
|
|
822
|
+
<>
|
|
823
|
+
{waypoints.map((wp, i) => (
|
|
824
|
+
<AdvancedMarker key={i} position={wp}>
|
|
825
|
+
{wp.media ? (
|
|
826
|
+
<div
|
|
827
|
+
style={{
|
|
828
|
+
width: 44,
|
|
829
|
+
height: 44,
|
|
830
|
+
borderRadius: 6,
|
|
831
|
+
overflow: "hidden",
|
|
832
|
+
border: "2px solid #fff",
|
|
833
|
+
boxShadow: "0 1px 4px rgba(0,0,0,0.45)",
|
|
834
|
+
background: "#fff",
|
|
835
|
+
position: "relative",
|
|
836
|
+
top: "-24px",
|
|
837
|
+
}}
|
|
838
|
+
>
|
|
839
|
+
<img
|
|
840
|
+
src={wp.media}
|
|
841
|
+
alt=""
|
|
842
|
+
style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
|
|
843
|
+
/>
|
|
844
|
+
</div>
|
|
845
|
+
) : wp.label ? (
|
|
846
|
+
<div
|
|
847
|
+
style={{
|
|
848
|
+
background: "rgba(255,255,255,0.9)",
|
|
849
|
+
borderRadius: "4px",
|
|
850
|
+
padding: "2px 6px",
|
|
851
|
+
fontSize: "12px",
|
|
852
|
+
fontWeight: 700,
|
|
853
|
+
color: "#333",
|
|
854
|
+
whiteSpace: "nowrap",
|
|
855
|
+
position: "relative",
|
|
856
|
+
top: "-24px",
|
|
857
|
+
}}
|
|
858
|
+
>
|
|
859
|
+
{wp.label}
|
|
860
|
+
</div>
|
|
861
|
+
) : null}
|
|
862
|
+
</AdvancedMarker>
|
|
863
|
+
))}
|
|
864
|
+
</>
|
|
865
|
+
);
|
|
866
|
+
}
|
|
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
|
|
144
935
|
// ============================================================
|
|
145
936
|
function RouteWithMarker({
|
|
146
937
|
waypoints, travelMode, markerEmoji, actionDuration,
|
|
147
938
|
}: {
|
|
148
|
-
waypoints: { lat: number; lng: number; label?: string }[];
|
|
939
|
+
waypoints: { lat: number; lng: number; label?: string; media?: string }[];
|
|
149
940
|
travelMode: string;
|
|
150
941
|
markerEmoji: string;
|
|
151
942
|
actionDuration: number;
|
|
152
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
|
+
|
|
1113
|
+
// ============================================================
|
|
1114
|
+
// useRouteLeg — loads the Directions route (shared by RouteMap,
|
|
1115
|
+
// CinematicMap and CinematicMap3D). Uses the map instance from
|
|
1116
|
+
// the nearest <GoogleMap> / <Map3D> context.
|
|
1117
|
+
// ============================================================
|
|
1118
|
+
function useRouteLeg(
|
|
1119
|
+
waypoints: { lat: number; lng: number }[],
|
|
1120
|
+
travelMode: string,
|
|
1121
|
+
): google.maps.DirectionsLeg | null {
|
|
153
1122
|
const map = useMap();
|
|
154
1123
|
const routesLibrary = useMapsLibrary("routes");
|
|
155
1124
|
const [leg, setLeg] = React.useState<google.maps.DirectionsLeg | null>(null);
|
|
156
|
-
const [routeIndex, setRouteIndex] = React.useState(0);
|
|
157
1125
|
const handle = React.useRef<number | null>(null);
|
|
158
1126
|
|
|
159
1127
|
// Load directions
|
|
@@ -167,15 +1135,14 @@ function RouteWithMarker({
|
|
|
167
1135
|
|
|
168
1136
|
service
|
|
169
1137
|
.route({
|
|
170
|
-
origin: waypoints[0]
|
|
171
|
-
destination: waypoints[waypoints.length - 1]
|
|
1138
|
+
origin: waypoints[0]!,
|
|
1139
|
+
destination: waypoints[waypoints.length - 1]!,
|
|
172
1140
|
waypoints: waypoints.slice(1, -1).map((wp) => ({ location: wp, stopover: true })),
|
|
173
1141
|
travelMode: google.maps.TravelMode[travelMode as keyof typeof google.maps.TravelMode],
|
|
174
1142
|
provideRouteAlternatives: false,
|
|
175
1143
|
})
|
|
176
1144
|
.then((response) => {
|
|
177
1145
|
renderer.setDirections(response);
|
|
178
|
-
setRouteIndex(0);
|
|
179
1146
|
setLeg(response.routes[0]?.legs[0] ?? null);
|
|
180
1147
|
if (handle.current !== null) continueRender(handle.current);
|
|
181
1148
|
})
|
|
@@ -188,44 +1155,7 @@ function RouteWithMarker({
|
|
|
188
1155
|
};
|
|
189
1156
|
}, [routesLibrary, map, waypoints, travelMode]);
|
|
190
1157
|
|
|
191
|
-
|
|
192
|
-
React.useEffect(() => {
|
|
193
|
-
setRouteIndex((prev) => prev);
|
|
194
|
-
}, [routeIndex]);
|
|
195
|
-
|
|
196
|
-
// Compute animated marker position
|
|
197
|
-
const position = useAnimatedPosition({ leg, actionDuration, waypoints });
|
|
198
|
-
|
|
199
|
-
return (
|
|
200
|
-
<>
|
|
201
|
-
{waypoints.map((wp, i) => (
|
|
202
|
-
<AdvancedMarker key={i} position={wp}>
|
|
203
|
-
{wp.label ? (
|
|
204
|
-
<div
|
|
205
|
-
style={{
|
|
206
|
-
background: "rgba(255,255,255,0.9)",
|
|
207
|
-
borderRadius: "4px",
|
|
208
|
-
padding: "2px 6px",
|
|
209
|
-
fontSize: "12px",
|
|
210
|
-
fontWeight: 700,
|
|
211
|
-
color: "#333",
|
|
212
|
-
whiteSpace: "nowrap",
|
|
213
|
-
position: "relative",
|
|
214
|
-
top: "-24px",
|
|
215
|
-
}}
|
|
216
|
-
>
|
|
217
|
-
{wp.label}
|
|
218
|
-
</div>
|
|
219
|
-
) : null}
|
|
220
|
-
</AdvancedMarker>
|
|
221
|
-
))}
|
|
222
|
-
{position ? (
|
|
223
|
-
<AdvancedMarker position={position}>
|
|
224
|
-
<Pin glyphText={markerEmoji} scale={4} />
|
|
225
|
-
</AdvancedMarker>
|
|
226
|
-
) : null}
|
|
227
|
-
</>
|
|
228
|
-
);
|
|
1158
|
+
return leg;
|
|
229
1159
|
}
|
|
230
1160
|
|
|
231
1161
|
// ============================================================
|
|
@@ -242,51 +1172,69 @@ function useAnimatedPosition({
|
|
|
242
1172
|
const frame = useCurrentFrame();
|
|
243
1173
|
const { fps } = useVideoConfig();
|
|
244
1174
|
|
|
245
|
-
return React.useMemo(
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const total = waypoints.length - 1;
|
|
251
|
-
const segI = Math.min(Math.floor(t * total), total - 1);
|
|
252
|
-
const segT = (t * total) - segI;
|
|
253
|
-
const a = waypoints[segI];
|
|
254
|
-
const b = waypoints[segI + 1];
|
|
255
|
-
if (!a || !b) return null;
|
|
256
|
-
return {
|
|
257
|
-
lat: a.lat + (b.lat - a.lat) * segT,
|
|
258
|
-
lng: a.lng + (b.lng - a.lng) * segT,
|
|
259
|
-
};
|
|
260
|
-
}
|
|
1175
|
+
return React.useMemo(
|
|
1176
|
+
() => routePositionAt(leg, waypoints, actionDuration, frame / fps),
|
|
1177
|
+
[leg, frame, fps, actionDuration, waypoints],
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
261
1180
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
1181
|
+
// ============================================================
|
|
1182
|
+
// routePositionAt — pure position math shared by the marker and
|
|
1183
|
+
// the cinematic camera (deterministic per second)
|
|
1184
|
+
// ============================================================
|
|
1185
|
+
function routePositionAt(
|
|
1186
|
+
leg: google.maps.DirectionsLeg | null,
|
|
1187
|
+
waypoints: { lat: number; lng: number }[],
|
|
1188
|
+
actionDuration: number,
|
|
1189
|
+
seconds: number,
|
|
1190
|
+
): { lat: number; lng: number } | null {
|
|
1191
|
+
const linearFallback = (): { lat: number; lng: number } | null => {
|
|
1192
|
+
if (waypoints.length < 2) return null;
|
|
1193
|
+
const t = Math.min(seconds / actionDuration, 1);
|
|
1194
|
+
const total = waypoints.length - 1;
|
|
1195
|
+
const segI = Math.min(Math.floor(t * total), total - 1);
|
|
1196
|
+
const segT = t * total - segI;
|
|
1197
|
+
const a = waypoints[segI];
|
|
1198
|
+
const b = waypoints[segI + 1];
|
|
1199
|
+
if (!a || !b) return null;
|
|
1200
|
+
return { lat: a.lat + (b.lat - a.lat) * segT, lng: a.lng + (b.lng - a.lng) * segT };
|
|
1201
|
+
};
|
|
279
1202
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
return
|
|
289
|
-
}
|
|
1203
|
+
if (!leg || !leg.duration?.value) {
|
|
1204
|
+
return linearFallback();
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// Follow the route path using leg steps
|
|
1208
|
+
const currentInSecond = seconds * (leg.duration.value / actionDuration);
|
|
1209
|
+
const { step, elapsedInSeconds } = getCurrentStep(leg, currentInSecond);
|
|
1210
|
+
if (!step || !step.path) {
|
|
1211
|
+
return linearFallback();
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
const stepElapsed = currentInSecond - elapsedInSeconds;
|
|
1215
|
+
const stepProgress = stepElapsed / (step.duration?.value ?? 1);
|
|
1216
|
+
const pathIdx = Math.min(
|
|
1217
|
+
Math.max(0, Math.floor(stepProgress * step.path.length)),
|
|
1218
|
+
step.path.length - 1,
|
|
1219
|
+
);
|
|
1220
|
+
const pt = step.path[pathIdx];
|
|
1221
|
+
if (!pt) return null;
|
|
1222
|
+
return { lat: pt.lat(), lng: pt.lng() };
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// ============================================================
|
|
1226
|
+
// bearing — initial bearing (degrees clockwise from north) between
|
|
1227
|
+
// two lat/lng points. Haversine; no geometry library required.
|
|
1228
|
+
// ============================================================
|
|
1229
|
+
function bearing(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
|
|
1230
|
+
const toRad = (d: number) => (d * Math.PI) / 180;
|
|
1231
|
+
const toDeg = (d: number) => (d * 180) / Math.PI;
|
|
1232
|
+
const lat1 = toRad(a.lat);
|
|
1233
|
+
const lat2 = toRad(b.lat);
|
|
1234
|
+
const dLng = toRad(b.lng - a.lng);
|
|
1235
|
+
const y = Math.sin(dLng) * Math.cos(lat2);
|
|
1236
|
+
const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLng);
|
|
1237
|
+
return (toDeg(Math.atan2(y, x)) + 360) % 360;
|
|
290
1238
|
}
|
|
291
1239
|
|
|
292
1240
|
// ============================================================
|