Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions Globe.qml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,15 @@ Item {
property color textColor: "#f3f4f5"
property string fontFamily: "monospace"

property bool showTerminator: true
property var subsolarPoint: RadioModel.subsolarPoint(new Date())
property color nightColor: "#040608"
property real nightOpacity: 0.44
property color terminatorColor: accentColor
property real terminatorOpacity: 0.45

property var hoveredStation: null

property real hoverX: 0
property real hoverY: 0
property var highlightedStation: null
Expand Down Expand Up @@ -428,6 +436,66 @@ Item {
}
}

function paintSolarTerminator(ctx, centreX, centreY, globeRadius) {
if (!subsolarPoint) return
var geometry = RadioModel.terminatorGeometry(subsolarPoint, centreLatitude, centreLongitude, 64)
if (!geometry) return
var sCamX = geometry.sunX
var sCamY = geometry.sunY
var sCamZ = geometry.sunZ
var inPlaneLen = geometry.inPlaneLength

if (inPlaneLen < 1e-4) {
if (sCamZ < 0) {
ctx.beginPath()
ctx.arc(centreX, centreY, globeRadius, 0, Math.PI * 2)
ctx.fillStyle = withAlpha(nightColor, nightOpacity)
ctx.fill()
}
return
}

var sunAngleScreen = Math.atan2(-sCamY, sCamX)

var steps = geometry.steps
var ellipsePoints = geometry.points.map(function(point) {
return {
x: centreX + point.x * globeRadius,
y: centreY - point.y * globeRadius
}
})

var angleStartScreen = Math.atan2(ellipsePoints[0].y - centreY, ellipsePoints[0].x - centreX)
var angleEndScreen = Math.atan2(ellipsePoints[steps].y - centreY, ellipsePoints[steps].x - centreX)

var awayFromSun = (sunAngleScreen + Math.PI * 3) % (Math.PI * 2)
var angleEndWrapped = (angleEndScreen + Math.PI * 2) % (Math.PI * 2)
var angleStartWrapped = (angleStartScreen + Math.PI * 2) % (Math.PI * 2)

var diff = (angleStartWrapped - angleEndWrapped + Math.PI * 2) % (Math.PI * 2)
var diffSun = (awayFromSun - angleEndWrapped + Math.PI * 2) % (Math.PI * 2)
var anticlockwise = !(diffSun < diff)

ctx.beginPath()
ctx.moveTo(ellipsePoints[0].x, ellipsePoints[0].y)
for (var j = 1; j <= steps; j++) {
ctx.lineTo(ellipsePoints[j].x, ellipsePoints[j].y)
}
ctx.arc(centreX, centreY, globeRadius, angleEndScreen, angleStartScreen, anticlockwise)
Comment on lines +475 to +484

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Terminator geometry lacks coverage

The new arc-direction logic determines which visible hemisphere receives the night mask, but the added tests only validate solar coordinates. A regression that shades the daylight hemisphere or closes the path along the wrong rim could therefore pass the test suite. Add focused geometry or rendering tests for front-lit, back-lit, and side-lit camera orientations.

Prompt To Fix With AI
This is a comment left during a code review.
Path: Globe.qml
Line: 499-508

Comment:
**Terminator geometry lacks coverage**

The new arc-direction logic determines which visible hemisphere receives the night mask, but the added tests only validate solar coordinates. A regression that shades the daylight hemisphere or closes the path along the wrong rim could therefore pass the test suite. Add focused geometry or rendering tests for front-lit, back-lit, and side-lit camera orientations.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex

ctx.closePath()
ctx.fillStyle = withAlpha(nightColor, nightOpacity)
ctx.fill()

ctx.beginPath()
ctx.moveTo(ellipsePoints[0].x, ellipsePoints[0].y)
for (var k = 1; k <= steps; k++) {
ctx.lineTo(ellipsePoints[k].x, ellipsePoints[k].y)
}
ctx.strokeStyle = withAlpha(terminatorColor, terminatorOpacity)
ctx.lineWidth = Math.min(2.0, Math.max(1.0, globeRadius / 400))
ctx.stroke()
}

