@@ -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
@@ -1807,6 +1822,7 @@ function App() {
18071822
18081823 // Effect to show/hide timezone boundaries
18091824 useEffect ( ( ) => {
1825+ if ( tooltipRef . current ) tooltipRef . current . style . display = 'none'
18101826 if ( ! sceneRef . current ) return
18111827
18121828 // Remove existing timezones if exists
@@ -1838,41 +1854,39 @@ function App() {
18381854
18391855 // Process each feature (timezone boundary)
18401856 data . features . forEach ( ( feature , featureIndex ) => {
1841- const timezoneName = feature . properties . tzid || feature . properties . name || `timezone-${ featureIndex } `
1842-
18431857 if ( feature . geometry . type === 'Polygon' ) {
18441858 feature . geometry . coordinates . forEach ( ring => {
1845- const points = ring . map ( coord =>
1859+ const points = ring . map ( coord =>
18461860 latLonToVector3 ( coord [ 1 ] , coord [ 0 ] , 2.005 )
18471861 )
1848-
1862+
18491863 const lineGeometry = new THREE . BufferGeometry ( ) . setFromPoints ( points )
18501864 const lineMaterial = new THREE . LineBasicMaterial ( {
18511865 color : isBWMode ? 0x0f0f0f : 0xffffff , // Check current BW mode state
18521866 transparent : true ,
18531867 opacity : 0
18541868 } )
1855-
1869+
18561870 const line = new THREE . Line ( lineGeometry , lineMaterial )
1857- line . userData . timezone = timezoneName // Store timezone identifier
1871+ line . userData . featureIndex = featureIndex
18581872 timezoneGroup . add ( line )
18591873 } )
18601874 } else if ( feature . geometry . type === 'MultiPolygon' ) {
18611875 feature . geometry . coordinates . forEach ( polygon => {
18621876 polygon . forEach ( ring => {
1863- const points = ring . map ( coord =>
1877+ const points = ring . map ( coord =>
18641878 latLonToVector3 ( coord [ 1 ] , coord [ 0 ] , 2.005 )
18651879 )
1866-
1880+
18671881 const lineGeometry = new THREE . BufferGeometry ( ) . setFromPoints ( points )
18681882 const lineMaterial = new THREE . LineBasicMaterial ( {
18691883 color : isBWMode ? 0x0f0f0f : 0xffffff , // Check current BW mode state
18701884 transparent : true ,
18711885 opacity : 0
18721886 } )
1873-
1887+
18741888 const line = new THREE . Line ( lineGeometry , lineMaterial )
1875- line . userData . timezone = timezoneName // Store timezone identifier
1889+ line . userData . featureIndex = featureIndex
18761890 timezoneGroup . add ( line )
18771891 } )
18781892 } )
@@ -1992,6 +2006,139 @@ function App() {
19922006
19932007 } , [ showTimezones ] )
19942008
2009+ // Tooltip: show IANA timezone on hover when timezone boundaries are visible
2010+ useEffect ( ( ) => {
2011+ const canvas = canvasRef . current
2012+ if ( ! canvas || isMobile ) return
2013+
2014+ const resetHighlight = ( ) => {
2015+ highlightedTimezoneRef . current = null
2016+ const tzGroup = sceneRef . current ?. getObjectByName ( 'timezone-boundaries' )
2017+ if ( tzGroup ) {
2018+ tzGroup . traverse ( ( child ) => {
2019+ if ( child . material && child . userData . featureIndex !== undefined ) {
2020+ child . material . opacity = 0.3
2021+ child . material . color . setHex ( isBWMode ? 0x0f0f0f : 0xffffff )
2022+ }
2023+ } )
2024+ }
2025+ }
2026+
2027+ const handleMouseMove = ( e ) => {
2028+ if ( ! showTimezones || ! tooltipRef . current || ! cameraRef . current || ! sceneRef . current ) {
2029+ if ( tooltipRef . current ) tooltipRef . current . style . display = 'none'
2030+ return
2031+ }
2032+
2033+ const rect = canvas . getBoundingClientRect ( )
2034+ mouseRef . current . x = ( ( e . clientX - rect . left ) / rect . width ) * 2 - 1
2035+ mouseRef . current . y = - ( ( e . clientY - rect . top ) / rect . height ) * 2 + 1
2036+
2037+ raycasterRef . current . setFromCamera ( mouseRef . current , cameraRef . current )
2038+
2039+ const earthMesh = sceneRef . current . getObjectByName ( 'earth-sphere' )
2040+ if ( ! earthMesh ) {
2041+ tooltipRef . current . style . display = 'none'
2042+ return
2043+ }
2044+
2045+ const intersects = raycasterRef . current . intersectObject ( earthMesh )
2046+
2047+ if ( intersects . length > 0 ) {
2048+ const point = intersects [ 0 ] . point
2049+ const { lat, lon } = vector3ToLatLon ( point )
2050+
2051+ try {
2052+ const tz = tzlookup ( lat , lon )
2053+ const now = new Date ( )
2054+ const formatter = new Intl . DateTimeFormat ( 'en-US' , {
2055+ timeZone : tz ,
2056+ timeZoneName : 'shortOffset'
2057+ } )
2058+ const parts = formatter . formatToParts ( now )
2059+ const offsetPart = parts . find ( p => p . type === 'timeZoneName' )
2060+ const offset = offsetPart ? offsetPart . value : ''
2061+
2062+ tooltipRef . current . textContent = `${ tz } · ${ offset } `
2063+ tooltipRef . current . style . display = 'block'
2064+ tooltipRef . current . style . left = `${ e . clientX + 16 } px`
2065+ tooltipRef . current . style . top = `${ e . clientY + 16 } px`
2066+
2067+ // Throttle point-in-polygon search to when mouse moves > 3px
2068+ const dx = e . clientX - ( lastMousePos . current ?. x || 0 )
2069+ const dy = e . clientY - ( lastMousePos . current ?. y || 0 )
2070+ if ( dx * dx + dy * dy >= 9 ) {
2071+ lastMousePos . current = { x : e . clientX , y : e . clientY }
2072+
2073+ // Find which GeoJSON feature contains this point
2074+ let matchedFeatureIndex = null
2075+ const tzData = timezoneDataRef . current
2076+ if ( tzData ) {
2077+ for ( let i = 0 ; i < tzData . features . length ; i ++ ) {
2078+ const feature = tzData . features [ i ]
2079+ const geom = feature . geometry
2080+ let polygons = [ ]
2081+
2082+ if ( geom . type === 'Polygon' ) {
2083+ polygons = [ geom . coordinates ]
2084+ } else if ( geom . type === 'MultiPolygon' ) {
2085+ polygons = geom . coordinates
2086+ }
2087+
2088+ for ( const polygon of polygons ) {
2089+ if ( pointInPolygon ( [ lon , lat ] , polygon ) ) {
2090+ matchedFeatureIndex = i
2091+ break
2092+ }
2093+ }
2094+ if ( matchedFeatureIndex !== null ) break
2095+ }
2096+ }
2097+
2098+ if ( matchedFeatureIndex !== null && matchedFeatureIndex !== highlightedTimezoneRef . current ) {
2099+ highlightedTimezoneRef . current = matchedFeatureIndex
2100+ const tzGroup = sceneRef . current . getObjectByName ( 'timezone-boundaries' )
2101+ if ( tzGroup ) {
2102+ tzGroup . traverse ( ( child ) => {
2103+ if ( child . material && child . userData . featureIndex !== undefined ) {
2104+ if ( child . userData . featureIndex === matchedFeatureIndex ) {
2105+ child . material . opacity = 1.0
2106+ child . material . color . setHex ( isBWMode ? 0x000000 : 0xffffff )
2107+ } else {
2108+ child . material . opacity = 0.1
2109+ child . material . color . setHex ( isBWMode ? 0x0f0f0f : 0xffffff )
2110+ }
2111+ }
2112+ } )
2113+ }
2114+ } else if ( matchedFeatureIndex === null && highlightedTimezoneRef . current !== null ) {
2115+ resetHighlight ( )
2116+ }
2117+ }
2118+ } catch {
2119+ tooltipRef . current . style . display = 'none'
2120+ resetHighlight ( )
2121+ }
2122+ } else {
2123+ tooltipRef . current . style . display = 'none'
2124+ resetHighlight ( )
2125+ }
2126+ }
2127+
2128+ const handleMouseLeave = ( ) => {
2129+ if ( tooltipRef . current ) tooltipRef . current . style . display = 'none'
2130+ resetHighlight ( )
2131+ }
2132+
2133+ canvas . addEventListener ( 'mousemove' , handleMouseMove )
2134+ canvas . addEventListener ( 'mouseleave' , handleMouseLeave )
2135+
2136+ return ( ) => {
2137+ canvas . removeEventListener ( 'mousemove' , handleMouseMove )
2138+ canvas . removeEventListener ( 'mouseleave' , handleMouseLeave )
2139+ }
2140+ } , [ showTimezones , isMobile , isBWMode ] )
2141+
19952142 useEffect ( ( ) => {
19962143 if ( ! twilightLinesRef . current . terminatorDay ) return
19972144
@@ -2179,18 +2326,23 @@ function App() {
21792326
21802327 // T for timezones toggle
21812328 if ( e . key === 't' || e . key === 'T' ) {
2182- setShowTimezones ( prev => {
2183- if ( ! prev ) setShowGraticule ( false )
2184- return ! prev
2185- } )
2329+ if ( ! showTimezones ) {
2330+ setShowGraticule ( false )
2331+ setTimeout ( ( ) => setShowTimezones ( true ) , 50 )
2332+ } else {
2333+ setShowTimezones ( false )
2334+ setTimeout ( ( ) => setShowGraticule ( true ) , 50 )
2335+ }
21862336 }
21872337
21882338 // G for graticule toggle
21892339 if ( e . key === 'g' || e . key === 'G' ) {
2190- setShowGraticule ( prev => {
2191- if ( ! prev ) setShowTimezones ( false )
2192- return ! prev
2193- } )
2340+ if ( ! showGraticule ) {
2341+ setShowTimezones ( false )
2342+ setTimeout ( ( ) => setShowGraticule ( true ) , 50 )
2343+ } else {
2344+ setShowGraticule ( false )
2345+ }
21942346 }
21952347
21962348 // L for twilight lines toggle
@@ -2206,7 +2358,7 @@ function App() {
22062358 window . removeEventListener ( 'keydown' , handleKeyPress )
22072359 }
22082360
2209- } , [ ] )
2361+ } , [ showTimezones , showGraticule ] )
22102362
22112363 const centerCameraOnFlight = ( departure , arrival , flightDistance ) => {
22122364 const camera = cameraRef . current
@@ -3062,8 +3214,13 @@ function App() {
30623214 type = "checkbox"
30633215 checked = { showGraticule }
30643216 onChange = { ( e ) => {
3065- setShowGraticule ( e . target . checked )
3066- if ( e . target . checked ) setShowTimezones ( false )
3217+ const checked = e . target . checked
3218+ if ( checked ) {
3219+ setShowTimezones ( false )
3220+ setTimeout ( ( ) => setShowGraticule ( true ) , 50 )
3221+ } else {
3222+ setShowGraticule ( false )
3223+ }
30673224 } }
30683225 />
30693226 < span > < span className = "key-circle" > Ⓖ</ span > < span className = "toggle-label-text" > Graticule</ span > </ span >
@@ -3087,8 +3244,14 @@ function App() {
30873244 type = "checkbox"
30883245 checked = { showTimezones }
30893246 onChange = { ( e ) => {
3090- setShowTimezones ( e . target . checked )
3091- if ( e . target . checked ) setShowGraticule ( false )
3247+ const checked = e . target . checked
3248+ if ( checked ) {
3249+ setShowGraticule ( false )
3250+ setTimeout ( ( ) => setShowTimezones ( true ) , 50 )
3251+ } else {
3252+ setShowTimezones ( false )
3253+ setTimeout ( ( ) => setShowGraticule ( true ) , 50 )
3254+ }
30923255 } }
30933256 />
30943257 < span > < span className = "key-circle" > Ⓣ</ span > < span className = "toggle-label-text" > Timezones</ span > </ span >
@@ -3225,6 +3388,24 @@ function App() {
32253388 />
32263389 ) }
32273390
3391+ < div
3392+ ref = { tooltipRef }
3393+ style = { {
3394+ display : 'none' ,
3395+ position : 'fixed' ,
3396+ pointerEvents : 'none' ,
3397+ zIndex : 1000 ,
3398+ background : isBWMode ? BG_COLOR_BW : BG_COLOR_DARK ,
3399+ color : isBWMode ? '#1a1a1a' : '#ffffff' ,
3400+ padding : '6px 12px' ,
3401+ borderRadius : '6px' ,
3402+ fontSize : '12px' ,
3403+ fontFamily : 'inherit' ,
3404+ whiteSpace : 'nowrap' ,
3405+ letterSpacing : '0.02em'
3406+ } }
3407+ />
3408+
32283409 < Analytics />
32293410 </ div >
32303411 )
0 commit comments