Skip to content

Commit 372e53a

Browse files
igorDykhtaIhor Dykhta
andauthored
feat: swipe view mode support in video export (#3503)
* feat: swipe view mode support in video export Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local> * fixes for regular view; fixes for swipe mode video record Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local> * fixes Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local> * fix tests Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local> * stabilizy hubble lazzy loading Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local> * follow up fixes Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local> * follow up fixes Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local> * fix lint Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local> * follow up Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local> --------- Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local> Co-authored-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>
1 parent d384cf5 commit 372e53a

8 files changed

Lines changed: 1661 additions & 27 deletions

File tree

src/components/src/modals/export-video-modal.tsx

Lines changed: 74 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
55
import styled, {ThemeProvider, useTheme} from 'styled-components';
66

7-
import {DEFAULT_MAPBOX_API_URL, NO_MAP_ID, EMPTY_MAPBOX_STYLE} from '@kepler.gl/constants';
7+
import {DEFAULT_MAPBOX_API_URL, NO_MAP_ID, EMPTY_MAPBOX_STYLE, MapSplitMode} from '@kepler.gl/constants';
88
import {FormattedMessage} from '@kepler.gl/localization';
99
import {Viewport, ExportVideo, Effect} from '@kepler.gl/types';
1010
import {
@@ -31,24 +31,32 @@ import {
3131
} from './hubble-utils';
3232
import {useFogHeightAnimation} from './fog-height-animation';
3333

34+
type SwipeContainerType = React.ComponentType<any>;
35+
3436
type HubbleModule = {
3537
ExportVideoPanelContainer: React.ComponentType<any>;
3638
KeplerUIContext: React.Context<any>;
3739
};
3840

3941
let _hubbleModule: HubbleModule | null = null;
4042
let _hubblePromise: Promise<HubbleModule> | null = null;
43+
let _swipeContainer: SwipeContainerType | null = null;
4144

4245
function loadHubble(): Promise<HubbleModule> {
4346
if (_hubbleModule) return Promise.resolve(_hubbleModule);
4447
if (_hubblePromise) return _hubblePromise;
4548
_hubblePromise = import('@hubble.gl/react').then(
46-
mod => {
49+
async mod => {
4750
_hubbleModule = mod as unknown as HubbleModule;
51+
try {
52+
const swipeMod = await import('./swipe-export-video-container');
53+
_swipeContainer = swipeMod.SwipeExportVideoPanelContainer;
54+
} catch (_err) {
55+
// swipe export unavailable; regular export still works
56+
}
4857
return _hubbleModule;
4958
},
5059
err => {
51-
// Allow retry on next call
5260
_hubblePromise = null;
5361
throw err;
5462
}
@@ -184,6 +192,9 @@ export type VideoConfiguration = {
184192
fileName?: string;
185193
resolution?: string;
186194
durationMs?: number;
195+
swipeStartPct?: number;
196+
swipeEndPct?: number;
197+
swipeEasing?: 'linear' | 'ease-in-out';
187198
};
188199

189200
export interface ExportVideoModalProps {
@@ -311,8 +322,13 @@ const ExportVideoModalFactory = () => {
311322
...exportVideo
312323
});
313324
const onUpdateVideoConfiguration = useCallback(
314-
(values: VideoConfiguration) => setVideoConfiguration(prev => ({...prev, ...values})),
315-
[]
325+
(values: VideoConfiguration) => {
326+
setVideoConfiguration(prev => ({...prev, ...values}));
327+
if (values.swipeStartPct !== undefined || values.swipeEndPct !== undefined || values.swipeEasing !== undefined) {
328+
uiStateActions.setExportVideoSetting(values as any);
329+
}
330+
},
331+
[uiStateActions]
316332
);
317333

318334
const hubbleContainerRef = useRef<any>(null);
@@ -364,14 +380,22 @@ const ExportVideoModalFactory = () => {
364380
typeof window !== 'undefined' ? window.devicePixelRatio : 1
365381
);
366382

383+
const isSwipeMode = mapState.mapSplitMode === MapSplitMode.SWIPE_COMPARE && mapState.isSplit;
384+
367385
useEffect(() => {
386+
// Block DPR changes during preview to keep basemap and deck.gl layers in sync.
387+
// hubble.gl sets window.devicePixelRatio to scale render buffers, but this causes
388+
// visual misalignment between MapLibre and DeckGL during animated preview.
389+
// Skip in swipe mode — the swipe preview manages its own DPR for canvas compositing.
390+
if (isSwipeMode) return;
391+
368392
const trueDpr = trueDevicePixelRatio.current;
369393
const descriptor = Object.getOwnPropertyDescriptor(window, 'devicePixelRatio');
370394

371395
Object.defineProperty(window, 'devicePixelRatio', {
372396
get: () => trueDpr,
373397
set: () => {
374-
// no-op: prevent hubble.gl from changing DPR
398+
// no-op: prevent hubble.gl from changing DPR during preview
375399
},
376400
configurable: true
377401
});
@@ -383,7 +407,7 @@ const ExportVideoModalFactory = () => {
383407
delete (window as any).devicePixelRatio;
384408
}
385409
};
386-
}, []);
410+
}, [isSwipeMode]);
387411

388412
const onFilterFrameUpdate = useCallback(
389413
(filterIdx: number, name: string, value: any) => {
@@ -429,27 +453,52 @@ const ExportVideoModalFactory = () => {
429453

430454
const {ExportVideoPanelContainer, KeplerUIContext} = hubble;
431455

456+
const SwipeContainer = _swipeContainer;
457+
432458
return (
433459
<KeplerUIContext.Provider value={KEPLER_UI}>
434460
<StyledExportVideoModalContent className="export-video-modal">
435-
<ExportVideoPanelContainer
436-
ref={hubbleContainerRef}
437-
initialState={videoConfiguration}
438-
mapData={keplerState}
439-
onSettingsChange={onUpdateVideoConfiguration}
440-
header={false}
441-
handleClose={onClose}
442-
exportVideoWidth={exportVideoWidth}
443-
onFilterFrameUpdate={onFilterFrameUpdate}
444-
onTripFrameUpdate={onTripFrameUpdate}
445-
deckProps={deckPropsWithEffects}
446-
mapProps={staticMapProps}
447-
disableBaseMap={false}
448-
mapboxLayerBeforeId={topLayer?.id}
449-
defaultFileName={DEFAULT_FILENAME}
450-
animatableFilters={animatableFilters}
451-
getTimeRangeFilterKeyframes={getTimeRangeFilterKeyframes}
452-
/>
461+
{isSwipeMode && SwipeContainer ? (
462+
<SwipeContainer
463+
initialState={videoConfiguration}
464+
mapData={keplerState}
465+
onSettingsChange={onUpdateVideoConfiguration}
466+
header={false}
467+
handleClose={onClose}
468+
exportVideoWidth={exportVideoWidth}
469+
onFilterFrameUpdate={onFilterFrameUpdate}
470+
onTripFrameUpdate={onTripFrameUpdate}
471+
deckProps={deckPropsWithEffects}
472+
mapProps={staticMapProps}
473+
disableBaseMap={false}
474+
mapboxLayerBeforeId={topLayer?.id}
475+
defaultFileName={DEFAULT_FILENAME}
476+
animatableFilters={animatableFilters}
477+
getTimeRangeFilterKeyframes={getTimeRangeFilterKeyframes}
478+
swipeStartPct={exportVideo.swipeStartPct}
479+
swipeEndPct={exportVideo.swipeEndPct}
480+
swipeEasing={exportVideo.swipeEasing}
481+
/>
482+
) : (
483+
<ExportVideoPanelContainer
484+
ref={hubbleContainerRef}
485+
initialState={videoConfiguration}
486+
mapData={keplerState}
487+
onSettingsChange={onUpdateVideoConfiguration}
488+
header={false}
489+
handleClose={onClose}
490+
exportVideoWidth={exportVideoWidth}
491+
onFilterFrameUpdate={onFilterFrameUpdate}
492+
onTripFrameUpdate={onTripFrameUpdate}
493+
deckProps={deckPropsWithEffects}
494+
mapProps={staticMapProps}
495+
disableBaseMap={false}
496+
mapboxLayerBeforeId={topLayer?.id}
497+
defaultFileName={DEFAULT_FILENAME}
498+
animatableFilters={animatableFilters}
499+
getTimeRangeFilterKeyframes={getTimeRangeFilterKeyframes}
500+
/>
501+
)}
453502
</StyledExportVideoModalContent>
454503
</KeplerUIContext.Provider>
455504
);

src/components/src/modals/hubble-utils.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
// SPDX-License-Identifier: MIT
22
// Copyright contributors to the kepler.gl project
33

4-
import {MapView} from '@deck.gl/core';
4+
import {MapView, WebMercatorViewport, type MapViewState} from '@deck.gl/core';
55
import {DEFAULT_MAPBOX_API_URL, FILTER_VIEW_TYPES, FILTER_TYPES} from '@kepler.gl/constants';
66
import {getLayerBlendingParameters, getBaseMapLibrary} from '@kepler.gl/utils';
77
import {isMapboxURL, transformMapboxUrl} from 'maplibregl-mapbox-request-transformer';
8+
import {point} from '@turf/helpers';
9+
import transformTranslate from '@turf/transform-translate';
810
import {TimeRangeFilter} from '@kepler.gl/types';
911

1012
type KeplerState = {
@@ -196,3 +198,74 @@ export function getAnimatableFilters(keplerState: KeplerState): TimeRangeFilter[
196198
(f.view === FILTER_VIEW_TYPES.enlarged || f.syncedWithLayerTimeline)
197199
);
198200
}
201+
202+
// --- Video export utilities (inlined from @hubble.gl internals) ---
203+
204+
export function scaleToVideoExport(
205+
viewState: MapViewState,
206+
container: {width: number; height: number}
207+
): MapViewState & {width: number; height: number} {
208+
const viewport = new WebMercatorViewport(viewState);
209+
const nw = viewport.unproject([0, 0]) as [number, number];
210+
const se = viewport.unproject([viewport.width, viewport.height]) as [number, number];
211+
const videoViewport = new WebMercatorViewport({
212+
...viewState,
213+
width: container.width,
214+
height: container.height
215+
}).fitBounds([nw, se]);
216+
const {height, width, latitude, longitude, zoom, altitude} = videoViewport;
217+
return {
218+
height,
219+
width,
220+
latitude,
221+
longitude,
222+
pitch: viewState.pitch,
223+
zoom,
224+
bearing: viewState.bearing,
225+
altitude
226+
} as any;
227+
}
228+
229+
export function parseSetCameraType(strCameraType: string, viewState: MapViewState): MapViewState {
230+
const modifiedViewState: any = {...viewState};
231+
const match = strCameraType.match(/\b(?!to)\b\S+\w/g);
232+
if (!match) return modifiedViewState;
233+
234+
const turfPoint = point([modifiedViewState.longitude, modifiedViewState.latitude]);
235+
236+
if (match[0] === 'Orbit') {
237+
modifiedViewState.bearing = modifiedViewState.bearing + parseInt(match[1], 10);
238+
}
239+
240+
const directions = new Set(['East', 'South', 'West', 'North']);
241+
if (directions.has(match[0])) {
242+
const directionMap: Record<string, number> = {East: 270, South: 0, West: 90, North: 180};
243+
const translatedPoly = transformTranslate(turfPoint, 10, directionMap[match[0]]);
244+
if (match[0] === 'East' || match[0] === 'West') {
245+
modifiedViewState.longitude = translatedPoly.geometry.coordinates[0];
246+
} else {
247+
modifiedViewState.latitude = translatedPoly.geometry.coordinates[1];
248+
}
249+
}
250+
251+
if (match[0] === 'Zoom') {
252+
modifiedViewState.zoom += match[1] === 'In' ? 3 : -3;
253+
}
254+
255+
return modifiedViewState;
256+
}
257+
258+
type Resolution = {value: string; label: string; width: number; height: number};
259+
260+
const RESOLUTIONS: Resolution[] = [
261+
{value: '960x540', label: 'Good (540p)', width: 960, height: 540},
262+
{value: '1280x720', label: 'High (720p)', width: 1280, height: 720},
263+
{value: '1920x1080', label: 'Highest (1080p)', width: 1920, height: 1080},
264+
{value: '640x480', label: 'Good (480p)', width: 640, height: 480},
265+
{value: '1280x960', label: 'High (960p)', width: 1280, height: 960},
266+
{value: '1920x1440', label: 'Highest (1440p)', width: 1920, height: 1440}
267+
];
268+
269+
export function getResolutionSetting(value: string): Resolution {
270+
return RESOLUTIONS.find(r => r.value === value) || RESOLUTIONS[0];
271+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// SPDX-License-Identifier: MIT
2+
// Copyright contributors to the kepler.gl project
3+
4+
import {easeInOut} from 'popmotion';
5+
6+
export type SwipeEasing = 'linear' | 'ease-in-out';
7+
8+
const linear = (p: number): number => p;
9+
const EASING_MAP: Record<SwipeEasing, (p: number) => number> = {
10+
linear,
11+
'ease-in-out': easeInOut
12+
};
13+
14+
/**
15+
* Compute the swipe percentage at a given time.
16+
*/
17+
export function getSwipePercentageAtTime(
18+
timeMs: number,
19+
durationMs: number,
20+
startPct: number,
21+
endPct: number,
22+
easing: SwipeEasing = 'ease-in-out'
23+
): number {
24+
const progress = Math.max(0, Math.min(1, timeMs / durationMs));
25+
const easedProgress = EASING_MAP[easing](progress);
26+
return startPct + (endPct - startPct) * easedProgress;
27+
}
28+
29+
const HANDLE_RADIUS = 14;
30+
const ARROW_SIZE = 6;
31+
32+
/**
33+
* Draw a swipe divider (line + circular handle with arrows) onto a 2D canvas context.
34+
*/
35+
function drawSwipeDivider(
36+
ctx: CanvasRenderingContext2D,
37+
splitX: number,
38+
height: number
39+
): void {
40+
ctx.save();
41+
42+
// Divider line
43+
ctx.strokeStyle = '#FFFFFF';
44+
ctx.lineWidth = 2;
45+
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
46+
ctx.shadowBlur = 4;
47+
ctx.beginPath();
48+
ctx.moveTo(splitX, 0);
49+
ctx.lineTo(splitX, height);
50+
ctx.stroke();
51+
52+
// Handle circle
53+
const cy = height / 2;
54+
ctx.shadowBlur = 6;
55+
ctx.beginPath();
56+
ctx.arc(splitX, cy, HANDLE_RADIUS, 0, Math.PI * 2);
57+
ctx.fillStyle = '#29323C';
58+
ctx.fill();
59+
ctx.strokeStyle = '#FFFFFF';
60+
ctx.lineWidth = 2;
61+
ctx.stroke();
62+
ctx.shadowBlur = 0;
63+
64+
// Left arrow
65+
ctx.fillStyle = '#FFFFFF';
66+
ctx.beginPath();
67+
ctx.moveTo(splitX - ARROW_SIZE - 3, cy);
68+
ctx.lineTo(splitX - 3, cy - ARROW_SIZE);
69+
ctx.lineTo(splitX - 3, cy + ARROW_SIZE);
70+
ctx.closePath();
71+
ctx.fill();
72+
73+
// Right arrow
74+
ctx.beginPath();
75+
ctx.moveTo(splitX + ARROW_SIZE + 3, cy);
76+
ctx.lineTo(splitX + 3, cy - ARROW_SIZE);
77+
ctx.lineTo(splitX + 3, cy + ARROW_SIZE);
78+
ctx.closePath();
79+
ctx.fill();
80+
81+
ctx.restore();
82+
}
83+
84+
/**
85+
* Composite two canvases (left/right map views) with a swipe divider
86+
* into a single output canvas.
87+
*/
88+
export function compositeSwipeFrame(
89+
leftCanvas: HTMLCanvasElement,
90+
rightCanvas: HTMLCanvasElement,
91+
outputCanvas: HTMLCanvasElement,
92+
percentage: number,
93+
showDivider: boolean = true
94+
): void {
95+
const ctx = outputCanvas.getContext('2d');
96+
if (!ctx) return;
97+
98+
const w = outputCanvas.width;
99+
const h = outputCanvas.height;
100+
const splitX = Math.round((w * percentage) / 100);
101+
102+
ctx.clearRect(0, 0, w, h);
103+
104+
// Draw right (background) canvas fully
105+
ctx.drawImage(rightCanvas, 0, 0, w, h);
106+
107+
// Draw left (foreground) canvas clipped to 0..splitX
108+
ctx.save();
109+
ctx.beginPath();
110+
ctx.rect(0, 0, splitX, h);
111+
ctx.clip();
112+
ctx.drawImage(leftCanvas, 0, 0, w, h);
113+
ctx.restore();
114+
115+
// Draw the divider
116+
if (showDivider) {
117+
drawSwipeDivider(ctx, splitX, h);
118+
}
119+
}

0 commit comments

Comments
 (0)