@@ -59,7 +59,10 @@ const DOT_DURATION = 14000;
5959const DISPLAY_RATE = 80 ;
6060const FEED_RATE = 320 ;
6161const STATS_INTERVAL = 1000 ;
62- const MAX_FEED = 14 ;
62+ const MAX_FEED_DESKTOP = 14 ;
63+ const MAX_FEED_MOBILE = 4 ;
64+ // Keep in sync with @media (max-width: 600px) in orbital.css
65+ const MOBILE_BREAKPOINT = 600 ;
6366const MARKER_SOFT_LIMIT = 100 ;
6467const MARKER_HARD_LIMIT = 400 ;
6568
@@ -81,9 +84,6 @@ const scene = new THREE.Scene();
8184const camera = new THREE . PerspectiveCamera (
8285 45 , window . innerWidth / window . innerHeight , 0.1 , 1000
8386) ;
84- const mobileQuery = window . matchMedia ( '(max-width: 600px)' ) ;
85- camera . position . z = mobileQuery . matches ? 8.0 : 5.6 ;
86- camera . position . y = mobileQuery . matches ? - 0.65 : 0 ;
8787
8888// ── Stars ────────────────────────────────────────────────────────────────────
8989
@@ -199,10 +199,17 @@ function latLngToVec3(lat, lng, r = GLOBE_RADIUS) {
199199// Seer is Sentry's AI debugger. It orbits the globe and flies to each new
200200// error location, beaming down onto it.
201201
202- const ufoTex = loader . load ( '/static/seer.png' ) ;
202+ let ufoTexLoaded = false ;
203+ const ufoTex = loader . load (
204+ '/static/seer.png' ,
205+ ( ) => { ufoTexLoaded = true ; } ,
206+ undefined ,
207+ ( ) => { console . warn ( '[Sentry Live] Failed to load Seer UFO texture — UFO disabled' ) ; }
208+ ) ;
203209const ufoMat = new THREE . SpriteMaterial ( {
204210 map : ufoTex ,
205211 transparent : true ,
212+ alphaTest : 0.01 ,
206213 depthWrite : false ,
207214} ) ;
208215const ufo = new THREE . Sprite ( ufoMat ) ;
@@ -351,7 +358,59 @@ let staleDrop = false;
351358let totalSampled = 0 ;
352359
353360const elSampled = document . getElementById ( 'total-sampled' ) ;
354- const feedList = document . getElementById ( 'feed-list' ) ;
361+ const feedList = document . getElementById ( 'feed-list' ) ;
362+ const eventFeed = document . getElementById ( 'event-feed' ) ;
363+
364+ // ── Feed toggle ───────────────────────────────────────────────
365+ const feedToggleBtn = eventFeed . querySelector ( '.feed-title' ) ;
366+
367+ // Restore collapsed state from previous session visit
368+ if ( sessionStorage . getItem ( 'feedCollapsed' ) === '1' ) {
369+ eventFeed . classList . add ( 'collapsed' ) ;
370+ feedToggleBtn . setAttribute ( 'aria-expanded' , 'false' ) ;
371+ }
372+
373+ feedToggleBtn . addEventListener ( 'click' , ( ) => {
374+ const nowCollapsed = eventFeed . classList . toggle ( 'collapsed' ) ;
375+ feedToggleBtn . setAttribute ( 'aria-expanded' , String ( ! nowCollapsed ) ) ;
376+ sessionStorage . setItem ( 'feedCollapsed' , nowCollapsed ? '1' : '0' ) ;
377+ } ) ;
378+
379+ // ── Interaction hint ──────────────────────────────────────────
380+ const hintEl = document . getElementById ( 'interaction-hint' ) ;
381+ if ( hintEl ) {
382+ if ( sessionStorage . getItem ( 'hintSeen' ) === '1' ) {
383+ hintEl . hidden = true ;
384+ } else {
385+ // Adapt text for touch devices
386+ if ( 'ontouchstart' in window || navigator . maxTouchPoints > 0 ) {
387+ hintEl . textContent = 'Drag to rotate · Pinch to zoom' ;
388+ }
389+
390+ // For reduced-motion users the CSS removes the animation; handle opacity manually
391+ const reducedMotion = window . matchMedia ( '(prefers-reduced-motion: reduce)' ) . matches ;
392+ if ( reducedMotion ) {
393+ // Show static hint; dismiss on first interaction only
394+ hintEl . style . opacity = '1' ;
395+ }
396+
397+ const dismissHint = ( ) => {
398+ if ( reducedMotion ) {
399+ hintEl . style . opacity = '0' ;
400+ }
401+ hintEl . hidden = true ;
402+ sessionStorage . setItem ( 'hintSeen' , '1' ) ;
403+ [ 'pointerdown' , 'wheel' , 'touchstart' ] . forEach ( t =>
404+ window . removeEventListener ( t , dismissHint ) ) ;
405+ } ;
406+
407+ [ 'pointerdown' , 'wheel' , 'touchstart' ] . forEach ( t =>
408+ window . addEventListener ( t , dismissHint , { passive : true , once : true } ) ) ;
409+
410+ // Also dismiss when the CSS animation naturally ends
411+ hintEl . addEventListener ( 'animationend' , dismissHint , { once : true } ) ;
412+ }
413+ }
355414
356415
357416function addFeedItem ( platform , lat , lng ) {
@@ -378,7 +437,8 @@ function addFeedItem(platform, lat, lng) {
378437 li . appendChild ( locationSpan ) ;
379438
380439 feedList . insertBefore ( li , feedList . firstChild ) ;
381- while ( feedList . children . length > MAX_FEED ) feedList . removeChild ( feedList . lastChild ) ;
440+ const maxFeed = window . innerWidth <= MOBILE_BREAKPOINT ? MAX_FEED_MOBILE : MAX_FEED_DESKTOP ;
441+ while ( feedList . children . length > maxFeed ) feedList . removeChild ( feedList . lastChild ) ;
382442}
383443
384444// ── SSE stream ────────────────────────────────────────────────────────────────
@@ -524,31 +584,40 @@ window.addEventListener('pagehide', onPageHidden);
524584
525585// ── Resize / breakpoint ───────────────────────────────────────────────────────
526586
527- window . addEventListener ( 'resize' , ( ) => {
587+ function onResize ( ) {
528588 camera . aspect = window . innerWidth / window . innerHeight ;
529589 camera . updateProjectionMatrix ( ) ;
530590 renderer . setSize ( window . innerWidth , window . innerHeight ) ;
591+ }
592+
593+ window . addEventListener ( 'resize' , onResize ) ;
594+ // orientationchange fires before the viewport dimensions settle; a short
595+ // rAF-based delay ensures innerWidth/Height reflect the new orientation.
596+ window . addEventListener ( 'orientationchange' , ( ) => {
597+ requestAnimationFrame ( ( ) => { requestAnimationFrame ( onResize ) ; } ) ;
531598} ) ;
532599
533600// Adjust camera distance and y-offset at the mobile/desktop breakpoint while
534601// 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 } ;
602+ const CAMERA_DESKTOP = { dist : 5.5 , y : 0.0 , minD : 1.4 , maxD : 10 } ;
603+ const CAMERA_MOBILE = { dist : 9 .5, y : - 0.8 , minD : 4.0 , maxD : 20 } ;
537604
538605function applyCameraBreakpoint ( cfg ) {
539606 // Decompose current position into azimuthal angle around Y axis.
540- const angle = Math . atan2 ( camera . position . x , camera . position . z ) ;
607+ const angle = Math . atan2 ( camera . position . x , camera . position . z ) ;
541608 // Horizontal component of the new spherical position.
542- const hDist = Math . sqrt ( Math . max ( 0 , cfg . dist * cfg . dist - cfg . y * cfg . y ) ) ;
609+ const hDist = Math . sqrt ( Math . max ( 0 , cfg . dist * cfg . dist - cfg . y * cfg . y ) ) ;
543610 camera . position . set (
544611 Math . sin ( angle ) * hDist ,
545612 cfg . y ,
546613 Math . cos ( angle ) * hDist ,
547614 ) ;
615+ controls . minDistance = cfg . minD ;
616+ controls . maxDistance = cfg . maxD ;
548617 controls . update ( ) ;
549618}
550619
551- const mobileQuery = window . matchMedia ( ' (max-width: 768px)' ) ;
620+ const mobileQuery = window . matchMedia ( ` (max-width: ${ MOBILE_BREAKPOINT } px)` ) ;
552621mobileQuery . addEventListener ( 'change' , e => {
553622 applyCameraBreakpoint ( e . matches ? CAMERA_MOBILE : CAMERA_DESKTOP ) ;
554623} ) ;
@@ -564,7 +633,7 @@ function animate() {
564633
565634 // ── Seer UFO state machine ───────────────────────────────────
566635 if ( ufoState === 'hidden' ) {
567- if ( hasErrorLocation && now >= ufoNextAppear ) {
636+ if ( ufoTexLoaded && hasErrorLocation && now >= ufoNextAppear ) {
568637 // Position Seer above the most recent error, slightly offset from globe
569638 const dir = latLngToVec3 ( lastErrorLat , lastErrorLng ) . normalize ( ) ;
570639 ufoHoverPos . copy ( dir ) . multiplyScalar ( UFO_ORBIT_RADIUS ) ;
0 commit comments