@@ -3,6 +3,7 @@ import * as THREE from 'three'
33import './App.css'
44import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
55import tzlookup from 'tz-lookup'
6+ import pointInPolygon from 'point-in-polygon-hao'
67import { DateTime } from 'luxon'
78import packageJson from '../package.json'
89import ReactMarkdown from 'react-markdown'
@@ -82,7 +83,12 @@ function App() {
8283 const cameraRef = useRef ( null )
8384 const rendererRef = useRef ( null )
8485 const controlsRef = useRef ( null )
85-
86+ const tooltipRef = useRef ( null )
87+ const raycasterRef = useRef ( new THREE . Raycaster ( ) )
88+ const mouseRef = useRef ( new THREE . Vector2 ( ) )
89+ const highlightedTimezoneRef = useRef ( null )
90+ const lastMousePos = useRef ( null )
91+
8692 // Three.js Scene Objects - Visualization
8793 const flightLineRef = useRef ( null )
8894 const progressTubeRef = useRef ( null )
@@ -142,6 +148,14 @@ function App() {
142148 }
143149 }
144150
151+ // Inverse of latLonToVector3: convert a point on the sphere back to lat/lon
152+ const vector3ToLatLon = ( v ) => {
153+ const radius = v . length ( )
154+ const lat = 90 - Math . acos ( v . y / radius ) * ( 180 / Math . PI )
155+ const lon = Math . atan2 ( v . z , - v . x ) * ( 180 / Math . PI ) - 180
156+ return { lat, lon : ( ( lon + 540 ) % 360 ) - 180 }
157+ }
158+
145159 // Calculate points along a twilight boundary with latitude-dependent width
146160 const calculateTwilightBoundary = ( sunDirection , baseElevationAngle , currentTime ) => {
147161 const points = [ ]
@@ -449,6 +463,7 @@ function App() {
449463 } )
450464
451465 const sphere = new THREE . Mesh ( geometry , material )
466+ sphere . name = 'earth-sphere'
452467 scene . add ( sphere )
453468
454469 earthMaterialRef . current = material // Store reference
@@ -1782,6 +1797,7 @@ function App() {
17821797
17831798 // Effect to show/hide timezone boundaries
17841799 useEffect ( ( ) => {
1800+ if ( tooltipRef . current ) tooltipRef . current . style . display = 'none'
17851801 if ( ! sceneRef . current ) return
17861802
17871803 // Remove existing timezones if exists
@@ -1813,41 +1829,39 @@ function App() {
18131829
18141830 // Process each feature (timezone boundary)
18151831 data . features . forEach ( ( feature , featureIndex ) => {
1816- const timezoneName = feature . properties . tzid || feature . properties . name || `timezone-${ featureIndex } `
1817-
18181832 if ( feature . geometry . type === 'Polygon' ) {
18191833 feature . geometry . coordinates . forEach ( ring => {
1820- const points = ring . map ( coord =>
1834+ const points = ring . map ( coord =>
18211835 latLonToVector3 ( coord [ 1 ] , coord [ 0 ] , 2.005 )
18221836 )
1823-
1837+
18241838 const lineGeometry = new THREE . BufferGeometry ( ) . setFromPoints ( points )
18251839 const lineMaterial = new THREE . LineBasicMaterial ( {
18261840 color : isBWMode ? 0x0f0f0f : 0xffffff , // Check current BW mode state
18271841 transparent : true ,
18281842 opacity : 0
18291843 } )
1830-
1844+
18311845 const line = new THREE . Line ( lineGeometry , lineMaterial )
1832- line . userData . timezone = timezoneName // Store timezone identifier
1846+ line . userData . featureIndex = featureIndex
18331847 timezoneGroup . add ( line )
18341848 } )
18351849 } else if ( feature . geometry . type === 'MultiPolygon' ) {
18361850 feature . geometry . coordinates . forEach ( polygon => {
18371851 polygon . forEach ( ring => {
1838- const points = ring . map ( coord =>
1852+ const points = ring . map ( coord =>
18391853 latLonToVector3 ( coord [ 1 ] , coord [ 0 ] , 2.005 )
18401854 )
1841-
1855+
18421856 const lineGeometry = new THREE . BufferGeometry ( ) . setFromPoints ( points )
18431857 const lineMaterial = new THREE . LineBasicMaterial ( {
18441858 color : isBWMode ? 0x0f0f0f : 0xffffff , // Check current BW mode state
18451859 transparent : true ,
18461860 opacity : 0
18471861 } )
1848-
1862+
18491863 const line = new THREE . Line ( lineGeometry , lineMaterial )
1850- line . userData . timezone = timezoneName // Store timezone identifier
1864+ line . userData . featureIndex = featureIndex
18511865 timezoneGroup . add ( line )
18521866 } )
18531867 } )
@@ -1967,6 +1981,139 @@ function App() {
19671981
19681982 } , [ showTimezones ] )
19691983
1984+ // Tooltip: show IANA timezone on hover when timezone boundaries are visible
1985+ useEffect ( ( ) => {
1986+ const canvas = canvasRef . current
1987+ if ( ! canvas || isMobile ) return
1988+
1989+ const resetHighlight = ( ) => {
1990+ highlightedTimezoneRef . current = null
1991+ const tzGroup = sceneRef . current ?. getObjectByName ( 'timezone-boundaries' )
1992+ if ( tzGroup ) {
1993+ tzGroup . traverse ( ( child ) => {
1994+ if ( child . material && child . userData . featureIndex !== undefined ) {
1995+ child . material . opacity = 0.3
1996+ child . material . color . setHex ( isBWMode ? 0x0f0f0f : 0xffffff )
1997+ }
1998+ } )
1999+ }
2000+ }
2001+
2002+ const handleMouseMove = ( e ) => {
2003+ if ( ! showTimezones || ! tooltipRef . current || ! cameraRef . current || ! sceneRef . current ) {
2004+ if ( tooltipRef . current ) tooltipRef . current . style . display = 'none'
2005+ return
2006+ }
2007+
2008+ const rect = canvas . getBoundingClientRect ( )
2009+ mouseRef . current . x = ( ( e . clientX - rect . left ) / rect . width ) * 2 - 1
2010+ mouseRef . current . y = - ( ( e . clientY - rect . top ) / rect . height ) * 2 + 1
2011+
2012+ raycasterRef . current . setFromCamera ( mouseRef . current , cameraRef . current )
2013+
2014+ const earthMesh = sceneRef . current . getObjectByName ( 'earth-sphere' )
2015+ if ( ! earthMesh ) {
2016+ tooltipRef . current . style . display = 'none'
2017+ return
2018+ }
2019+
2020+ const intersects = raycasterRef . current . intersectObject ( earthMesh )
2021+
2022+ if ( intersects . length > 0 ) {
2023+ const point = intersects [ 0 ] . point
2024+ const { lat, lon } = vector3ToLatLon ( point )
2025+
2026+ try {
2027+ const tz = tzlookup ( lat , lon )
2028+ const now = new Date ( )
2029+ const formatter = new Intl . DateTimeFormat ( 'en-US' , {
2030+ timeZone : tz ,
2031+ timeZoneName : 'shortOffset'
2032+ } )
2033+ const parts = formatter . formatToParts ( now )
2034+ const offsetPart = parts . find ( p => p . type === 'timeZoneName' )
2035+ const offset = offsetPart ? offsetPart . value : ''
2036+
2037+ tooltipRef . current . textContent = `${ tz } · ${ offset } `
2038+ tooltipRef . current . style . display = 'block'
2039+ tooltipRef . current . style . left = `${ e . clientX + 16 } px`
2040+ tooltipRef . current . style . top = `${ e . clientY + 16 } px`
2041+
2042+ // Throttle point-in-polygon search to when mouse moves > 3px
2043+ const dx = e . clientX - ( lastMousePos . current ?. x || 0 )
2044+ const dy = e . clientY - ( lastMousePos . current ?. y || 0 )
2045+ if ( dx * dx + dy * dy >= 9 ) {
2046+ lastMousePos . current = { x : e . clientX , y : e . clientY }
2047+
2048+ // Find which GeoJSON feature contains this point
2049+ let matchedFeatureIndex = null
2050+ const tzData = timezoneDataRef . current
2051+ if ( tzData ) {
2052+ for ( let i = 0 ; i < tzData . features . length ; i ++ ) {
2053+ const feature = tzData . features [ i ]
2054+ const geom = feature . geometry
2055+ let polygons = [ ]
2056+
2057+ if ( geom . type === 'Polygon' ) {
2058+ polygons = [ geom . coordinates ]
2059+ } else if ( geom . type === 'MultiPolygon' ) {
2060+ polygons = geom . coordinates
2061+ }
2062+
2063+ for ( const polygon of polygons ) {
2064+ if ( pointInPolygon ( [ lon , lat ] , polygon ) ) {
2065+ matchedFeatureIndex = i
2066+ break
2067+ }
2068+ }
2069+ if ( matchedFeatureIndex !== null ) break
2070+ }
2071+ }
2072+
2073+ if ( matchedFeatureIndex !== null && matchedFeatureIndex !== highlightedTimezoneRef . current ) {
2074+ highlightedTimezoneRef . current = matchedFeatureIndex
2075+ const tzGroup = sceneRef . current . getObjectByName ( 'timezone-boundaries' )
2076+ if ( tzGroup ) {
2077+ tzGroup . traverse ( ( child ) => {
2078+ if ( child . material && child . userData . featureIndex !== undefined ) {
2079+ if ( child . userData . featureIndex === matchedFeatureIndex ) {
2080+ child . material . opacity = 1.0
2081+ child . material . color . setHex ( isBWMode ? 0x000000 : 0xffffff )
2082+ } else {
2083+ child . material . opacity = 0.1
2084+ child . material . color . setHex ( isBWMode ? 0x0f0f0f : 0xffffff )
2085+ }
2086+ }
2087+ } )
2088+ }
2089+ } else if ( matchedFeatureIndex === null && highlightedTimezoneRef . current !== null ) {
2090+ resetHighlight ( )
2091+ }
2092+ }
2093+ } catch {
2094+ tooltipRef . current . style . display = 'none'
2095+ resetHighlight ( )
2096+ }
2097+ } else {
2098+ tooltipRef . current . style . display = 'none'
2099+ resetHighlight ( )
2100+ }
2101+ }
2102+
2103+ const handleMouseLeave = ( ) => {
2104+ if ( tooltipRef . current ) tooltipRef . current . style . display = 'none'
2105+ resetHighlight ( )
2106+ }
2107+
2108+ canvas . addEventListener ( 'mousemove' , handleMouseMove )
2109+ canvas . addEventListener ( 'mouseleave' , handleMouseLeave )
2110+
2111+ return ( ) => {
2112+ canvas . removeEventListener ( 'mousemove' , handleMouseMove )
2113+ canvas . removeEventListener ( 'mouseleave' , handleMouseLeave )
2114+ }
2115+ } , [ showTimezones , isMobile , isBWMode ] )
2116+
19702117 useEffect ( ( ) => {
19712118 if ( ! twilightLinesRef . current . terminatorDay ) return
19722119
@@ -2154,18 +2301,23 @@ function App() {
21542301
21552302 // T for timezones toggle
21562303 if ( e . key === 't' || e . key === 'T' ) {
2157- setShowTimezones ( prev => {
2158- if ( ! prev ) setShowGraticule ( false )
2159- return ! prev
2160- } )
2304+ if ( ! showTimezones ) {
2305+ setShowGraticule ( false )
2306+ setTimeout ( ( ) => setShowTimezones ( true ) , 50 )
2307+ } else {
2308+ setShowTimezones ( false )
2309+ setTimeout ( ( ) => setShowGraticule ( true ) , 50 )
2310+ }
21612311 }
21622312
21632313 // G for graticule toggle
21642314 if ( e . key === 'g' || e . key === 'G' ) {
2165- setShowGraticule ( prev => {
2166- if ( ! prev ) setShowTimezones ( false )
2167- return ! prev
2168- } )
2315+ if ( ! showGraticule ) {
2316+ setShowTimezones ( false )
2317+ setTimeout ( ( ) => setShowGraticule ( true ) , 50 )
2318+ } else {
2319+ setShowGraticule ( false )
2320+ }
21692321 }
21702322
21712323 // L for twilight lines toggle
@@ -2181,7 +2333,7 @@ function App() {
21812333 window . removeEventListener ( 'keydown' , handleKeyPress )
21822334 }
21832335
2184- } , [ ] )
2336+ } , [ showTimezones , showGraticule ] )
21852337
21862338 const centerCameraOnFlight = ( departure , arrival , flightDistance ) => {
21872339 const camera = cameraRef . current
@@ -3037,8 +3189,13 @@ function App() {
30373189 type = "checkbox"
30383190 checked = { showGraticule }
30393191 onChange = { ( e ) => {
3040- setShowGraticule ( e . target . checked )
3041- if ( e . target . checked ) setShowTimezones ( false )
3192+ const checked = e . target . checked
3193+ if ( checked ) {
3194+ setShowTimezones ( false )
3195+ setTimeout ( ( ) => setShowGraticule ( true ) , 50 )
3196+ } else {
3197+ setShowGraticule ( false )
3198+ }
30423199 } }
30433200 />
30443201 < span > < span className = "key-circle" > Ⓖ</ span > < span className = "toggle-label-text" > Graticule</ span > </ span >
@@ -3062,8 +3219,14 @@ function App() {
30623219 type = "checkbox"
30633220 checked = { showTimezones }
30643221 onChange = { ( e ) => {
3065- setShowTimezones ( e . target . checked )
3066- if ( e . target . checked ) setShowGraticule ( false )
3222+ const checked = e . target . checked
3223+ if ( checked ) {
3224+ setShowGraticule ( false )
3225+ setTimeout ( ( ) => setShowTimezones ( true ) , 50 )
3226+ } else {
3227+ setShowTimezones ( false )
3228+ setTimeout ( ( ) => setShowGraticule ( true ) , 50 )
3229+ }
30673230 } }
30683231 />
30693232 < span > < span className = "key-circle" > Ⓣ</ span > < span className = "toggle-label-text" > Timezones</ span > </ span >
@@ -3200,6 +3363,24 @@ function App() {
32003363 />
32013364 ) }
32023365
3366+ < div
3367+ ref = { tooltipRef }
3368+ style = { {
3369+ display : 'none' ,
3370+ position : 'fixed' ,
3371+ pointerEvents : 'none' ,
3372+ zIndex : 1000 ,
3373+ background : isBWMode ? BG_COLOR_BW : BG_COLOR_DARK ,
3374+ color : isBWMode ? '#1a1a1a' : '#ffffff' ,
3375+ padding : '6px 12px' ,
3376+ borderRadius : '6px' ,
3377+ fontSize : '12px' ,
3378+ fontFamily : 'inherit' ,
3379+ whiteSpace : 'nowrap' ,
3380+ letterSpacing : '0.02em'
3381+ } }
3382+ />
3383+
32033384 < Analytics />
32043385 </ div >
32053386 )
0 commit comments