Skip to content

Commit b2178b5

Browse files
committed
Merge branch 'dev'
2 parents 5295cc0 + 7582ba8 commit b2178b5

3 files changed

Lines changed: 210 additions & 27 deletions

File tree

package-lock.json

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "lightpath",
3-
"version": "0.9.5",
3+
"version": "0.9.6",
44
"private": true,
55
"type": "module",
66
"scripts": {
@@ -13,6 +13,7 @@
1313
"@vercel/analytics": "^1.6.1",
1414
"geo-tz": "^8.1.4",
1515
"luxon": "^3.7.2",
16+
"point-in-polygon-hao": "^1.2.4",
1617
"react": "^19.2.0",
1718
"react-dom": "^19.2.0",
1819
"react-markdown": "^10.1.0",

src/App.jsx

Lines changed: 205 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as THREE from 'three'
33
import './App.css'
44
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
55
import tzlookup from 'tz-lookup'
6+
import pointInPolygon from 'point-in-polygon-hao'
67
import { DateTime } from 'luxon'
78
import packageJson from '../package.json'
89
import 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

Comments
 (0)