Skip to content

Commit 50f1926

Browse files
committed
feat(globe): add real-time solar day/night terminator and shading
1 parent 1738fa1 commit 50f1926

4 files changed

Lines changed: 245 additions & 0 deletions

File tree

Globe.qml

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,15 @@ Item {
2727
property color textColor: "#f3f4f5"
2828
property string fontFamily: "monospace"
2929

30+
property bool showTerminator: true
31+
property var subsolarPoint: RadioModel.subsolarPoint(new Date())
32+
property color nightColor: "#040608"
33+
property real nightOpacity: 0.44
34+
property color twilightColor: accentColor
35+
property real twilightOpacity: 0.45
36+
3037
property var hoveredStation: null
38+
3139
property real hoverX: 0
3240
property real hoverY: 0
3341
property var preparedCountries: []
@@ -328,6 +336,90 @@ Item {
328336
}
329337
}
330338

339+
function paintSolarTerminator(ctx, centreX, centreY, globeRadius) {
340+
if (!subsolarPoint) return
341+
var sLat = Number(subsolarPoint.latitude) * Math.PI / 180
342+
var sLon = Number(subsolarPoint.longitude) * Math.PI / 180
343+
var cosSLat = Math.cos(sLat)
344+
var sx = cosSLat * Math.cos(sLon)
345+
var sy = cosSLat * Math.sin(sLon)
346+
var sz = Math.sin(sLat)
347+
348+
var latitude = centreLatitude * Math.PI / 180
349+
var longitude = centreLongitude * Math.PI / 180
350+
var sinLatitude = Math.sin(latitude)
351+
var cosLatitude = Math.cos(latitude)
352+
var sinLongitude = Math.sin(longitude)
353+
var cosLongitude = Math.cos(longitude)
354+
355+
var horizontal = sx * cosLongitude + sy * sinLongitude
356+
var sCamX = sy * cosLongitude - sx * sinLongitude
357+
var sCamY = cosLatitude * sz - sinLatitude * horizontal
358+
var sCamZ = sinLatitude * sz + cosLatitude * horizontal
359+
360+
var inPlaneLen = Math.hypot(sCamX, sCamY)
361+
362+
if (inPlaneLen < 1e-4) {
363+
if (sCamZ < 0) {
364+
ctx.beginPath()
365+
ctx.arc(centreX, centreY, globeRadius, 0, Math.PI * 2)
366+
ctx.fillStyle = withAlpha(nightColor, nightOpacity)
367+
ctx.fill()
368+
}
369+
return
370+
}
371+
372+
var sunAngleMath = Math.atan2(sCamY, sCamX)
373+
var sunAngleScreen = Math.atan2(-sCamY, sCamX)
374+
var cosSun = Math.cos(sunAngleMath)
375+
var sinSun = Math.sin(sunAngleMath)
376+
377+
var steps = 64
378+
var ellipsePoints = []
379+
for (var i = 0; i <= steps; i++) {
380+
var t = -Math.PI / 2 + (i / steps) * Math.PI
381+
var v = Math.sin(t)
382+
var u = -sCamZ * Math.cos(t)
383+
384+
var x = u * cosSun - v * sinSun
385+
var y = u * sinSun + v * cosSun
386+
387+
var sxScreen = centreX + x * globeRadius
388+
var syScreen = centreY - y * globeRadius
389+
ellipsePoints.push({ x: sxScreen, y: syScreen })
390+
}
391+
392+
var angleStartScreen = Math.atan2(ellipsePoints[0].y - centreY, ellipsePoints[0].x - centreX)
393+
var angleEndScreen = Math.atan2(ellipsePoints[steps].y - centreY, ellipsePoints[steps].x - centreX)
394+
395+
var awayFromSun = (sunAngleScreen + Math.PI * 3) % (Math.PI * 2)
396+
var angleEndWrapped = (angleEndScreen + Math.PI * 2) % (Math.PI * 2)
397+
var angleStartWrapped = (angleStartScreen + Math.PI * 2) % (Math.PI * 2)
398+
399+
var diff = (angleStartWrapped - angleEndWrapped + Math.PI * 2) % (Math.PI * 2)
400+
var diffSun = (awayFromSun - angleEndWrapped + Math.PI * 2) % (Math.PI * 2)
401+
var anticlockwise = !(diffSun < diff)
402+
403+
ctx.beginPath()
404+
ctx.moveTo(ellipsePoints[0].x, ellipsePoints[0].y)
405+
for (var j = 1; j <= steps; j++) {
406+
ctx.lineTo(ellipsePoints[j].x, ellipsePoints[j].y)
407+
}
408+
ctx.arc(centreX, centreY, globeRadius, angleEndScreen, angleStartScreen, anticlockwise)
409+
ctx.closePath()
410+
ctx.fillStyle = withAlpha(nightColor, nightOpacity)
411+
ctx.fill()
412+
413+
ctx.beginPath()
414+
ctx.moveTo(ellipsePoints[0].x, ellipsePoints[0].y)
415+
for (var k = 1; k <= steps; k++) {
416+
ctx.lineTo(ellipsePoints[k].x, ellipsePoints[k].y)
417+
}
418+
ctx.strokeStyle = withAlpha(twilightColor, twilightOpacity)
419+
ctx.lineWidth = Math.min(2.0, Math.max(1.0, globeRadius / 400))
420+
ctx.stroke()
421+
}
422+
331423
function paintGlobe(ctx) {
332424
var centreX = globeCanvas.width / 2
333425
var centreY = globeCanvas.height / 2
@@ -355,6 +447,7 @@ Item {
355447
ctx.clip()
356448
paintGrid(ctx, centreX, centreY, globeRadius)
357449
paintCountries(ctx, centreX, centreY, globeRadius)
450+
if (showTerminator) paintSolarTerminator(ctx, centreX, centreY, globeRadius)
358451
paintSignals(ctx)
359452
ctx.restore()
360453

@@ -416,6 +509,20 @@ Item {
416509
onGlobeScaleChanged: globeCanvas.requestPaint()
417510
onWidthChanged: globeCanvas.requestPaint()
418511
onHeightChanged: globeCanvas.requestPaint()
512+
onSubsolarPointChanged: globeCanvas.requestPaint()
513+
onShowTerminatorChanged: globeCanvas.requestPaint()
514+
onNightColorChanged: globeCanvas.requestPaint()
515+
onTwilightColorChanged: globeCanvas.requestPaint()
516+
517+
Timer {
518+
id: solarTimer
519+
interval: 60000
520+
running: true
521+
repeat: true
522+
onTriggered: {
523+
root.subsolarPoint = RadioModel.subsolarPoint(new Date())
524+
}
525+
}
419526

420527
Canvas {
421528
id: globeCanvas

RadioAtlas.qml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ Item {
103103
property color mapSphere: lightTheme ? "#d2d0ca" : "#11151a"
104104
property color mapLand: lightTheme ? "#a9aaa6" : "#283039"
105105
property color mapGrid: lightTheme ? "#3f454a" : "#7d8791"
106+
property color mapNight: lightTheme ? "#1b2028" : "#040608"
107+
property color mapTwilight: accent
108+
106109

107110
readonly property int cardWidth: Math.min(Style.space(1180), panel.width - Style.gapsOut * 2)
108111
readonly property int cardHeight: Math.min(Style.space(760), panel.height - Style.gapsOut * 2)
@@ -1228,7 +1231,10 @@ Item {
12281231
sphereColor: root.mapSphere
12291232
landColor: root.mapLand
12301233
gridColor: root.mapGrid
1234+
nightColor: root.mapNight
1235+
twilightColor: root.mapTwilight
12311236
outlineColor: root.lightTheme ? "#3c4247" : "#9099a3"
1237+
12321238
signalColor: root.lightTheme ? "#202428" : "#d9dee3"
12331239
accentColor: root.accent
12341240
textColor: root.foreground

RadioModel.js

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,3 +404,103 @@ function indexByUuid(stations, uuid) {
404404
for (var i = 0; i < rows.length; i++) if (rows[i].uuid === uuid) return i
405405
return -1
406406
}
407+
408+
function subsolarPoint(date) {
409+
var d = date || new Date()
410+
var ms = d.getTime()
411+
var julianDate = ms / 86400000 + 2440587.5
412+
var t = (julianDate - 2451545.0) / 36525.0
413+
414+
var L0 = (280.46646 + t * (36000.76983 + t * 0.0003032)) % 360
415+
if (L0 < 0) L0 += 360
416+
var M = (357.52911 + t * (35999.05029 - t * 0.0001537)) % 360
417+
if (M < 0) M += 360
418+
var e = 0.016708634 - t * (0.000042037 + 0.0000001267 * t)
419+
420+
var C = Math.sin(M * radians) * (1.914602 - t * (0.004817 + 0.000014 * t))
421+
+ Math.sin(2 * M * radians) * (0.019993 - 0.000101 * t)
422+
+ Math.sin(3 * M * radians) * 0.000289
423+
var sunTrueLong = L0 + C
424+
425+
var eps0 = 23.439291 - t * (0.013004167 + t * (0.00000016667 - t * 0.000000502778))
426+
var omega = 125.04 - 1934.136 * t
427+
var eps = eps0 + 0.00256 * Math.cos(omega * radians)
428+
var lambdaApparent = sunTrueLong - 0.00569 - 0.00478 * Math.sin(omega * radians)
429+
430+
var sinDec = Math.sin(eps * radians) * Math.sin(lambdaApparent * radians)
431+
var declination = Math.asin(sinDec) * degrees
432+
433+
var y = Math.pow(Math.tan((eps / 2) * radians), 2)
434+
var eot = 4 * degrees * (
435+
y * Math.sin(2 * L0 * radians)
436+
- 2 * e * Math.sin(M * radians)
437+
+ 4 * e * y * Math.sin(M * radians) * Math.cos(2 * L0 * radians)
438+
- 0.5 * y * y * Math.sin(4 * L0 * radians)
439+
- 1.25 * e * e * Math.sin(2 * M * radians)
440+
)
441+
442+
var utcSeconds = d.getUTCHours() * 3600 + d.getUTCMinutes() * 60 + d.getUTCSeconds() + d.getUTCMilliseconds() / 1000
443+
var subsolarLong = wrapLongitude(-((utcSeconds / 3600 - 12) * 15 + eot / 4))
444+
445+
return { latitude: declination, longitude: subsolarLong }
446+
}
447+
448+
function terminatorGeometry(subsolarLatitude, subsolarLongitude, steps) {
449+
var resolution = Math.max(24, Math.min(360, Number(steps || 120)))
450+
var sLat = Number(subsolarLatitude) * radians
451+
var sLon = Number(subsolarLongitude) * radians
452+
var cosLat = Math.cos(sLat)
453+
var sx = cosLat * Math.cos(sLon)
454+
var sy = cosLat * Math.sin(sLon)
455+
var sz = Math.sin(sLat)
456+
457+
var ux, uy, uz
458+
if (Math.abs(sz) < 0.9999) {
459+
var uLen = Math.hypot(-sy, sx) || 1
460+
ux = -sy / uLen
461+
uy = sx / uLen
462+
uz = 0
463+
} else {
464+
ux = 1
465+
uy = 0
466+
uz = 0
467+
}
468+
469+
var vx = sy * uz - sz * uy
470+
var vy = sz * ux - sx * uz
471+
var vz = sx * uy - sy * ux
472+
473+
var output = []
474+
for (var i = 0; i < resolution; i++) {
475+
var alpha = (i / resolution) * Math.PI * 2
476+
var cosA = Math.cos(alpha)
477+
var sinA = Math.sin(alpha)
478+
output.push(
479+
ux * cosA + vx * sinA,
480+
uy * cosA + vy * sinA,
481+
uz * cosA + vz * sinA
482+
)
483+
}
484+
return output
485+
}
486+
487+
function isNightAt(latitude, longitude, subsolarLatitude, subsolarLongitude) {
488+
if (latitude === null || longitude === null || !isFinite(Number(latitude)) || !isFinite(Number(longitude)))
489+
return false
490+
var pLat = Number(latitude) * radians
491+
var pLon = Number(longitude) * radians
492+
var sLat = Number(subsolarLatitude) * radians
493+
var sLon = Number(subsolarLongitude) * radians
494+
495+
var px = Math.cos(pLat) * Math.cos(pLon)
496+
var py = Math.cos(pLat) * Math.sin(pLon)
497+
var pz = Math.sin(pLat)
498+
499+
var sx = Math.cos(sLat) * Math.cos(sLon)
500+
var sy = Math.cos(sLat) * Math.sin(sLon)
501+
var sz = Math.sin(sLat)
502+
503+
var dot = px * sx + py * sy + pz * sz
504+
return dot < 0
505+
}
506+

tests/model.test.mjs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,4 +193,36 @@ assert.deepEqual(
193193
)
194194
assert.deepEqual(Array.from(model.stationWindow(playlistRows, "missing", 5)), [])
195195

196+
// Solar day/night terminator tests
197+
const summerSolstice = model.subsolarPoint(new Date("2026-06-21T12:00:00Z"))
198+
assert.ok(summerSolstice.latitude > 23.4 && summerSolstice.latitude < 23.5)
199+
assert.ok(Math.abs(summerSolstice.longitude) < 2)
200+
201+
const winterSolstice = model.subsolarPoint(new Date("2026-12-21T12:00:00Z"))
202+
assert.ok(winterSolstice.latitude < -23.4 && winterSolstice.latitude > -23.5)
203+
assert.ok(Math.abs(winterSolstice.longitude) < 2)
204+
205+
const springEquinox = model.subsolarPoint(new Date("2026-03-20T12:00:00Z"))
206+
assert.ok(Math.abs(springEquinox.latitude) < 0.5)
207+
208+
const midnightUtc = model.subsolarPoint(new Date("2026-03-20T00:00:00Z"))
209+
assert.ok(Math.abs(midnightUtc.longitude) > 175)
210+
211+
const terminator = model.terminatorGeometry(23.44, 0, 120)
212+
assert.equal(terminator.length, 360) // 120 points * 3 coordinates
213+
// Test every point on terminator is on unit sphere and orthogonal to sun
214+
for (let i = 0; i < terminator.length; i += 3) {
215+
const x = terminator[i]
216+
const y = terminator[i + 1]
217+
const z = terminator[i + 2]
218+
const len = Math.hypot(x, y, z)
219+
assert.ok(Math.abs(len - 1.0) < 1e-6)
220+
}
221+
222+
// isNightAt test
223+
assert.equal(model.isNightAt(summerSolstice.latitude, summerSolstice.longitude, summerSolstice.latitude, summerSolstice.longitude), false) // Noon at subsolar point is Day
224+
assert.equal(model.isNightAt(-summerSolstice.latitude, model.wrapLongitude(summerSolstice.longitude + 180), summerSolstice.latitude, summerSolstice.longitude), true) // Antipodal point is Night
225+
assert.equal(model.isNightAt(null, null, 0, 0), false)
226+
196227
console.log("RadioModel tests passed")
228+

0 commit comments

Comments
 (0)