Skip to content

Commit 16410f4

Browse files
Ihor DykhtaIhor Dykhta
authored andcommitted
feat: swipe view mode support in video export
Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>
1 parent d384cf5 commit 16410f4

8 files changed

Lines changed: 1667 additions & 39 deletions

File tree

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

Lines changed: 59 additions & 37 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 {
@@ -30,6 +30,7 @@ import {
3030
getAnimatableFilters
3131
} from './hubble-utils';
3232
import {useFogHeightAnimation} from './fog-height-animation';
33+
import {SwipeExportVideoPanelContainer} from './swipe-export-video-container';
3334

3435
type HubbleModule = {
3536
ExportVideoPanelContainer: React.ComponentType<any>;
@@ -184,6 +185,9 @@ export type VideoConfiguration = {
184185
fileName?: string;
185186
resolution?: string;
186187
durationMs?: number;
188+
swipeStartPct?: number;
189+
swipeEndPct?: number;
190+
swipeEasing?: 'linear' | 'ease-in-out';
187191
};
188192

189193
export interface ExportVideoModalProps {
@@ -311,8 +315,13 @@ const ExportVideoModalFactory = () => {
311315
...exportVideo
312316
});
313317
const onUpdateVideoConfiguration = useCallback(
314-
(values: VideoConfiguration) => setVideoConfiguration(prev => ({...prev, ...values})),
315-
[]
318+
(values: VideoConfiguration) => {
319+
setVideoConfiguration(prev => ({...prev, ...values}));
320+
if (values.swipeStartPct !== undefined || values.swipeEndPct !== undefined || values.swipeEasing !== undefined) {
321+
uiStateActions.setExportVideoSetting(values as any);
322+
}
323+
},
324+
[uiStateActions]
316325
);
317326

318327
const hubbleContainerRef = useRef<any>(null);
@@ -365,23 +374,11 @@ const ExportVideoModalFactory = () => {
365374
);
366375

367376
useEffect(() => {
368-
const trueDpr = trueDevicePixelRatio.current;
369-
const descriptor = Object.getOwnPropertyDescriptor(window, 'devicePixelRatio');
370-
371-
Object.defineProperty(window, 'devicePixelRatio', {
372-
get: () => trueDpr,
373-
set: () => {
374-
// no-op: prevent hubble.gl from changing DPR
375-
},
376-
configurable: true
377-
});
378-
377+
// Allow hubble.gl to change DPR for resolution scaling during video export.
378+
// On unmount, restore the original DPR value so the rest of the app is unaffected.
379379
return () => {
380-
if (descriptor) {
381-
Object.defineProperty(window, 'devicePixelRatio', descriptor);
382-
} else {
383-
delete (window as any).devicePixelRatio;
384-
}
380+
// @ts-ignore
381+
window.devicePixelRatio = trueDevicePixelRatio.current;
385382
};
386383
}, []);
387384

@@ -429,27 +426,52 @@ const ExportVideoModalFactory = () => {
429426

430427
const {ExportVideoPanelContainer, KeplerUIContext} = hubble;
431428

429+
const isSwipeMode = mapState.mapSplitMode === MapSplitMode.SWIPE_COMPARE && mapState.isSplit;
430+
432431
return (
433432
<KeplerUIContext.Provider value={KEPLER_UI}>
434433
<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-
/>
434+
{isSwipeMode ? (
435+
<SwipeExportVideoPanelContainer
436+
initialState={videoConfiguration}
437+
mapData={keplerState}
438+
onSettingsChange={onUpdateVideoConfiguration}
439+
header={false}
440+
handleClose={onClose}
441+
exportVideoWidth={exportVideoWidth}
442+
onFilterFrameUpdate={onFilterFrameUpdate}
443+
onTripFrameUpdate={onTripFrameUpdate}
444+
deckProps={deckPropsWithEffects}
445+
mapProps={staticMapProps}
446+
disableBaseMap={false}
447+
mapboxLayerBeforeId={topLayer?.id}
448+
defaultFileName={DEFAULT_FILENAME}
449+
animatableFilters={animatableFilters}
450+
getTimeRangeFilterKeyframes={getTimeRangeFilterKeyframes}
451+
swipeStartPct={exportVideo.swipeStartPct}
452+
swipeEndPct={exportVideo.swipeEndPct}
453+
swipeEasing={exportVideo.swipeEasing}
454+
/>
455+
) : (
456+
<ExportVideoPanelContainer
457+
ref={hubbleContainerRef}
458+
initialState={videoConfiguration}
459+
mapData={keplerState}
460+
onSettingsChange={onUpdateVideoConfiguration}
461+
header={false}
462+
handleClose={onClose}
463+
exportVideoWidth={exportVideoWidth}
464+
onFilterFrameUpdate={onFilterFrameUpdate}
465+
onTripFrameUpdate={onTripFrameUpdate}
466+
deckProps={deckPropsWithEffects}
467+
mapProps={staticMapProps}
468+
disableBaseMap={false}
469+
mapboxLayerBeforeId={topLayer?.id}
470+
defaultFileName={DEFAULT_FILENAME}
471+
animatableFilters={animatableFilters}
472+
getTimeRangeFilterKeyframes={getTimeRangeFilterKeyframes}
473+
/>
474+
)}
453475
</StyledExportVideoModalContent>
454476
</KeplerUIContext.Provider>
455477
);

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)