Skip to content

Commit 7582ba8

Browse files
committed
v0.9.6 — Reposition twilight labels, add timezone tooltip and highlight
1 parent d708044 commit 7582ba8

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
@@ -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

Comments
 (0)