SUNDIAL solar dial v0.3: round screen, NOAA solar math, day/night arc

- 466px round layout, designWidth 466 (S4 41mm 466x466)
- solar position + dayProgress in src/common/solar.js (pure, TDD'd)
- sunrise/sunset sweep, night moon, countdown, 30s refresh
- tests: solar math, page logic, circle geometry/contrast
This commit is contained in:
2026-09-09 15:09:14 +01:00
commit 1a3a2566e3
16 changed files with 10991 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
<rect width="192" height="192" rx="48" fill="#ff6900"/>
<text x="96" y="116" text-anchor="middle" font-family="DejaVu Sans, sans-serif" font-size="72" font-weight="700" fill="#ffffff">Hi</text>
</svg>

After

Width:  |  Height:  |  Size: 293 B

+91
View File
@@ -0,0 +1,91 @@
// Solar position — NOAA solar calculator algorithm (Astronomical Algorithms, Meeus).
// All functions pure; no Vela APIs. Exports: solarPosition, dayProgress, formatCountdown.
const RAD = Math.PI / 180
const DEG = 180 / Math.PI
function julianDay(date) {
return date.getTime() / 86400000 + 2440587.5
}
function sunVector(date) {
const jd = julianDay(date)
const n = jd - 2451545.0
const L = (280.460 + 0.9856474 * n) % 360 // mean longitude
const g = ((357.528 + 0.9856003 * n) % 360) * RAD // mean anomaly
const lambda = (L + 1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g)) * RAD // ecliptic longitude
const eps = (23.439 - 0.0000004 * n) * RAD // obliquity
const declination = Math.asin(Math.sin(eps) * Math.sin(lambda)) * DEG
const rightAscension = Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda)) * DEG
const gmst = (18.697374558 + 24.06570982441908 * n) % 24 // sidereal hours
return { declination, rightAscension: (rightAscension + 360) % 360, gmst: (gmst + 24) % 24 }
}
// altitude/azimuth in degrees; azimuth 0=N, 90=E
function solarPosition(date, latDeg, lonDeg) {
const { declination, rightAscension, gmst } = sunVector(date)
const lat = latDeg * RAD
const hourAngle = ((gmst * 15 + lonDeg - rightAscension + 180) % 360 - 180) * RAD
const sinAlt = Math.sin(lat) * Math.sin(declination * RAD) + Math.cos(lat) * Math.cos(declination * RAD) * Math.cos(hourAngle)
const altitude = Math.asin(Math.max(-1, Math.min(1, sinAlt))) * DEG
const az = Math.atan2(
-Math.sin(hourAngle),
Math.tan(lat) * Math.cos(declination * RAD) - Math.sin(declination * RAD) * Math.cos(hourAngle)
) * DEG
return { altitude, azimuth: (az + 360) % 360 }
}
// fraction of daylight elapsed at `date` (0 sunrise → 1 sunset; 0 before, 1 after)
function dayProgress(date, latDeg, lonDeg) {
// scan the last 48h for the latest crossing at or before `date`
let lastSunrise = NaN, lastSunset = NaN
for (let h = 1; h <= 48; h++) {
const t = date.getTime() - h * 3600e3
const altT = solarPosition(new Date(t), latDeg, lonDeg).altitude
const altNext = solarPosition(new Date(t + 3600e3), latDeg, lonDeg).altitude
if (altT <= 0 && altNext > 0) { lastSunrise = t; break }
}
for (let h = 1; h <= 48; h++) {
const t = date.getTime() - h * 3600e3
const altT = solarPosition(new Date(t), latDeg, lonDeg).altitude
const altNext = solarPosition(new Date(t + 3600e3), latDeg, lonDeg).altitude
if (altT > 0 && altNext <= 0) { lastSunset = t; break }
}
// next sunset (forward scan)
let nextSunset = NaN
for (let h = 1; h <= 48; h++) {
const t = date.getTime() + h * 3600e3
const altT = solarPosition(new Date(t), latDeg, lonDeg).altitude
const altNext = solarPosition(new Date(t + 3600e3), latDeg, lonDeg).altitude
if (altT > 0 && altNext <= 0) { nextSunset = t + 3600e3; break }
}
if (Number.isNaN(lastSunrise)) return 0 // no sunrise in 48h → polar night
// find next sunrise (forward scan) to disambiguate late-evening vs pre-dawn night
let nextSunrise = NaN
for (let h = 1; h <= 48; h++) {
const t = date.getTime() + h * 3600e3
const altT = solarPosition(new Date(t), latDeg, lonDeg).altitude
const altNext = solarPosition(new Date(t + 3600e3), latDeg, lonDeg).altitude
if (altT <= 0 && altNext > 0) { nextSunrise = t + 3600e3; break }
}
// night: past sunset (→1) until solar midnight, then pre-dawn (→0)
if (!Number.isNaN(lastSunset) && lastSunset > lastSunrise) {
if (Number.isNaN(nextSunrise)) return 1 // midnight sun edge
const solarMidnight = lastSunset + (nextSunrise - lastSunset) / 2
return date.getTime() < solarMidnight ? 1 : 0
}
if (Number.isNaN(nextSunset)) return 1 // midnight sun
const span = nextSunset - lastSunrise
if (span <= 0) return 1
return Math.max(0, Math.min(1, (date.getTime() - lastSunrise) / span))
}
function formatCountdown(seconds) {
const s = Math.max(0, Math.round(seconds))
if (s === 0) return '0m'
if (s < 60) return '<1m'
const m = Math.round(s / 60)
if (m < 60) return m + 'm'
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm'
}
module.exports = { solarPosition, dayProgress, formatCountdown }