Skip to content

Commit d6a951f

Browse files
committed
feat(globe): add real-time solar day/night terminator and shading
1 parent 655bf29 commit d6a951f

5 files changed

Lines changed: 181 additions & 0 deletions

File tree

Globe.qml

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,15 @@ Item {
3434
property color textColor: "#f3f4f5"
3535
property string fontFamily: "monospace"
3636

37+
property bool showTerminator: true
38+
property var subsolarPoint: RadioModel.subsolarPoint(new Date())
39+
property color nightColor: "#040608"
40+
property real nightOpacity: 0.44
41+
property color terminatorColor: accentColor
42+
property real terminatorOpacity: 0.45
43+
3744
property var hoveredStation: null
45+
3846
property real hoverX: 0
3947
property real hoverY: 0
4048
property var highlightedStation: null
@@ -428,6 +436,90 @@ Item {
428436
}
429437
}
430438

439+
function paintSolarTerminator(ctx, centreX, centreY, globeRadius) {
440+
if (!subsolarPoint) return
441+
var sLat = Number(subsolarPoint.latitude) * Math.PI / 180
442+
var sLon = Number(subsolarPoint.longitude) * Math.PI / 180
443+
var cosSLat = Math.cos(sLat)
444+
var sx = cosSLat * Math.cos(sLon)
445+
var sy = cosSLat * Math.sin(sLon)
446+
var sz = Math.sin(sLat)
447+
448+
var latitude = centreLatitude * Math.PI / 180
449+
var longitude = centreLongitude * Math.PI / 180
450+
var sinLatitude = Math.sin(latitude)
451+
var cosLatitude = Math.cos(latitude)
452+
var sinLongitude = Math.sin(longitude)
453+
var cosLongitude = Math.cos(longitude)
454+
455+
var horizontal = sx * cosLongitude + sy * sinLongitude
456+
var sCamX = sy * cosLongitude - sx * sinLongitude
457+
var sCamY = cosLatitude * sz - sinLatitude * horizontal
458+
var sCamZ = sinLatitude * sz + cosLatitude * horizontal
459+
460+
var inPlaneLen = Math.hypot(sCamX, sCamY)
461+
462+
if (inPlaneLen < 1e-4) {
463+
if (sCamZ < 0) {
464+
ctx.beginPath()
465+
ctx.arc(centreX, centreY, globeRadius, 0, Math.PI * 2)
466+
ctx.fillStyle = withAlpha(nightColor, nightOpacity)
467+
ctx.fill()
468+
}
469+
return
470+
}
471+
472+
var sunAngleMath = Math.atan2(sCamY, sCamX)
473+
var sunAngleScreen = Math.atan2(-sCamY, sCamX)
474+
var cosSun = Math.cos(sunAngleMath)
475+
var sinSun = Math.sin(sunAngleMath)
476+
477+
var steps = 64
478+
var ellipsePoints = []
479+
for (var i = 0; i <= steps; i++) {
480+
var t = -Math.PI / 2 + (i / steps) * Math.PI
481+
var v = Math.sin(t)
482+
var u = -sCamZ * Math.cos(t)
483+
484+
var x = u * cosSun - v * sinSun
485+
var y = u * sinSun + v * cosSun
486+
487+
var sxScreen = centreX + x * globeRadius
488+
var syScreen = centreY - y * globeRadius
489+
ellipsePoints.push({ x: sxScreen, y: syScreen })
490+
}
491+
492+
var angleStartScreen = Math.atan2(ellipsePoints[0].y - centreY, ellipsePoints[0].x - centreX)
493+
var angleEndScreen = Math.atan2(ellipsePoints[steps].y - centreY, ellipsePoints[steps].x - centreX)
494+
495+
var awayFromSun = (sunAngleScreen + Math.PI * 3) % (Math.PI * 2)
496+
var angleEndWrapped = (angleEndScreen + Math.PI * 2) % (Math.PI * 2)
497+
var angleStartWrapped = (angleStartScreen + Math.PI * 2) % (Math.PI * 2)
498+
499+
var diff = (angleStartWrapped - angleEndWrapped + Math.PI * 2) % (Math.PI * 2)
500+
var diffSun = (awayFromSun - angleEndWrapped + Math.PI * 2) % (Math.PI * 2)
501+
var anticlockwise = !(diffSun < diff)
502+
503+
ctx.beginPath()
504+
ctx.moveTo(ellipsePoints[0].x, ellipsePoints[0].y)
505+
for (var j = 1; j <= steps; j++) {
506+
ctx.lineTo(ellipsePoints[j].x, ellipsePoints[j].y)
507+
}
508+
ctx.arc(centreX, centreY, globeRadius, angleEndScreen, angleStartScreen, anticlockwise)
509+
ctx.closePath()
510+
ctx.fillStyle = withAlpha(nightColor, nightOpacity)
511+
ctx.fill()
512+
513+
ctx.beginPath()
514+
ctx.moveTo(ellipsePoints[0].x, ellipsePoints[0].y)
515+
for (var k = 1; k <= steps; k++) {
516+
ctx.lineTo(ellipsePoints[k].x, ellipsePoints[k].y)
517+
}
518+
ctx.strokeStyle = withAlpha(terminatorColor, terminatorOpacity)
519+
ctx.lineWidth = Math.min(2.0, Math.max(1.0, globeRadius / 400))
520+
ctx.stroke()
521+
}
522+
431523
function paintGlobe(ctx) {
432524
var centreX = globeCanvas.width / 2
433525
var centreY = globeCanvas.height / 2
@@ -455,6 +547,7 @@ Item {
455547
ctx.clip()
456548
paintGrid(ctx, centreX, centreY, globeRadius)
457549
paintCountries(ctx, centreX, centreY, globeRadius)
550+
if (showTerminator) paintSolarTerminator(ctx, centreX, centreY, globeRadius)
458551
paintSignals(ctx)
459552
ctx.restore()
460553

@@ -543,6 +636,30 @@ Item {
543636
onVisibleChanged: {
544637
if (!visible) stopKineticRotation(true)
545638
}
639+
onSubsolarPointChanged: globeCanvas.requestPaint()
640+
onShowTerminatorChanged: {
641+
if (showTerminator && visible) refreshSolarPosition()
642+
globeCanvas.requestPaint()
643+
}
644+
onNightColorChanged: globeCanvas.requestPaint()
645+
onNightOpacityChanged: globeCanvas.requestPaint()
646+
onTerminatorColorChanged: globeCanvas.requestPaint()
647+
onTerminatorOpacityChanged: globeCanvas.requestPaint()
648+
649+
function refreshSolarPosition() {
650+
root.subsolarPoint = RadioModel.subsolarPoint(new Date())
651+
}
652+
653+
Timer {
654+
id: solarTimer
655+
interval: 60000
656+
running: root.visible && root.showTerminator
657+
repeat: true
658+
onRunningChanged: {
659+
if (running) root.refreshSolarPosition()
660+
}
661+
onTriggered: root.refreshSolarPosition()
662+
}
546663

547664
Canvas {
548665
id: globeCanvas

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ usual play, pause, previous, and next controls.
1212
## Features
1313

1414
- Kinetic drag rotation that highlights a nearby station when it settles, plus deep wheel zoom on a theme-aware globe
15+
- Real-time solar day/night terminator with night-side shading across the globe
1516
- A fast cached world view that progressively adds thousands of stations and keeps the session catalog when closed
1617
- Country stations stay on the session globe and take priority over background signals
1718
- Country-level map estimates when a station has no published coordinates

RadioAtlas.qml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ Item {
113113
property color mapSphere: lightTheme ? "#d2d0ca" : "#11151a"
114114
property color mapLand: lightTheme ? "#a9aaa6" : "#283039"
115115
property color mapGrid: lightTheme ? "#3f454a" : "#7d8791"
116+
property color mapNight: lightTheme ? "#1b2028" : "#040608"
117+
property color mapTerminator: accent
118+
116119

117120
property bool windowSetupReady: false
118121
property bool windowFrameReady: false
@@ -1420,7 +1423,10 @@ Item {
14201423
sphereColor: root.mapSphere
14211424
landColor: root.mapLand
14221425
gridColor: root.mapGrid
1426+
nightColor: root.mapNight
1427+
terminatorColor: root.mapTerminator
14231428
outlineColor: root.lightTheme ? "#3c4247" : "#9099a3"
1429+
14241430
signalColor: root.lightTheme ? "#202428" : "#d9dee3"
14251431
accentColor: root.accent
14261432
textColor: root.foreground

RadioModel.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,3 +567,44 @@ function indexByUuid(stations, uuid) {
567567
for (var i = 0; i < rows.length; i++) if (rows[i].uuid === uuid) return i
568568
return -1
569569
}
570+
571+
function subsolarPoint(date) {
572+
var d = date || new Date()
573+
var ms = d.getTime()
574+
var julianDate = ms / 86400000 + 2440587.5
575+
var t = (julianDate - 2451545.0) / 36525.0
576+
577+
var L0 = (280.46646 + t * (36000.76983 + t * 0.0003032)) % 360
578+
if (L0 < 0) L0 += 360
579+
var M = (357.52911 + t * (35999.05029 - t * 0.0001537)) % 360
580+
if (M < 0) M += 360
581+
var e = 0.016708634 - t * (0.000042037 + 0.0000001267 * t)
582+
583+
var C = Math.sin(M * radians) * (1.914602 - t * (0.004817 + 0.000014 * t))
584+
+ Math.sin(2 * M * radians) * (0.019993 - 0.000101 * t)
585+
+ Math.sin(3 * M * radians) * 0.000289
586+
var sunTrueLong = L0 + C
587+
588+
var eps0 = 23.439291 - t * (0.013004167 + t * (0.00000016667 - t * 0.000000502778))
589+
var omega = 125.04 - 1934.136 * t
590+
var eps = eps0 + 0.00256 * Math.cos(omega * radians)
591+
var lambdaApparent = sunTrueLong - 0.00569 - 0.00478 * Math.sin(omega * radians)
592+
593+
var sinDec = Math.sin(eps * radians) * Math.sin(lambdaApparent * radians)
594+
var declination = Math.asin(sinDec) * degrees
595+
596+
var y = Math.pow(Math.tan((eps / 2) * radians), 2)
597+
var eot = 4 * degrees * (
598+
y * Math.sin(2 * L0 * radians)
599+
- 2 * e * Math.sin(M * radians)
600+
+ 4 * e * y * Math.sin(M * radians) * Math.cos(2 * L0 * radians)
601+
- 0.5 * y * y * Math.sin(4 * L0 * radians)
602+
- 1.25 * e * e * Math.sin(2 * M * radians)
603+
)
604+
605+
var utcSeconds = d.getUTCHours() * 3600 + d.getUTCMinutes() * 60 + d.getUTCSeconds() + d.getUTCMilliseconds() / 1000
606+
var subsolarLong = wrapLongitude(-((utcSeconds / 3600 - 12) * 15 + eot / 4))
607+
608+
return { latitude: declination, longitude: subsolarLong }
609+
}
610+

tests/model.test.mjs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,4 +334,20 @@ assert.deepEqual(
334334
)
335335
assert.deepEqual(Array.from(model.stationWindow(playlistRows, "missing", 5)), [])
336336

337+
// Solar day/night terminator tests
338+
const summerSolstice = model.subsolarPoint(new Date("2026-06-21T12:00:00Z"))
339+
assert.ok(summerSolstice.latitude > 23.4 && summerSolstice.latitude < 23.5)
340+
assert.ok(Math.abs(summerSolstice.longitude) < 2)
341+
342+
const winterSolstice = model.subsolarPoint(new Date("2026-12-21T12:00:00Z"))
343+
assert.ok(winterSolstice.latitude < -23.4 && winterSolstice.latitude > -23.5)
344+
assert.ok(Math.abs(winterSolstice.longitude) < 2)
345+
346+
const springEquinox = model.subsolarPoint(new Date("2026-03-20T12:00:00Z"))
347+
assert.ok(Math.abs(springEquinox.latitude) < 0.5)
348+
349+
const midnightUtc = model.subsolarPoint(new Date("2026-03-20T00:00:00Z"))
350+
assert.ok(Math.abs(midnightUtc.longitude) > 175)
351+
337352
console.log("RadioModel tests passed")
353+

0 commit comments

Comments
 (0)