Skip to content

Commit ec4dfe0

Browse files
committed
fix(map): 🐛 stabilize playback camera and control layout
1 parent 3156a48 commit ec4dfe0

11 files changed

Lines changed: 364 additions & 81 deletions

File tree

e2e/travelback.spec.ts

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,19 @@ const JSON_SEMANTIC_LOC_FIXTURE = path.resolve(__dirname, 'fixtures/google-seman
1010
const JSON_TIMELINE_EDITS_FIXTURE = path.resolve(__dirname, 'fixtures/google-timeline-edits.json')
1111
const JSON_SEMANTIC_SEG_FIXTURE = path.resolve(__dirname, 'fixtures/google-semantic-segments.json')
1212

13+
function boxesOverlap(a: { x: number; y: number; width: number; height: number }, b: { x: number; y: number; width: number; height: number }) {
14+
return !(
15+
a.x + a.width <= b.x ||
16+
b.x + b.width <= a.x ||
17+
a.y + a.height <= b.y ||
18+
b.y + b.height <= a.y
19+
)
20+
}
21+
22+
function shortestAngleDelta(from: number, to: number) {
23+
return Math.abs(((to - from + 540) % 360) - 180)
24+
}
25+
1326
/** Helper: wait for the app to be ready (map container rendered, with or without WebGL) */
1427
async function waitForApp(page: Page) {
1528
// Wait for the heading to be visible (confirms React rendered)
@@ -18,11 +31,6 @@ async function waitForApp(page: Page) {
1831
await page.waitForTimeout(500)
1932
}
2033

21-
/** Helper: check if the map canvas loaded (WebGL worked) */
22-
async function hasMapCanvas(page: Page): Promise<boolean> {
23-
return await page.locator('canvas').count() > 0
24-
}
25-
2634
/** Helper: upload a GPX file and wait for the track to load */
2735
async function uploadGpx(page: Page) {
2836
const fileInput = page.locator('input[type="file"]')
@@ -101,6 +109,98 @@ test.describe('Travelback App', () => {
101109
await expect(page.getByRole('button', { name: /camera tracking/i })).toBeVisible()
102110
})
103111

112+
test('map zoom controls do not overlap top toolbars', async ({ page }) => {
113+
await uploadGpx(page)
114+
115+
const zoomControls = page.locator('.maplibregl-ctrl-top-left .maplibregl-ctrl-group').first()
116+
await expect(zoomControls).toBeVisible({ timeout: 10_000 })
117+
await expect(page.getByTestId('global-toolbar')).toBeVisible({ timeout: 10_000 })
118+
await expect(page.getByTestId('track-toolbar')).toBeVisible({ timeout: 10_000 })
119+
120+
await expect.poll(async () => {
121+
const [zoomBox, globalToolbarBox, trackToolbarBox] = await Promise.all([
122+
zoomControls.boundingBox(),
123+
page.getByTestId('global-toolbar').boundingBox(),
124+
page.getByTestId('track-toolbar').boundingBox(),
125+
])
126+
127+
if (!zoomBox || !globalToolbarBox || !trackToolbarBox) {
128+
return true
129+
}
130+
131+
return boxesOverlap(zoomBox, globalToolbarBox) || boxesOverlap(zoomBox, trackToolbarBox)
132+
}, { timeout: 5_000, intervals: [120, 200, 300] }).toBeFalsy()
133+
})
134+
135+
test('map camera movement stays stable during playback', async ({ page }) => {
136+
await uploadGpx(page)
137+
138+
const playBtn = page.getByRole('button', { name: 'Play' })
139+
await expect(playBtn).toBeVisible({ timeout: 10_000 })
140+
await playBtn.click({ force: true })
141+
142+
const samples = await page.evaluate(async () => {
143+
type CameraSample = { center: [number, number]; bearing: number }
144+
type DebugWindow = Window & {
145+
__travelbackDebug?: {
146+
getCamera: () => CameraSample | null
147+
}
148+
}
149+
150+
const debugWindow = window as DebugWindow
151+
const points: CameraSample[] = []
152+
153+
for (let i = 0; i < 16; i++) {
154+
const camera = debugWindow.__travelbackDebug?.getCamera()
155+
if (camera) {
156+
points.push({ center: [...camera.center] as [number, number], bearing: camera.bearing })
157+
}
158+
await new Promise(resolve => setTimeout(resolve, 120))
159+
}
160+
161+
return points
162+
})
163+
164+
expect(samples.length).toBeGreaterThanOrEqual(8)
165+
166+
const centerJumpsMeters: number[] = []
167+
const bearingJumps: number[] = []
168+
169+
for (let i = 1; i < samples.length; i++) {
170+
const prev = samples[i - 1]
171+
const next = samples[i]
172+
const avgLatRad = ((prev.center[1] + next.center[1]) / 2) * (Math.PI / 180)
173+
const dLngMeters = (next.center[0] - prev.center[0]) * 111320 * Math.cos(avgLatRad)
174+
const dLatMeters = (next.center[1] - prev.center[1]) * 110540
175+
centerJumpsMeters.push(Math.hypot(dLngMeters, dLatMeters))
176+
bearingJumps.push(shortestAngleDelta(prev.bearing, next.bearing))
177+
}
178+
179+
const steadyCenterJumps = centerJumpsMeters.slice(4)
180+
const steadyBearingJumps = bearingJumps.slice(4)
181+
182+
expect(steadyCenterJumps.length).toBeGreaterThanOrEqual(4)
183+
expect(steadyBearingJumps.length).toBeGreaterThanOrEqual(4)
184+
185+
const sortedCenterJumps = [...steadyCenterJumps].sort((a, b) => a - b)
186+
const sortedBearingJumps = [...steadyBearingJumps].sort((a, b) => a - b)
187+
const centerMedian = sortedCenterJumps[Math.floor(sortedCenterJumps.length / 2)]
188+
const bearingMedian = sortedBearingJumps[Math.floor(sortedBearingJumps.length / 2)]
189+
const centerP95 = sortedCenterJumps[Math.floor((sortedCenterJumps.length - 1) * 0.95)]
190+
const bearingP95 = sortedBearingJumps[Math.floor((sortedBearingJumps.length - 1) * 0.95)]
191+
192+
const firstSample = samples[0]
193+
const lastSample = samples[samples.length - 1]
194+
const avgLatRad = ((firstSample.center[1] + lastSample.center[1]) / 2) * (Math.PI / 180)
195+
const totalLngMeters = (lastSample.center[0] - firstSample.center[0]) * 111320 * Math.cos(avgLatRad)
196+
const totalLatMeters = (lastSample.center[1] - firstSample.center[1]) * 110540
197+
const totalDisplacementMeters = Math.hypot(totalLngMeters, totalLatMeters)
198+
199+
expect(totalDisplacementMeters).toBeGreaterThan(25)
200+
expect(centerP95).toBeLessThan(Math.max(600, centerMedian * 8))
201+
expect(bearingP95).toBeLessThan(Math.max(150, bearingMedian * 8))
202+
})
203+
104204
test('scene editor opens and allows adding scenes', async ({ page }) => {
105205
await uploadGpx(page)
106206

src/app/globals.css

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,5 +129,6 @@ input[type="range"]::-webkit-slider-runnable-track {
129129
.vitro-toast-ok { background: color-mix(in srgb, var(--ok) 80%, var(--go-bg)); }
130130
.vitro-toast-info { background: color-mix(in srgb, var(--info) 80%, var(--go-bg)); }
131131

132-
/* Hide map controls on landing to prevent overlap with upload card */
133-
.hide-map-controls .maplibregl-ctrl-top-right { display: none; }
132+
/* Hide map zoom controls on landing to prevent overlap with upload card */
133+
.hide-map-controls .maplibregl-ctrl-top-right,
134+
.hide-map-controls .maplibregl-ctrl-top-left { display: none; }

src/app/page.tsx

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ function HomeInner() {
3434
const { t, locale, setLocale } = useLocale()
3535
const [fullTrack, setFullTrack] = useState<Track | null>(null)
3636
const [track, setTrack] = useState<Track | null>(null)
37-
const [timelineRange, setTimelineRange] = useState<[number, number] | null>(null)
3837
const [isPlaying, setIsPlaying] = useState(false)
3938
const [progress, setProgress] = useState(0)
4039
const [speed, setSpeed] = useState(1)
@@ -52,6 +51,7 @@ function HomeInner() {
5251
const [showSceneEditor, setShowSceneEditor] = useState(false)
5352
const [transitionDuration, setTransitionDuration] = useState(0.03)
5453
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false)
54+
const [seekNonce, setSeekNonce] = useState(0)
5555
const { messages: toasts, addToast, dismissToast } = useToast()
5656

5757
const mapViewRef = useRef<MapViewHandle>(null)
@@ -82,11 +82,13 @@ function HomeInner() {
8282

8383
if (next >= 1) {
8484
setProgress(1)
85+
progressRef.current = 1
8586
setIsPlaying(false)
8687
return
8788
}
8889

8990
setProgress(next)
91+
progressRef.current = next
9092
animFrameRef.current = requestAnimationFrame(animate)
9193
}
9294

@@ -111,11 +113,21 @@ function HomeInner() {
111113
break
112114
case 'ArrowRight':
113115
e.preventDefault()
114-
setProgress(p => Math.min(1, p + 0.02))
116+
{
117+
const next = Math.min(1, progressRef.current + 0.02)
118+
setProgress(next)
119+
progressRef.current = next
120+
setSeekNonce(n => n + 1)
121+
}
115122
break
116123
case 'ArrowLeft':
117124
e.preventDefault()
118-
setProgress(p => Math.max(0, p - 0.02))
125+
{
126+
const next = Math.max(0, progressRef.current - 0.02)
127+
setProgress(next)
128+
progressRef.current = next
129+
setSeekNonce(n => n + 1)
130+
}
119131
break
120132
case 'f':
121133
case 'F':
@@ -155,20 +167,23 @@ function HomeInner() {
155167
setFullTrack(t)
156168
setTrack(t)
157169
setProgress(0)
170+
progressRef.current = 0
158171
setIsPlaying(false)
159172
}, [])
160173

161174
const handleJourneyComplete = useCallback((t: Track) => {
162175
setFullTrack(t)
163176
setTrack(t)
164177
setProgress(0)
178+
progressRef.current = 0
165179
setIsPlaying(false)
166180
setIsCreatingJourney(false)
167181
}, [])
168182

169183
const handleTogglePlay = useCallback(() => {
170184
if (progress >= 1) {
171185
setProgress(0)
186+
progressRef.current = 0
172187
setIsPlaying(true)
173188
} else {
174189
setIsPlaying((p) => !p)
@@ -178,6 +193,7 @@ function HomeInner() {
178193
const handleSeek = useCallback((p: number) => {
179194
setProgress(p)
180195
progressRef.current = p
196+
setSeekNonce(n => n + 1)
181197
}, [])
182198

183199
const handleExport = useCallback(async (config: ExportConfig) => {
@@ -206,7 +222,7 @@ function HomeInner() {
206222

207223
// Wait for resize to settle then wait for map idle
208224
await new Promise(r => setTimeout(r, 200))
209-
await mapHandle.waitForIdle()
225+
await mapHandle.waitForIdle(abortController.signal)
210226

211227
const result = await exportVideo(
212228
canvas,
@@ -219,7 +235,7 @@ function HomeInner() {
219235
progressRef.current = progress
220236
},
221237
(p) => setExportProgress(p),
222-
() => mapHandle.waitForIdle(),
238+
() => mapHandle.waitForIdle(abortController.signal),
223239
abortController.signal,
224240
)
225241

@@ -276,6 +292,7 @@ function HomeInner() {
276292
setFullTrack(parsed)
277293
setTrack(parsed)
278294
setProgress(0)
295+
progressRef.current = 0
279296
setIsPlaying(false)
280297
} catch (err) {
281298
console.error('Sample load failed:', {
@@ -314,6 +331,8 @@ function HomeInner() {
314331
progress={progress}
315332
mapStyleKey={mapStyleKey}
316333
followCamera={followCamera}
334+
suspendAutoCamera={isExporting}
335+
seekNonce={seekNonce}
317336
scenes={scenes}
318337
duration={duration}
319338
transitionDuration={transitionDuration}
@@ -348,7 +367,7 @@ function HomeInner() {
348367
)}
349368

350369
{/* Theme toggle + Language picker */}
351-
<div className="absolute top-4 right-4 z-10 flex items-center gap-2">
370+
<div data-testid="global-toolbar" className="absolute top-4 right-4 z-10 flex items-center gap-2">
352371
<select
353372
value={locale}
354373
onChange={e => setLocale(e.target.value as Locale)}
@@ -430,7 +449,7 @@ function HomeInner() {
430449

431450
{/* Top-right toolbar */}
432451
{track && (
433-
<div className="absolute top-4 right-16 z-10 flex flex-wrap gap-2 max-w-[calc(100vw-5rem)]">
452+
<div data-testid="track-toolbar" className="absolute top-4 right-16 z-10 flex flex-wrap gap-2 max-w-[calc(100vw-5rem)]">
434453
<button
435454
onClick={() => {
436455
setTrack(null)

src/components/ExportPanel.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useState, useCallback, useEffect, useRef } from 'react'
44
import { X, ChevronDown, Check, Share2, RotateCcw } from 'lucide-react'
5-
import type { VideoCodec, ExportConfig, ResolutionPreset } from '@/types'
5+
import type { VideoCodec, ExportConfig } from '@/types'
66
import { CODEC_LABELS, RESOLUTION_PRESETS } from '@/types'
77
import { isCodecSupported } from '@/lib/videoEncoder'
88
import { useLocale } from '@/lib/i18n'
@@ -261,7 +261,7 @@ export default function ExportPanel({
261261
<select value={codec}
262262
onChange={e => setCodec(e.target.value as VideoCodec)}
263263
className="vitro-select w-full px-3 py-2 text-sm">
264-
{(Object.entries(CODEC_LABELS) as [VideoCodec, string][]).map(([k, v]) => (
264+
{(Object.entries(CODEC_LABELS) as [VideoCodec, string][]).map(([k]) => (
265265
<option key={k} value={k} disabled={codecSupport[k] === false}>
266266
{t(`codec.${k}Desc` as 'codec.h264Desc' | 'codec.h265Desc' | 'codec.av1Desc')}{codecSupport[k] === false ? ` ${t('export.unsupported')}` : ''}
267267
</option>
@@ -284,7 +284,6 @@ export default function ExportPanel({
284284
<div>
285285
<label className="vitro-label block text-sm font-medium mb-1">{t('export.mbps')}</label>
286286
<input type="number" min={1} max={50} value={bitrate}
287-
onChange={e => setQuality('custom')}
288287
className="vitro-input w-full px-3 py-2 text-sm"
289288
readOnly
290289
/>

src/components/FileUpload.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ interface FileUploadProps {
1414
onCreateJourney?: () => void
1515
}
1616

17+
const MAX_FILE_SIZE = 500 * 1024 * 1024
18+
const WARN_FILE_SIZE = 100 * 1024 * 1024
19+
1720
export default function FileUpload({ onTrackLoaded, hasTrack, onShowGoogleGuide, onLoadSample, onCreateJourney }: FileUploadProps) {
1821
const { t } = useLocale()
1922
const [isDragging, setIsDragging] = useState(false)
@@ -26,9 +29,6 @@ export default function FileUpload({ onTrackLoaded, hasTrack, onShowGoogleGuide,
2629
return /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
2730
}, [])
2831

29-
const MAX_FILE_SIZE = 500 * 1024 * 1024 // 500 MB
30-
const WARN_FILE_SIZE = 100 * 1024 * 1024 // 100 MB
31-
3232
const handleFile = useCallback(async (file: File) => {
3333
setError(null)
3434
setLoading(true)

0 commit comments

Comments
 (0)