@lalalic/markcut 2.9.0 → 3.1.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 +1 -1
- package/skills/markcut/SKILL.md +7 -0
- package/skills/markcut/docs/components.md +45 -2
- package/skills/markcut/docs/map-dynamic-camera.md +244 -0
- package/skills/markcut/docs/markdown-descriptive.md +7 -2
- package/src/components/Markdown.tsx +138 -24
- package/src/components/Mermaid.tsx +223 -22
- package/src/context/EventContext.tsx +3 -0
- package/src/descriptive/compiler.ts +105 -29
- package/src/descriptive/dsl.ts +42 -5
- package/src/descriptive/markdown.ts +23 -0
- package/src/descriptive/resolve.test.ts +5 -5
- package/src/descriptive/resolve.ts +51 -12
- package/src/player/bundle/player.js +751 -143
- package/src/player/pipeline.mjs +130 -32
- 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 +58 -2
- package/src/spots/cli.mjs +266 -0
- package/src/types/Component.tsx +27 -1
- package/src/types/Effect.tsx +13 -6
- package/src/types/Folder.tsx +1 -1
- package/src/types/Map.tsx +501 -127
- package/src/utils/index.ts +14 -2
- package/src/utils/tween.ts +49 -1
- package/tests/dsl.test.ts +43 -0
- package/tests/fixtures/map-dynamic.json +52 -0
- package/tests/fixtures/md/animate-diagrams.md +42 -0
- package/tests/fixtures/md/electricity-grow.md +130 -0
- package/tests/fixtures/md/map-all-views.md +28 -0
- package/tests/md-descriptive.test.ts +58 -0
- package/tests/render.test.ts +1 -0
- package/tests/schema.test.ts +58 -1
- package/tests/validate-assets.test.ts +106 -0
package/src/types/Map.tsx
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
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
|
*/
|
|
@@ -22,9 +32,11 @@ import React from "react";
|
|
|
22
32
|
import { Sequence, 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";
|
|
38
|
+
import type { Map3DRef } from "@vis.gl/react-google-maps";
|
|
39
|
+
import { resolveTween } from "../utils/tween";
|
|
28
40
|
import type { MapStream } from "../schema/index";
|
|
29
41
|
|
|
30
42
|
// API key is injected by the compiler onto the stream node (see compileLeaf in compiler.ts).
|
|
@@ -49,7 +61,7 @@ function resolveMapLocale(language?: string, region?: string): { language?: stri
|
|
|
49
61
|
}
|
|
50
62
|
|
|
51
63
|
// ============================================================
|
|
52
|
-
// MapLeaf — entry point,
|
|
64
|
+
// MapLeaf — entry point, dispatches to the view renderer
|
|
53
65
|
// ============================================================
|
|
54
66
|
export function MapLeaf({ stream }: { stream: MapStream }) {
|
|
55
67
|
const { fps } = useVideoConfig();
|
|
@@ -58,15 +70,12 @@ export function MapLeaf({ stream }: { stream: MapStream }) {
|
|
|
58
70
|
const end = stream.end ?? start + (stream.duration ?? 1);
|
|
59
71
|
const totalDur = stream.durationInSeconds ?? end;
|
|
60
72
|
const apiKey = resolveApiKey(stream);
|
|
73
|
+
const view = stream.view ?? "route";
|
|
61
74
|
useFrameEvents(stream.on, Math.max(1, Math.floor(totalDur * fps)));
|
|
62
|
-
|
|
75
|
+
// route/cinematic animate along waypoints; overview/streetview are standalone views.
|
|
76
|
+
if ((view === "route" || view === "cinematic") && waypoints.length === 0) return null;
|
|
63
77
|
|
|
64
78
|
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
79
|
const mapLocale = React.useMemo(
|
|
71
80
|
() => resolveMapLocale(stream.language, stream.region),
|
|
72
81
|
[stream.language, stream.region],
|
|
@@ -75,10 +84,10 @@ export function MapLeaf({ stream }: { stream: MapStream }) {
|
|
|
75
84
|
const mapLoadContinuedRef = React.useRef(false);
|
|
76
85
|
|
|
77
86
|
React.useEffect(() => {
|
|
78
|
-
mapLoadHandleRef.current = delayRender("Waiting for map
|
|
87
|
+
mapLoadHandleRef.current = delayRender("Waiting for map to load...");
|
|
79
88
|
mapLoadContinuedRef.current = false;
|
|
80
89
|
|
|
81
|
-
// Avoid hanging indefinitely when
|
|
90
|
+
// Avoid hanging indefinitely when tiles/pano/3D fail to load.
|
|
82
91
|
const fallbackTimer = window.setTimeout(() => {
|
|
83
92
|
if (!mapLoadContinuedRef.current && mapLoadHandleRef.current !== null) {
|
|
84
93
|
continueRender(mapLoadHandleRef.current);
|
|
@@ -96,13 +105,20 @@ export function MapLeaf({ stream }: { stream: MapStream }) {
|
|
|
96
105
|
};
|
|
97
106
|
}, [stream.id, start, end]);
|
|
98
107
|
|
|
99
|
-
const
|
|
108
|
+
const handleMapReady = React.useCallback(() => {
|
|
100
109
|
if (!mapLoadContinuedRef.current && mapLoadHandleRef.current !== null) {
|
|
101
110
|
continueRender(mapLoadHandleRef.current);
|
|
102
111
|
mapLoadContinuedRef.current = true;
|
|
103
112
|
}
|
|
104
113
|
}, []);
|
|
105
114
|
|
|
115
|
+
// Experimental Map3D is opt-in via `cinematic.fallback:"none"` (requires the
|
|
116
|
+
// Google Maps 3D preview API). Default renders the safe 2D chase camera.
|
|
117
|
+
const use3d =
|
|
118
|
+
view === "cinematic" &&
|
|
119
|
+
stream.cinematic?.fallback === "none" &&
|
|
120
|
+
(stream.cinematic.mode === "flyTo" || stream.cinematic.mode === "orbit");
|
|
121
|
+
|
|
106
122
|
return (
|
|
107
123
|
<Sequence
|
|
108
124
|
durationInFrames={durFrames}
|
|
@@ -114,84 +130,356 @@ export function MapLeaf({ stream }: { stream: MapStream }) {
|
|
|
114
130
|
language={mapLocale.language}
|
|
115
131
|
region={mapLocale.region}
|
|
116
132
|
>
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
disableDefaultUI: true,
|
|
124
|
-
zoomControl: false,
|
|
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>
|
|
133
|
+
{view === "overview" && <OverviewMap stream={stream} onTilesLoaded={handleMapReady} />}
|
|
134
|
+
{view === "cinematic" && (use3d
|
|
135
|
+
? <CinematicMap3D stream={stream} onReady={handleMapReady} />
|
|
136
|
+
: <CinematicMap stream={stream} onTilesLoaded={handleMapReady} />)}
|
|
137
|
+
{view === "streetview" && <StreetViewLeaf stream={stream} onPanoReady={handleMapReady} />}
|
|
138
|
+
{view === "route" && <RouteMap stream={stream} onTilesLoaded={handleMapReady} />}
|
|
136
139
|
</APIProvider>
|
|
137
140
|
</Sequence>
|
|
138
141
|
);
|
|
139
142
|
}
|
|
140
143
|
|
|
141
144
|
// ============================================================
|
|
142
|
-
//
|
|
143
|
-
// renders an animated marker that follows the route path
|
|
145
|
+
// OverviewMap — static or dolly camera (far → near) over the map
|
|
144
146
|
// ============================================================
|
|
145
|
-
function
|
|
146
|
-
|
|
147
|
-
}: {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
147
|
+
function OverviewMap({
|
|
148
|
+
stream, onTilesLoaded,
|
|
149
|
+
}: { stream: MapStream; onTilesLoaded: () => void }) {
|
|
150
|
+
const { fps } = useVideoConfig();
|
|
151
|
+
const frame = useCurrentFrame();
|
|
152
|
+
const start = stream.start ?? 0;
|
|
153
|
+
const end = stream.end ?? start + (stream.duration ?? 1);
|
|
154
|
+
const fallbackCenter = stream.center ?? { lat: 37.7749, lng: -122.4194 };
|
|
155
|
+
const center = {
|
|
156
|
+
lat: resolveTween(frame, fps, stream.camera?.center?.lat, start, end, fallbackCenter.lat),
|
|
157
|
+
lng: resolveTween(frame, fps, stream.camera?.center?.lng, start, end, fallbackCenter.lng),
|
|
158
|
+
};
|
|
159
|
+
const zoom = resolveTween(frame, fps, stream.camera?.zoom, start, end, stream.zoom ?? 10);
|
|
160
|
+
const heading = resolveTween(frame, fps, stream.camera?.heading, start, end, 0);
|
|
161
|
+
const tilt = resolveTween(frame, fps, stream.camera?.tilt, start, end, 0);
|
|
162
|
+
return (
|
|
163
|
+
<GoogleMap
|
|
164
|
+
mapId={String(stream.id ?? "map-overview")}
|
|
165
|
+
center={center}
|
|
166
|
+
zoom={zoom}
|
|
167
|
+
heading={heading}
|
|
168
|
+
tilt={tilt}
|
|
169
|
+
mapTypeId={stream.mapType ?? "roadmap"}
|
|
170
|
+
disableDefaultUI
|
|
171
|
+
zoomControl={false}
|
|
172
|
+
onTilesLoaded={onTilesLoaded}
|
|
173
|
+
style={{ width: "100%", height: "100%", position: "absolute" }}
|
|
174
|
+
/>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ============================================================
|
|
179
|
+
// RouteMap — classic animated route (view:"route", the default)
|
|
180
|
+
// ============================================================
|
|
181
|
+
function RouteMap({
|
|
182
|
+
stream, onTilesLoaded,
|
|
183
|
+
}: { stream: MapStream; onTilesLoaded: () => void }) {
|
|
184
|
+
const { fps } = useVideoConfig();
|
|
185
|
+
const frame = useCurrentFrame();
|
|
186
|
+
const waypoints = stream.waypoints ?? [];
|
|
187
|
+
const start = stream.start ?? 0;
|
|
188
|
+
const end = stream.end ?? start + (stream.duration ?? 1);
|
|
189
|
+
const fallbackCenter = stream.center ?? { lat: waypoints[0]!.lat, lng: waypoints[0]!.lng };
|
|
190
|
+
const center = {
|
|
191
|
+
lat: resolveTween(frame, fps, stream.camera?.center?.lat, start, end, fallbackCenter.lat),
|
|
192
|
+
lng: resolveTween(frame, fps, stream.camera?.center?.lng, start, end, fallbackCenter.lng),
|
|
193
|
+
};
|
|
194
|
+
const zoom = resolveTween(frame, fps, stream.camera?.zoom, start, end, stream.zoom ?? 10);
|
|
195
|
+
return (
|
|
196
|
+
<GoogleMap
|
|
197
|
+
mapId={String(stream.id ?? "map-route")}
|
|
198
|
+
center={center}
|
|
199
|
+
zoom={zoom}
|
|
200
|
+
mapTypeId={stream.mapType ?? "roadmap"}
|
|
201
|
+
disableDefaultUI
|
|
202
|
+
zoomControl={false}
|
|
203
|
+
onTilesLoaded={onTilesLoaded}
|
|
204
|
+
style={{ width: "100%", height: "100%", position: "absolute" }}
|
|
205
|
+
>
|
|
206
|
+
<RouteWithMarker
|
|
207
|
+
waypoints={waypoints}
|
|
208
|
+
travelMode={stream.travelMode ?? "DRIVING"}
|
|
209
|
+
markerEmoji={stream.routeMarker ?? "🚗"}
|
|
210
|
+
actionDuration={end - start}
|
|
211
|
+
/>
|
|
212
|
+
</GoogleMap>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ============================================================
|
|
217
|
+
// CinematicMap — 2D movie flyover (view:"cinematic")
|
|
218
|
+
//
|
|
219
|
+
// flyAlong (default): drone chase — center follows the marker,
|
|
220
|
+
// heading = route bearing, tilt 45°
|
|
221
|
+
// flyTo: dolly from first waypoint toward the destination
|
|
222
|
+
// orbit: rotate heading around a fixed center
|
|
223
|
+
// ============================================================
|
|
224
|
+
function CinematicMap({
|
|
225
|
+
stream, onTilesLoaded,
|
|
226
|
+
}: { stream: MapStream; onTilesLoaded: () => void }) {
|
|
227
|
+
const { fps } = useVideoConfig();
|
|
228
|
+
const frame = useCurrentFrame();
|
|
229
|
+
const waypoints = stream.waypoints ?? [];
|
|
230
|
+
const start = stream.start ?? 0;
|
|
231
|
+
const end = stream.end ?? start + (stream.duration ?? 1);
|
|
232
|
+
const actionDuration = Math.max(0.1, end - start);
|
|
233
|
+
const mode = stream.cinematic?.mode ?? "flyAlong";
|
|
234
|
+
const headingFollow = stream.cinematic?.headingFollow ?? true;
|
|
235
|
+
const leg = useRouteLeg(waypoints, stream.travelMode ?? "DRIVING");
|
|
236
|
+
|
|
237
|
+
const seconds = frame / fps;
|
|
238
|
+
const pos = routePositionAt(leg, waypoints, actionDuration, seconds);
|
|
239
|
+
const lookahead = routePositionAt(leg, waypoints, actionDuration, seconds + 0.4);
|
|
240
|
+
const first = waypoints[0]!;
|
|
241
|
+
const fallbackCenter = stream.center ?? first;
|
|
242
|
+
|
|
243
|
+
let center: { lat: number; lng: number };
|
|
244
|
+
if (mode === "flyAlong" && pos) {
|
|
245
|
+
center = pos;
|
|
246
|
+
} else if (mode === "flyTo") {
|
|
247
|
+
center = {
|
|
248
|
+
lat: resolveTween(frame, fps, stream.camera?.center?.lat, start, end, first.lat),
|
|
249
|
+
lng: resolveTween(frame, fps, stream.camera?.center?.lng, start, end, first.lng),
|
|
250
|
+
};
|
|
251
|
+
} else {
|
|
252
|
+
center = fallbackCenter;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let heading = resolveTween(frame, fps, stream.camera?.heading, start, end, 0);
|
|
256
|
+
if (headingFollow && pos && lookahead) {
|
|
257
|
+
heading = bearing(pos, lookahead);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const tilt = resolveTween(frame, fps, stream.cinematic?.tilt, start, end, 45);
|
|
261
|
+
const zoom = resolveTween(frame, fps, stream.camera?.zoom, start, end, stream.zoom ?? 13);
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<GoogleMap
|
|
265
|
+
mapId={String(stream.id ?? "map-cinematic")}
|
|
266
|
+
center={center}
|
|
267
|
+
zoom={zoom}
|
|
268
|
+
heading={heading}
|
|
269
|
+
tilt={tilt}
|
|
270
|
+
mapTypeId={stream.mapType ?? "roadmap"}
|
|
271
|
+
disableDefaultUI
|
|
272
|
+
zoomControl={false}
|
|
273
|
+
onTilesLoaded={onTilesLoaded}
|
|
274
|
+
style={{ width: "100%", height: "100%", position: "absolute" }}
|
|
275
|
+
>
|
|
276
|
+
<RouteWithMarker
|
|
277
|
+
waypoints={waypoints}
|
|
278
|
+
travelMode={stream.travelMode ?? "DRIVING"}
|
|
279
|
+
markerEmoji={stream.routeMarker ?? "🚗"}
|
|
280
|
+
actionDuration={actionDuration}
|
|
281
|
+
/>
|
|
282
|
+
</GoogleMap>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ============================================================
|
|
287
|
+
// CinematicMap3D — experimental Map3D flyover (opt-in via fallback:"none")
|
|
288
|
+
//
|
|
289
|
+
// flyTo: controlled `range` tween (far → near) over the route
|
|
290
|
+
// orbit: controlled heading/roll tween around a fixed center
|
|
291
|
+
// Route drawn with a raw <gmp-polyline-3d> element; markers via <Marker3D>.
|
|
292
|
+
// ============================================================
|
|
293
|
+
function CinematicMap3D({
|
|
294
|
+
stream, onReady,
|
|
295
|
+
}: { stream: MapStream; onReady: () => void }) {
|
|
296
|
+
const { fps } = useVideoConfig();
|
|
297
|
+
const frame = useCurrentFrame();
|
|
298
|
+
const waypoints = stream.waypoints ?? [];
|
|
299
|
+
const start = stream.start ?? 0;
|
|
300
|
+
const end = stream.end ?? start + (stream.duration ?? 1);
|
|
301
|
+
const actionDuration = Math.max(0.1, end - start);
|
|
302
|
+
const first = waypoints[0] ?? { lat: 37.7749, lng: -122.4194 };
|
|
303
|
+
const map3dRef = React.useRef<Map3DRef>(null);
|
|
304
|
+
const map3d = useMap3D();
|
|
305
|
+
const leg = useRouteLeg(waypoints, stream.travelMode ?? "DRIVING");
|
|
306
|
+
|
|
307
|
+
const range = resolveTween(frame, fps, stream.cinematic?.range, start, end, 2000);
|
|
308
|
+
const tilt = resolveTween(frame, fps, stream.cinematic?.tilt, start, end, 60);
|
|
309
|
+
const roll = resolveTween(frame, fps, stream.cinematic?.roll, start, end, 0);
|
|
310
|
+
const heading = resolveTween(frame, fps, stream.camera?.heading, start, end, 0);
|
|
311
|
+
const center = {
|
|
312
|
+
lat: resolveTween(frame, fps, stream.camera?.center?.lat, start, end, first.lat),
|
|
313
|
+
lng: resolveTween(frame, fps, stream.camera?.center?.lng, start, end, first.lng),
|
|
314
|
+
altitude: stream.cinematic?.altitude ?? 100,
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const seconds = frame / fps;
|
|
318
|
+
const pos = routePositionAt(leg, waypoints, actionDuration, seconds);
|
|
319
|
+
|
|
320
|
+
// Route line via the raw gmp-polyline-3d custom element (no wrapper in 1.8.x).
|
|
321
|
+
const legPath = React.useMemo(() => {
|
|
322
|
+
if (!leg) return null;
|
|
323
|
+
const pts: Array<{ lat: number; lng: number; altitude: number }> = [];
|
|
324
|
+
for (const step of leg.steps ?? []) {
|
|
325
|
+
for (const p of step.path ?? []) pts.push({ lat: p.lat(), lng: p.lng(), altitude: 0 });
|
|
326
|
+
}
|
|
327
|
+
return pts.length ? pts : null;
|
|
328
|
+
}, [leg]);
|
|
158
329
|
|
|
159
|
-
// Load directions
|
|
160
330
|
React.useEffect(() => {
|
|
161
|
-
if (!
|
|
162
|
-
const
|
|
163
|
-
|
|
331
|
+
if (!map3d || !legPath) return;
|
|
332
|
+
const el = document.createElement("gmp-polyline-3d") as unknown as google.maps.maps3d.Polyline3DElement;
|
|
333
|
+
el.coordinates = legPath;
|
|
334
|
+
el.strokeColor = stream.routeColor ?? "#4285F4";
|
|
335
|
+
el.strokeWidth = stream.routeWeight ?? 4;
|
|
336
|
+
map3d.appendChild(el);
|
|
337
|
+
return () => {
|
|
338
|
+
el.remove();
|
|
339
|
+
};
|
|
340
|
+
}, [map3d, legPath, stream.routeColor, stream.routeWeight]);
|
|
164
341
|
|
|
165
|
-
|
|
166
|
-
|
|
342
|
+
return (
|
|
343
|
+
<Map3D
|
|
344
|
+
ref={map3dRef}
|
|
345
|
+
mode={stream.mapType === "hybrid" ? "HYBRID" : "SATELLITE"}
|
|
346
|
+
center={center}
|
|
347
|
+
range={range}
|
|
348
|
+
heading={heading}
|
|
349
|
+
tilt={tilt}
|
|
350
|
+
roll={roll}
|
|
351
|
+
onSteadyChange={onReady}
|
|
352
|
+
onAnimationEnd={onReady}
|
|
353
|
+
style={{ width: "100%", height: "100%", position: "absolute" }}
|
|
354
|
+
>
|
|
355
|
+
{pos ? (
|
|
356
|
+
<Marker3D position={{ lat: pos.lat, lng: pos.lng, altitude: 0 }} />
|
|
357
|
+
) : null}
|
|
358
|
+
</Map3D>
|
|
359
|
+
);
|
|
360
|
+
}
|
|
167
361
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
.catch(() => {
|
|
183
|
-
if (handle.current !== null) continueRender(handle.current);
|
|
184
|
-
});
|
|
362
|
+
// ============================================================
|
|
363
|
+
// StreetViewLeaf — immersive StreetViewPanorama with animated POV/position
|
|
364
|
+
// ============================================================
|
|
365
|
+
function StreetViewLeaf({
|
|
366
|
+
stream, onPanoReady,
|
|
367
|
+
}: { stream: MapStream; onPanoReady: () => void }) {
|
|
368
|
+
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
369
|
+
const svLibrary = useMapsLibrary("streetView");
|
|
370
|
+
const [pano, setPano] = React.useState<google.maps.StreetViewPanorama | null>(null);
|
|
371
|
+
const { fps } = useVideoConfig();
|
|
372
|
+
const frame = useCurrentFrame();
|
|
373
|
+
const start = stream.start ?? 0;
|
|
374
|
+
const end = stream.end ?? start + (stream.duration ?? 1);
|
|
375
|
+
const sv = stream.streetView;
|
|
185
376
|
|
|
377
|
+
// Create the panorama once (no React wrapper component in this library version).
|
|
378
|
+
React.useEffect(() => {
|
|
379
|
+
if (!svLibrary || !containerRef.current) return;
|
|
380
|
+
const pan = new svLibrary.StreetViewPanorama(containerRef.current, {
|
|
381
|
+
disableDefaultUI: true,
|
|
382
|
+
});
|
|
383
|
+
if (sv?.pano) {
|
|
384
|
+
pan.setPano(sv.pano);
|
|
385
|
+
} else if (sv?.location) {
|
|
386
|
+
pan.setPosition(sv.location);
|
|
387
|
+
} else if (sv?.route?.length) {
|
|
388
|
+
pan.setPosition(sv.route[0]!);
|
|
389
|
+
}
|
|
390
|
+
if (sv?.pov) {
|
|
391
|
+
pan.setPov({
|
|
392
|
+
heading: typeof sv.pov.heading === "number" ? sv.pov.heading : 0,
|
|
393
|
+
pitch: typeof sv.pov.pitch === "number" ? sv.pov.pitch : 0,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
if (typeof sv?.zoom === "number") {
|
|
397
|
+
pan.setZoom(sv.zoom);
|
|
398
|
+
}
|
|
399
|
+
setPano(pan);
|
|
186
400
|
return () => {
|
|
187
|
-
|
|
401
|
+
pan.setVisible(false);
|
|
188
402
|
};
|
|
189
|
-
}, [
|
|
403
|
+
}, [svLibrary, stream.id, sv?.pano, sv?.location, sv?.route, sv?.pov, sv?.zoom]);
|
|
190
404
|
|
|
191
|
-
//
|
|
405
|
+
// Per-frame POV + walk/drive position, with per-frame tile-load gating.
|
|
406
|
+
//
|
|
407
|
+
// Street View imagery loads asynchronously after setPosition/pano_changed, so
|
|
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.
|
|
192
412
|
React.useEffect(() => {
|
|
193
|
-
|
|
194
|
-
|
|
413
|
+
if (!pano) return;
|
|
414
|
+
const pov = pano.getPov();
|
|
415
|
+
const heading = resolveTween(frame, fps, sv?.pov?.heading, start, end, pov.heading);
|
|
416
|
+
const pitch = resolveTween(frame, fps, sv?.pov?.pitch, start, end, pov.pitch);
|
|
417
|
+
pano.setPov({ heading, pitch });
|
|
418
|
+
const zoom = resolveTween(frame, fps, sv?.zoom, start, end, pano.getZoom() ?? 0);
|
|
419
|
+
pano.setZoom(zoom);
|
|
420
|
+
|
|
421
|
+
let movedPosition = false;
|
|
422
|
+
if (sv?.route && sv.route.length > 1) {
|
|
423
|
+
const t = Math.min(Math.max((frame / fps) / Math.max(0.1, end - start), 0), 1);
|
|
424
|
+
const total = sv.route.length - 1;
|
|
425
|
+
const segI = Math.min(Math.floor(t * total), total - 1);
|
|
426
|
+
const segT = t * total - segI;
|
|
427
|
+
const a = sv.route[segI]!;
|
|
428
|
+
const b = sv.route[segI + 1]!;
|
|
429
|
+
pano.setPosition({
|
|
430
|
+
lat: a.lat + (b.lat - a.lat) * segT,
|
|
431
|
+
lng: a.lng + (b.lng - a.lng) * segT,
|
|
432
|
+
});
|
|
433
|
+
movedPosition = true;
|
|
434
|
+
}
|
|
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]);
|
|
460
|
+
|
|
461
|
+
return (
|
|
462
|
+
<div
|
|
463
|
+
ref={containerRef}
|
|
464
|
+
style={{ width: "100%", height: "100%", position: "absolute" }}
|
|
465
|
+
/>
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ============================================================
|
|
470
|
+
// RouteWithMarker — fetches the Directions route, renders the
|
|
471
|
+
// route line + waypoint markers (label or media thumbnail) +
|
|
472
|
+
// the animated traveling marker
|
|
473
|
+
// ============================================================
|
|
474
|
+
function RouteWithMarker({
|
|
475
|
+
waypoints, travelMode, markerEmoji, actionDuration,
|
|
476
|
+
}: {
|
|
477
|
+
waypoints: { lat: number; lng: number; label?: string; media?: string }[];
|
|
478
|
+
travelMode: string;
|
|
479
|
+
markerEmoji: string;
|
|
480
|
+
actionDuration: number;
|
|
481
|
+
}) {
|
|
482
|
+
const leg = useRouteLeg(waypoints, travelMode);
|
|
195
483
|
|
|
196
484
|
// Compute animated marker position
|
|
197
485
|
const position = useAnimatedPosition({ leg, actionDuration, waypoints });
|
|
@@ -200,7 +488,27 @@ function RouteWithMarker({
|
|
|
200
488
|
<>
|
|
201
489
|
{waypoints.map((wp, i) => (
|
|
202
490
|
<AdvancedMarker key={i} position={wp}>
|
|
203
|
-
{wp.
|
|
491
|
+
{wp.media ? (
|
|
492
|
+
<div
|
|
493
|
+
style={{
|
|
494
|
+
width: 44,
|
|
495
|
+
height: 44,
|
|
496
|
+
borderRadius: 6,
|
|
497
|
+
overflow: "hidden",
|
|
498
|
+
border: "2px solid #fff",
|
|
499
|
+
boxShadow: "0 1px 4px rgba(0,0,0,0.45)",
|
|
500
|
+
background: "#fff",
|
|
501
|
+
position: "relative",
|
|
502
|
+
top: "-24px",
|
|
503
|
+
}}
|
|
504
|
+
>
|
|
505
|
+
<img
|
|
506
|
+
src={wp.media}
|
|
507
|
+
alt=""
|
|
508
|
+
style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
|
|
509
|
+
/>
|
|
510
|
+
</div>
|
|
511
|
+
) : wp.label ? (
|
|
204
512
|
<div
|
|
205
513
|
style={{
|
|
206
514
|
background: "rgba(255,255,255,0.9)",
|
|
@@ -228,6 +536,54 @@ function RouteWithMarker({
|
|
|
228
536
|
);
|
|
229
537
|
}
|
|
230
538
|
|
|
539
|
+
// ============================================================
|
|
540
|
+
// useRouteLeg — loads the Directions route (shared by RouteMap,
|
|
541
|
+
// CinematicMap and CinematicMap3D). Uses the map instance from
|
|
542
|
+
// the nearest <GoogleMap> / <Map3D> context.
|
|
543
|
+
// ============================================================
|
|
544
|
+
function useRouteLeg(
|
|
545
|
+
waypoints: { lat: number; lng: number }[],
|
|
546
|
+
travelMode: string,
|
|
547
|
+
): google.maps.DirectionsLeg | null {
|
|
548
|
+
const map = useMap();
|
|
549
|
+
const routesLibrary = useMapsLibrary("routes");
|
|
550
|
+
const [leg, setLeg] = React.useState<google.maps.DirectionsLeg | null>(null);
|
|
551
|
+
const handle = React.useRef<number | null>(null);
|
|
552
|
+
|
|
553
|
+
// Load directions
|
|
554
|
+
React.useEffect(() => {
|
|
555
|
+
if (!routesLibrary || !map || waypoints.length < 2) return;
|
|
556
|
+
const renderHandle = delayRender("Loading map directions...");
|
|
557
|
+
handle.current = renderHandle;
|
|
558
|
+
|
|
559
|
+
const renderer = new routesLibrary.DirectionsRenderer({ map, suppressMarkers: true });
|
|
560
|
+
const service = new routesLibrary.DirectionsService();
|
|
561
|
+
|
|
562
|
+
service
|
|
563
|
+
.route({
|
|
564
|
+
origin: waypoints[0]!,
|
|
565
|
+
destination: waypoints[waypoints.length - 1]!,
|
|
566
|
+
waypoints: waypoints.slice(1, -1).map((wp) => ({ location: wp, stopover: true })),
|
|
567
|
+
travelMode: google.maps.TravelMode[travelMode as keyof typeof google.maps.TravelMode],
|
|
568
|
+
provideRouteAlternatives: false,
|
|
569
|
+
})
|
|
570
|
+
.then((response) => {
|
|
571
|
+
renderer.setDirections(response);
|
|
572
|
+
setLeg(response.routes[0]?.legs[0] ?? null);
|
|
573
|
+
if (handle.current !== null) continueRender(handle.current);
|
|
574
|
+
})
|
|
575
|
+
.catch(() => {
|
|
576
|
+
if (handle.current !== null) continueRender(handle.current);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
return () => {
|
|
580
|
+
renderer.setMap(null);
|
|
581
|
+
};
|
|
582
|
+
}, [routesLibrary, map, waypoints, travelMode]);
|
|
583
|
+
|
|
584
|
+
return leg;
|
|
585
|
+
}
|
|
586
|
+
|
|
231
587
|
// ============================================================
|
|
232
588
|
// useAnimatedPosition — returns the current lat/lng of the
|
|
233
589
|
// animated marker along the route path
|
|
@@ -242,51 +598,69 @@ function useAnimatedPosition({
|
|
|
242
598
|
const frame = useCurrentFrame();
|
|
243
599
|
const { fps } = useVideoConfig();
|
|
244
600
|
|
|
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
|
-
}
|
|
601
|
+
return React.useMemo(
|
|
602
|
+
() => routePositionAt(leg, waypoints, actionDuration, frame / fps),
|
|
603
|
+
[leg, frame, fps, actionDuration, waypoints],
|
|
604
|
+
);
|
|
605
|
+
}
|
|
261
606
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
607
|
+
// ============================================================
|
|
608
|
+
// routePositionAt — pure position math shared by the marker and
|
|
609
|
+
// the cinematic camera (deterministic per second)
|
|
610
|
+
// ============================================================
|
|
611
|
+
function routePositionAt(
|
|
612
|
+
leg: google.maps.DirectionsLeg | null,
|
|
613
|
+
waypoints: { lat: number; lng: number }[],
|
|
614
|
+
actionDuration: number,
|
|
615
|
+
seconds: number,
|
|
616
|
+
): { lat: number; lng: number } | null {
|
|
617
|
+
const linearFallback = (): { lat: number; lng: number } | null => {
|
|
618
|
+
if (waypoints.length < 2) return null;
|
|
619
|
+
const t = Math.min(seconds / actionDuration, 1);
|
|
620
|
+
const total = waypoints.length - 1;
|
|
621
|
+
const segI = Math.min(Math.floor(t * total), total - 1);
|
|
622
|
+
const segT = t * total - segI;
|
|
623
|
+
const a = waypoints[segI];
|
|
624
|
+
const b = waypoints[segI + 1];
|
|
625
|
+
if (!a || !b) return null;
|
|
626
|
+
return { lat: a.lat + (b.lat - a.lat) * segT, lng: a.lng + (b.lng - a.lng) * segT };
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
if (!leg || !leg.duration?.value) {
|
|
630
|
+
return linearFallback();
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Follow the route path using leg steps
|
|
634
|
+
const currentInSecond = seconds * (leg.duration.value / actionDuration);
|
|
635
|
+
const { step, elapsedInSeconds } = getCurrentStep(leg, currentInSecond);
|
|
636
|
+
if (!step || !step.path) {
|
|
637
|
+
return linearFallback();
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const stepElapsed = currentInSecond - elapsedInSeconds;
|
|
641
|
+
const stepProgress = stepElapsed / (step.duration?.value ?? 1);
|
|
642
|
+
const pathIdx = Math.min(
|
|
643
|
+
Math.max(0, Math.floor(stepProgress * step.path.length)),
|
|
644
|
+
step.path.length - 1,
|
|
645
|
+
);
|
|
646
|
+
const pt = step.path[pathIdx];
|
|
647
|
+
if (!pt) return null;
|
|
648
|
+
return { lat: pt.lat(), lng: pt.lng() };
|
|
649
|
+
}
|
|
279
650
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
651
|
+
// ============================================================
|
|
652
|
+
// bearing — initial bearing (degrees clockwise from north) between
|
|
653
|
+
// two lat/lng points. Haversine; no geometry library required.
|
|
654
|
+
// ============================================================
|
|
655
|
+
function bearing(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
|
|
656
|
+
const toRad = (d: number) => (d * Math.PI) / 180;
|
|
657
|
+
const toDeg = (d: number) => (d * 180) / Math.PI;
|
|
658
|
+
const lat1 = toRad(a.lat);
|
|
659
|
+
const lat2 = toRad(b.lat);
|
|
660
|
+
const dLng = toRad(b.lng - a.lng);
|
|
661
|
+
const y = Math.sin(dLng) * Math.cos(lat2);
|
|
662
|
+
const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLng);
|
|
663
|
+
return (toDeg(Math.atan2(y, x)) + 360) % 360;
|
|
290
664
|
}
|
|
291
665
|
|
|
292
666
|
// ============================================================
|