function paintGlobe(ctx) {
var centreX = globeCanvas.width / 2
var centreY = globeCanvas.height / 2
Expand Down Expand Up @@ -455,6 +523,7 @@ Item {
ctx.clip()
paintGrid(ctx, centreX, centreY, globeRadius)
paintCountries(ctx, centreX, centreY, globeRadius)
if (showTerminator) paintSolarTerminator(ctx, centreX, centreY, globeRadius)
paintSignals(ctx)
ctx.restore()

Expand Down Expand Up @@ -543,6 +612,30 @@ Item {
onVisibleChanged: {
if (!visible) stopKineticRotation(true)
}
onSubsolarPointChanged: globeCanvas.requestPaint()
onShowTerminatorChanged: {
if (showTerminator && visible) refreshSolarPosition()
globeCanvas.requestPaint()
}
onNightColorChanged: globeCanvas.requestPaint()
onNightOpacityChanged: globeCanvas.requestPaint()
onTerminatorColorChanged: globeCanvas.requestPaint()
onTerminatorOpacityChanged: globeCanvas.requestPaint()

function refreshSolarPosition() {
root.subsolarPoint = RadioModel.subsolarPoint(new Date())
}

Timer {
id: solarTimer
interval: 60000
running: root.visible && root.showTerminator
repeat: true
onRunningChanged: {
if (running) root.refreshSolarPosition()
}
onTriggered: root.refreshSolarPosition()
}

Canvas {
id: globeCanvas
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ usual play, pause, previous, and next controls.
## Features

- Kinetic drag rotation that highlights a nearby station when it settles, plus deep wheel zoom on a theme-aware globe
- Real-time solar day/night terminator with night-side shading across the globe
- A fast cached world view that progressively adds thousands of stations and keeps the session catalog when closed
- Country stations stay on the session globe and take priority over background signals
- Country-level map estimates when a station has no published coordinates
Expand Down
6 changes: 6 additions & 0 deletions RadioAtlas.qml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ Item {
property color mapSphere: lightTheme ? "#d2d0ca" : "#11151a"
property color mapLand: lightTheme ? "#a9aaa6" : "#283039"
property color mapGrid: lightTheme ? "#3f454a" : "#7d8791"
property color mapNight: lightTheme ? "#1b2028" : "#040608"
property color mapTerminator: accent


property bool windowSetupReady: false
property bool windowFrameReady: false
Expand Down Expand Up @@ -1420,7 +1423,10 @@ Item {
sphereColor: root.mapSphere
landColor: root.mapLand
gridColor: root.mapGrid
nightColor: root.mapNight
terminatorColor: root.mapTerminator
outlineColor: root.lightTheme ? "#3c4247" : "#9099a3"

signalColor: root.lightTheme ? "#202428" : "#d9dee3"
accentColor: root.accent
textColor: root.foreground
Expand Down
93 changes: 93 additions & 0 deletions RadioModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -567,3 +567,96 @@ function indexByUuid(stations, uuid) {
for (var i = 0; i < rows.length; i++) if (rows[i].uuid === uuid) return i
return -1
}

function subsolarPoint(date) {
var d = date || new Date()
var ms = d.getTime()
var julianDate = ms / 86400000 + 2440587.5
var t = (julianDate - 2451545.0) / 36525.0

var L0 = (280.46646 + t * (36000.76983 + t * 0.0003032)) % 360
if (L0 < 0) L0 += 360
var M = (357.52911 + t * (35999.05029 - t * 0.0001537)) % 360
if (M < 0) M += 360
var e = 0.016708634 - t * (0.000042037 + 0.0000001267 * t)

var C = Math.sin(M * radians) * (1.914602 - t * (0.004817 + 0.000014 * t))
+ Math.sin(2 * M * radians) * (0.019993 - 0.000101 * t)
+ Math.sin(3 * M * radians) * 0.000289
var sunTrueLong = L0 + C

var eps0 = 23.439291 - t * (0.013004167 + t * (0.00000016667 - t * 0.000000502778))
var omega = 125.04 - 1934.136 * t
var eps = eps0 + 0.00256 * Math.cos(omega * radians)
var lambdaApparent = sunTrueLong - 0.00569 - 0.00478 * Math.sin(omega * radians)

var sinDec = Math.sin(eps * radians) * Math.sin(lambdaApparent * radians)
var declination = Math.asin(sinDec) * degrees

var y = Math.pow(Math.tan((eps / 2) * radians), 2)
var eot = 4 * degrees * (
y * Math.sin(2 * L0 * radians)
- 2 * e * Math.sin(M * radians)
+ 4 * e * y * Math.sin(M * radians) * Math.cos(2 * L0 * radians)
- 0.5 * y * y * Math.sin(4 * L0 * radians)
- 1.25 * e * e * Math.sin(2 * M * radians)
)

var utcSeconds = d.getUTCHours() * 3600 + d.getUTCMinutes() * 60 + d.getUTCSeconds() + d.getUTCMilliseconds() / 1000
var subsolarLong = wrapLongitude(-((utcSeconds / 3600 - 12) * 15 + eot / 4))

return { latitude: declination, longitude: subsolarLong }
}

// Returns the camera-space sun vector and unit-radius points on the visible
// terminator ellipse. Keeping this projection here makes the renderer and its
// tests use the same geometry and coordinate conventions.
function terminatorGeometry(point, centreLatitude, centreLongitude, steps) {
if (!point) return null
var sLat = Number(point.latitude) * radians
var sLon = Number(point.longitude) * radians
var cosSLat = Math.cos(sLat)
var sx = cosSLat * Math.cos(sLon)
var sy = cosSLat * Math.sin(sLon)
var sz = Math.sin(sLat)

var latitude = Number(centreLatitude) * radians
var longitude = Number(centreLongitude) * radians
var sinLatitude = Math.sin(latitude)
var cosLatitude = Math.cos(latitude)
var sinLongitude = Math.sin(longitude)
var cosLongitude = Math.cos(longitude)

var horizontal = sx * cosLongitude + sy * sinLongitude
var cameraX = sy * cosLongitude - sx * sinLongitude
var cameraY = cosLatitude * sz - sinLatitude * horizontal
var cameraZ = sinLatitude * sz + cosLatitude * horizontal
var inPlaneLength = Math.hypot(cameraX, cameraY)
var count = Math.max(4, Math.floor(Number(steps) || 64))
var points = []

if (inPlaneLength >= 1e-4) {
var angle = Math.atan2(cameraY, cameraX)
var cosAngle = Math.cos(angle)
var sinAngle = Math.sin(angle)
for (var i = 0; i <= count; i++) {
var t = -Math.PI / 2 + (i / count) * Math.PI
var v = Math.sin(t)
var u = -cameraZ * Math.cos(t)
points.push({
x: u * cosAngle - v * sinAngle,
y: u * sinAngle + v * cosAngle,
z: inPlaneLength * Math.cos(t)
})
}
}

return {
sunX: cameraX,
sunY: cameraY,
sunZ: cameraZ,
inPlaneLength: inPlaneLength,
steps: count,
points: points
}
}
37 changes: 37 additions & 0 deletions tests/model.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -334,4 +334,41 @@ assert.deepEqual(
)
assert.deepEqual(Array.from(model.stationWindow(playlistRows, "missing", 5)), [])

// Solar day/night terminator tests
const summerSolstice = model.subsolarPoint(new Date("2026-06-21T12:00:00Z"))
assert.ok(summerSolstice.latitude > 23.4 && summerSolstice.latitude < 23.5)
assert.ok(Math.abs(summerSolstice.longitude) < 2)

const winterSolstice = model.subsolarPoint(new Date("2026-12-21T12:00:00Z"))
assert.ok(winterSolstice.latitude < -23.4 && winterSolstice.latitude > -23.5)
assert.ok(Math.abs(winterSolstice.longitude) < 2)

const springEquinox = model.subsolarPoint(new Date("2026-03-20T12:00:00Z"))
assert.ok(Math.abs(springEquinox.latitude) < 0.5)

const midnightUtc = model.subsolarPoint(new Date("2026-03-20T00:00:00Z"))
assert.ok(Math.abs(midnightUtc.longitude) > 175)

function assertTerminatorIsOrthogonal(point, centreLatitude, centreLongitude) {
const geometry = model.terminatorGeometry(point, centreLatitude, centreLongitude, 32)
assert.equal(geometry.points.length, 33)
for (const terminatorPoint of geometry.points) {
assert.ok(Math.abs(terminatorPoint.x * terminatorPoint.x + terminatorPoint.y * terminatorPoint.y + terminatorPoint.z * terminatorPoint.z - 1) < 1e-12)
assert.ok(Math.abs(geometry.sunX * terminatorPoint.x + geometry.sunY * terminatorPoint.y + geometry.sunZ * terminatorPoint.z) < 1e-12)
}
return geometry
}

const frontLit = model.terminatorGeometry({ latitude: 0, longitude: 0 }, 0, 0, 32)
assert.equal(frontLit.points.length, 0)
assert.ok(frontLit.sunZ > 0.99)

const backLit = model.terminatorGeometry({ latitude: 0, longitude: 180 }, 0, 0, 32)
assert.equal(backLit.points.length, 0)
assert.ok(backLit.sunZ < -0.99)

const sideLit = assertTerminatorIsOrthogonal({ latitude: 0, longitude: 90 }, 0, 0)
assert.ok(Math.abs(sideLit.sunZ) < 1e-12)
assert.ok(Math.abs(sideLit.points[16].x) < 1e-12)

console.log("RadioModel tests passed")