@@ -386,7 +386,32 @@ function addFeedItem(platform, lat, lng) {
386386let windowInFocus = ! document . hidden ;
387387let source = null ;
388388
389+ // Watchdog: if no SSE message arrives for 30s, the connection is dead.
390+ // Safari silently fails EventSource reconnection (readyState stays CONNECTING
391+ // but never actually receives data). Force-close and reconnect.
392+ const isSafari = / ^ ( (? ! c h r o m e | a n d r o i d ) .) * s a f a r i / i. test ( navigator . userAgent ) ;
393+ let reconnectWatchdog = null ;
394+
395+ function resetWatchdog ( ) {
396+ clearTimeout ( reconnectWatchdog ) ;
397+ reconnectWatchdog = setTimeout ( ( ) => {
398+ console . warn ( '[Sentry Live] No events for 5s — forcing SSE reconnect' ) ;
399+ Sentry . addBreadcrumb ( { category : 'sse' , message : 'watchdog triggered reconnect' , level : 'warning' } ) ;
400+ if ( source ) {
401+ source . onmessage = null ;
402+ source . onerror = null ;
403+ source . close ( ) ;
404+ source = null ;
405+ }
406+ connectStream ( ) ;
407+ } , 5000 ) ;
408+ }
409+
389410function onStreamMessage ( e ) {
411+ // Track message arrival before the focus guard so the watchdog knows the
412+ // connection is alive even while the tab is hidden.
413+ resetWatchdog ( ) ;
414+
390415 // Skip all processing while backgrounded — browsers may queue a burst of
391416 // events when a throttled tab is foregrounded, which would freeze the UI.
392417 if ( ! windowInFocus ) return ;
@@ -400,16 +425,20 @@ function onStreamMessage(e) {
400425 return ;
401426 }
402427
428+ // Server sends periodic heartbeat pings ({}) to keep the connection alive
429+ // and prevent the watchdog from firing during quiet traffic periods.
430+ if ( ! Array . isArray ( parsed ) ) return ;
431+
403432 const [ lat , lng , ts , platform ] = parsed ;
404- // Drop events whose timestamp is more than 5s away from now (either direction).
405- // Guards against the browser replaying a burst of buffered SSE messages when a
406- // throttled tab regains focus. Using Math.abs handles server/client clock skew
407- // in both directions — without it, a server clock lagging >5s silently empties
408- // the globe .
409- if ( Math . abs ( Date . now ( ) - ts ) > 5000 ) {
433+ // Drop events that are too old — guards against the browser replaying a burst
434+ // of buffered SSE messages when a throttled tab regains focus.
435+ // We only check the past direction: events with a future timestamp are fine
436+ // (the source server's clock may run slightly ahead of the client's clock),
437+ // whereas stale buffered events always arrive with an old timestamp .
438+ if ( Date . now ( ) - ts > 10000 ) {
410439 if ( ! staleDrop ) {
411440 staleDrop = true ;
412- console . warn ( `[Sentry Live] Dropping events: clock skew or stale burst detected (ts=${ ts } , now=${ Date . now ( ) } )` ) ;
441+ console . warn ( `[Sentry Live] Dropping stale events: buffered burst detected (ts=${ ts } , now=${ Date . now ( ) } , age= ${ Date . now ( ) - ts } ms )` ) ;
413442 }
414443 return ;
415444 }
@@ -440,48 +469,91 @@ function connectStream() {
440469 source = new EventSource ( '/stream' ) ;
441470 source . onmessage = onStreamMessage ;
442471 source . onerror = ( ) => {
443- // EventSource auto-reconnects on network errors (readyState stays CONNECTING).
444- // On HTTP errors it enters CLOSED state and won't retry — handle that case manually.
445- if ( source . readyState === EventSource . CLOSED ) {
472+ // CLOSED: server rejected the connection (HTTP error) — EventSource won't
473+ // retry automatically, so we do it manually.
474+ // CONNECTING on Safari: Safari sometimes fires onerror but never actually
475+ // reconnects, leaving the source stuck. Force a clean reconnect.
476+ // On other browsers, CONNECTING means the browser is handling reconnection
477+ // with native exponential backoff — don't interfere.
478+ if ( source . readyState === EventSource . CLOSED ||
479+ ( source . readyState === EventSource . CONNECTING && isSafari ) ) {
480+ source . onmessage = null ;
481+ source . onerror = null ;
482+ source . close ( ) ;
446483 source = null ;
447- Sentry . addBreadcrumb ( { category : 'sse' , message : 'stream closed (HTTP error), retrying in 3s' , level : 'warning' } ) ;
448- console . error ( '[Sentry Live] Stream closed (HTTP error), retrying in 3s…' ) ;
484+ clearTimeout ( reconnectWatchdog ) ;
485+ Sentry . addBreadcrumb ( { category : 'sse' , message : 'stream error, retrying in 3s' , level : 'warning' } ) ;
486+ console . error ( '[Sentry Live] Stream error, retrying in 3s…' ) ;
449487 setTimeout ( connectStream , 3000 ) ;
450488 }
451489 } ;
490+ resetWatchdog ( ) ;
452491}
453492
454- document . addEventListener ( 'visibilitychange' , ( ) => {
455- windowInFocus = ! document . hidden ;
456- if ( windowInFocus ) {
457- if ( ufoHiddenAt !== null ) {
458- // Freeze UFO state timing while RAF is paused in background tabs.
459- // Use Date.now() for both sides so the delta is in the same wall-clock
460- // domain as ufoNextAppear and ufoStateStart (which are Date.now()-based).
461- shiftUfoTimers ( Date . now ( ) - ufoHiddenAt ) ;
462- ufoHiddenAt = null ;
463- }
464-
465- return ;
493+ function onPageVisible ( ) {
494+ windowInFocus = true ;
495+ if ( ufoHiddenAt !== null ) {
496+ // Freeze UFO state timing while RAF is paused in background tabs.
497+ // Use Date.now() for both sides so the delta is in the same wall-clock
498+ // domain as ufoNextAppear and ufoStateStart (which are Date.now()-based).
499+ shiftUfoTimers ( Date . now ( ) - ufoHiddenAt ) ;
500+ ufoHiddenAt = null ;
466501 }
502+ }
503+
504+ function onPageHidden ( ) {
505+ windowInFocus = false ;
467506 ufoHiddenAt = Date . now ( ) ;
468- // SSE connection stays open — browsers throttle background tabs naturally
469- // and the windowInFocus guard at the top of onStreamMessage prevents any
470- // processing or DOM work while hidden.
507+ }
508+
509+ // `visibilitychange` is the standard, but Safari (especially iOS) sometimes
510+ // fails to fire it when returning from a switched app, leaving windowInFocus
511+ // stuck at false. `pageshow`/`pagehide` and `focus`/`blur` are more reliable
512+ // on Safari and serve as fallbacks.
513+ document . addEventListener ( 'visibilitychange' , ( ) => {
514+ if ( document . hidden ) onPageHidden ( ) ; else onPageVisible ( ) ;
471515} ) ;
516+ // Guard against pageshow firing on initial load in a background tab.
517+ // `pageshow` fires for all page loads (not just bfcache restorations), so
518+ // blindly calling onPageVisible() here would override the document.hidden
519+ // initialisation and mark a background tab as focused.
520+ window . addEventListener ( 'pageshow' , ( ) => { if ( ! document . hidden ) onPageVisible ( ) ; } ) ;
521+ window . addEventListener ( 'pagehide' , onPageHidden ) ;
522+ // `focus`/`blur` intentionally not used: `blur` fires when clicking browser
523+ // chrome (address bar, DevTools) and would incorrectly suppress events.
472524
473- // ── Resize ───────────── ───────────────────────────────────────────────────────
525+ // ── Resize / breakpoint ───────────────────────────────────────────────────────
474526
475527window . addEventListener ( 'resize' , ( ) => {
476528 camera . aspect = window . innerWidth / window . innerHeight ;
477529 camera . updateProjectionMatrix ( ) ;
478530 renderer . setSize ( window . innerWidth , window . innerHeight ) ;
479531} ) ;
480532
481- mobileQuery . addEventListener ( 'change' , e => {
482- camera . position . set ( 0 , e . matches ? - 0.65 : 0 , e . matches ? 8.0 : 5.6 ) ;
533+ // Adjust camera distance and y-offset at the mobile/desktop breakpoint while
534+ // preserving the current orbital angle so autoRotate doesn't snap the globe.
535+ const CAMERA_DESKTOP = { dist : 2.8 , y : 0.0 } ;
536+ const CAMERA_MOBILE = { dist : 3.5 , y : - 0.15 } ;
537+
538+ function applyCameraBreakpoint ( cfg ) {
539+ // Decompose current position into azimuthal angle around Y axis.
540+ const angle = Math . atan2 ( camera . position . x , camera . position . z ) ;
541+ // Horizontal component of the new spherical position.
542+ const hDist = Math . sqrt ( Math . max ( 0 , cfg . dist * cfg . dist - cfg . y * cfg . y ) ) ;
543+ camera . position . set (
544+ Math . sin ( angle ) * hDist ,
545+ cfg . y ,
546+ Math . cos ( angle ) * hDist ,
547+ ) ;
483548 controls . update ( ) ;
549+ }
550+
551+ const mobileQuery = window . matchMedia ( '(max-width: 768px)' ) ;
552+ mobileQuery . addEventListener ( 'change' , e => {
553+ applyCameraBreakpoint ( e . matches ? CAMERA_MOBILE : CAMERA_DESKTOP ) ;
484554} ) ;
555+ // Apply on load.
556+ applyCameraBreakpoint ( mobileQuery . matches ? CAMERA_MOBILE : CAMERA_DESKTOP ) ;
485557
486558// ── Animation loop ────────────────────────────────────────────────────────────
487559
0 commit comments