From 60f6ee4cac7515fd2c65430f43c9d5faff9202da Mon Sep 17 00:00:00 2001 From: Matt Marjanovic Date: Sun, 15 Feb 2026 21:58:31 -0800 Subject: [PATCH] Add a new app, the F3 Blue Moon Widget --- apps/f3bluemoon/ChangeLog | 1 + apps/f3bluemoon/README.md | 102 ++++++++++++ apps/f3bluemoon/f3bluemoon-icon.png | Bin 0 -> 1206 bytes apps/f3bluemoon/f3bluemoon.settings.js | 84 ++++++++++ apps/f3bluemoon/f3bluemoon.wid.js | 207 +++++++++++++++++++++++++ apps/f3bluemoon/metadata.json | 16 ++ 6 files changed, 410 insertions(+) create mode 100644 apps/f3bluemoon/ChangeLog create mode 100644 apps/f3bluemoon/README.md create mode 100644 apps/f3bluemoon/f3bluemoon-icon.png create mode 100644 apps/f3bluemoon/f3bluemoon.settings.js create mode 100644 apps/f3bluemoon/f3bluemoon.wid.js create mode 100644 apps/f3bluemoon/metadata.json diff --git a/apps/f3bluemoon/ChangeLog b/apps/f3bluemoon/ChangeLog new file mode 100644 index 0000000000..bd9217785d --- /dev/null +++ b/apps/f3bluemoon/ChangeLog @@ -0,0 +1 @@ +1.00: Yet another moon-phase widget rises on the horizon. diff --git a/apps/f3bluemoon/README.md b/apps/f3bluemoon/README.md new file mode 100644 index 0000000000..60ec3bee76 --- /dev/null +++ b/apps/f3bluemoon/README.md @@ -0,0 +1,102 @@ +# F3 Blue Moon Widget + +F3 Blue Moon Widget is a widget that displays a moon-phase indicator on the +[Espruino Bangle.js](https://banglejs.com/) wristwatches (both v1 and v2). + +F3 Blue Moon Widget is brought to you by the +[FatFingerFederation](https://codeberg.org/FatFingerFederation/). + +## Usage + +This is a widget; simply install it and it will proceed to widge. + +## Configuration + +There are two options, configurable via the `Settings` app; +look for `F3 Blue Moon Widget`. + + * _Hemisphere_ + * The hemisphere setting will cause the moon display to flip appropriately + to match (more or less) what you will see in the sky. + * By default, if you also have the `My Location` app installed and working, + the widget will use your latitude to determine your hemisphere. Otherwise, + the widget defaults to _Northern_. + * You can explicitly set the hemisphere to _Northern_ or _Southern_ instead. + * _Color_ + * The full gamut of 3-bit colors is available for your choosing: + red (R), green (G), blue (B), yellow (RG), cyan (GB), magenta (BR), + and white (RGB)! + * The default color setting is "GB", e.g. a light blue or cyan. + +## Reporting Issues + +Upstream development happens at +[FatFingerFederation/F3BlueMoonWidget](https://codeberg.org/FatFingerFederation/F3BlueMoonWidget); +please report any bugs/issues/etc there directly. + +## Development and Algorithms + +The math used to determine the moon phase was adapted from +https://github.com/pjain03/moon_phases, which itself is an implementation +of algorithms from "Astronomical Algorithms (2nd Ed)", Jean Meeus (1999). +For the widget, the algorithms were simplified considerably, removing many +terms and correction factors that had no appreciable effect on displaying +a moon image that is only 22 pixels wide. + +That said, the output is still quite accurate, and was cross-checked against +100 years of daily ephemeris results produced by the +[NASA Jet Propulsion Laboratory](https://www.jpl.nasa.gov/)'s +Solar System Dynamics [Horizons System](https://ssd.jpl.nasa.gov/horizons/). + +The F3 Blue Moon output was also compared to two other moon-phase algorithms +that coexist in the BangleApps catalog: + * `moonPhase()` in the [`widmp` widget](https://github.com/espruino/BangleApps/tree/master/apps/widmp) (and others), derived from https://github.com/deirdreobyrne/LunarPhase + * `getMoonIllumination()` in the [`suncalc` module](https://github.com/espruino/BangleApps/tree/master/modules/suncalc), derived from https://github.com/mourner/suncalc + +The "moon's phase" basically means "what fraction of the moon is +illuminated by the sun", and that is really determined by the Sun-Moon-Earth +angle, squeezed through a cosine transformation. (Actually, we use the +Sun-Earth-Moon angle; since the Earth and Moon are so close to each other +relative to the Sun, this amounts to a neglibible difference, less than +0.2 degrees.) + +To compare the F3 Blue Moon math to that of the other two, we looked +at the error (relative to the JPL Horizons data) of both the calculated +illumination fraction and the Sun-Earth-Moon angle. + +Here is the `[min, max]` error measured over 100 years of daily samples +(from 2026-01-01 to 2125-12-31): + +| algorithm | illuminated fraction (%) | sun-earth-moon (deg) | +|--------------|--------------------------|----------------------| +| F3 Blue Moon | `[-0.57, 0.41]` | `[-0.70, 0.74]` | +| LunarPhase | `[-0.29, 0.29]` | `[-4.96, 4.97]` | +| suncalc | `[-3.54, 3.67]` | `[-4.31, 4.68]` | + +The LunarPhase algorithm produced the best illumination fraction output, +with a deviation of less than ±0.3%, but our Blue Moon algorithm was a +close second, with a deviation of less than ±0.6%. The suncalc results +were kind of lousy, with error bars of greater than ±3.5%. (Given +that "full moon" is considered to mean ">99% illumination", then an +error of ±3.5% is quite significant.) + +Looking at the errors in Sun-Earth-Moon angle, our Blue Moon algorithm +was by far the best (under ±0.75 degrees), and the LunarPhase algorithm +was the worst (up to about ±5 degrees) with suncalc barely any better. + +How can it be that LunarPhase has the tightest bounds on illumination, +but worst bounds on angle, when the illumination is derived from angle? +The answer lies in *where* the errors occur. The LunarPhase algorithm +happens to give the worst results for angle at those angles where it +has the least effect on illumination, i.e., around the angles 0 and π, +corresponding to new and full moons. (That makes some sense given that +the LunarPhase algorithm seems to have been constructed, not so much +from astronomical first principles, but by curve-fitting +until the illuminated fraction values were optimized.) + +Overall, the Blue Moon code, sourced from principled astronomical +algorithms, provides compellingly accurate values for both illuminated +fraction and angle. + +## Creator +- [Matt Marjanovic](https://codeberg.org/maddog) diff --git a/apps/f3bluemoon/f3bluemoon-icon.png b/apps/f3bluemoon/f3bluemoon-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b36f4bd34b4b956bd83d008279e757aebcc9e429 GIT binary patch literal 1206 zcmV;n1WEgeP)w6^Yr~#f-nR-d{Mn4? z`F+s50)UTUyZ&2lm$zlX=fXcb`y`kk04}#|x%|er@1cNzHo*$tQNBy!v^9zu8`j+# z?wB9~RDpoF<~kHGRl*v{H2?n@0>79iOhp6oxV%@$`}l%qpW)$8e7@|SmKS+mgA#DV z(vYleC<>wESUVrnorMyJ0MVN=VqWCHivRDl{`nmq`7>}iGwX)+o03Z-y#r4~$jA2J zGUkO~>e|bS^PEsREHfGSKuo?i)n^0qH*Nh3nElMLE8}&-vrU!L01)iUuT;8s01pV& zO64QHP&}`JAhOVg0v-@`Uoxr1v{sc|53uUf4ufajhNma{7&<<%Ypoq z06^wFKa6M7EKli|7RUcKNOkS|F?264&T9R1i zK$K}~U8>V!Zi(hV>g>j>#d*x~6A>00r%IGypUY0Hr#+l#%Dbb4%07n24B5Auf839V{ms<(IC@hb?gFY&&PN3z6TyLBOig=RY%W-11_KS)% zV7;1iN{8?I3E#MzJ_g{!n`{vK@)hInJ;ksBmb0!LA*Dj6x%Y_a&Lqd#Xz}hNN=~l5 zD*$WnPMDZp;&CUNe8%!Xl%8_qbQ$eNT1W`E*fOr6YcMJ_`#S>HiSvM^gfuW9k;pjg z$U~&VW;((eOhAJ1X6~wVTp^?*gn-C?Wg(*XKgZF8Nwz6C8e~I(^FRRwOaCICbX9 zXIPgGqdjvFn&HXPS!T$XyvKbj(dEDZiDm~K;y18U(kj4ygsZy7dM{MpI+wY8N-A5d zdf*J>h5@Pvw0TD|r_Mx&VfCAm$oe=H0M#vlB>WK4S0RxLY2NodU=4Xj4)o?uOo@Cc zc|cTt-PE(OtCdoR49B_cZYu}Wvu?o4W;dn>L<0Z)zyK0f)Aa!onBjLqvIitPLuFbY zpbd2n^5W8Y-U48zX#GHRv0=oLD0;ixU3u5paOCnP$9L3mxL?g=!v|IuF35ihBLBR^ zS{j2ldJ7Tp`a=e}L2wHBe$H6kSna%8=oqNactHsrF{fr_+&JzL!xnzzo2cl!v<^t1 zAu>A}9!WV}dgH=V`jNs-rfCQQ1ly07#FjP13_paJ!6!_MWKCVO8&kB5v>j&u2Vi{Z UBa>QlBme*a07*qoM6N<$f;}uu9RL6T literal 0 HcmV?d00001 diff --git a/apps/f3bluemoon/f3bluemoon.settings.js b/apps/f3bluemoon/f3bluemoon.settings.js new file mode 100644 index 0000000000..5845b22b4f --- /dev/null +++ b/apps/f3bluemoon/f3bluemoon.settings.js @@ -0,0 +1,84 @@ +// This file is part of F3 Blue Moon Widget. +// Copyright 2026 Matt Marjanovic +// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. + +(function(back) { + + Bangle.loadWidgets(); // Ensure we have access to our widget! + Bangle.drawWidgets(); + + let config = WIDGETS["f3bluemoon"].loadConfig(); + + function saveConfig() { + require('Storage').writeJSON("f3bluemoon.json", config); + } + + function redrawWidget() { + let w = WIDGETS["f3bluemoon"]; + if (!w) { return; } + w.setConfig(config); + setTimeout(w.draw.bind(w), 0); // ...defer drawing until idle. + } + + const colorNames = {null: "default", + r__: "R--", + rg_: "RG-", + _g_: "-G-", + _gb: "-GB", + __b: "--B", + r_b: "R-B", + rgb: "RGB", + }; + const colorSequence = [null, "r__", "rg_", "_g_", "_gb", "__b", "r_b", "rgb"]; + + E.showMenu({ + "": { title: "Blue Moon options", + back: back, + }, + "Hemisphere": { + value: config.hemisphere, + min: -1, + max: 1, + noList: true, + wrap: true, + format: function (v, context) { + // Update widget as user flips through options, OR ensure sync of + // value/widget if returning to menu without making selection. + if (context !== 1) { + config.hemisphere = v; + redrawWidget(); + } + return ["Southern", "default\n(try My Location)", "Northern"][v + 1]; + }, + onchange: v => { + config.hemisphere = v; + redrawWidget(); + saveConfig(); + } + }, + "Color": { + value: (()=>{ let i = colorSequence.indexOf(config.color); + if (i < 0) { i = 0; } + return i; })(), + min: 0, + max: colorSequence.length - 1, + noList: true, + wrap: true, + format: function (i, context) { + let tag = colorSequence[i]; + // Update widget as user flips through options, OR ensure sync of + // value/widget if returning to menu without making selection. + if (context !== 1) { + config.color = tag; + redrawWidget(); + } + return colorNames[tag]; + }, + onchange: i => { + config.color = colorSequence[i]; + redrawWidget(); + saveConfig(); + } + }, + }); +}) diff --git a/apps/f3bluemoon/f3bluemoon.wid.js b/apps/f3bluemoon/f3bluemoon.wid.js new file mode 100644 index 0000000000..ad36c4fc69 --- /dev/null +++ b/apps/f3bluemoon/f3bluemoon.wid.js @@ -0,0 +1,207 @@ +// This file is part of F3 Blue Moon Widget. +// Copyright 2026 Matt Marjanovic +// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. + +(() => { + let isDownUnder, moonColor; // configuration variables + + function setConfig(c) { + let hemi = c.hemisphere; // NB: Avoid mutating c! + if (hemi === 0) { + // Default to using latitude from "My Location" app, with fallback + // to "northern hemisphere". + let location = require("Storage").readJSON("mylocation.json", true) || {"lat": 1}; + hemi = (location.lat >= 0) ? 1 : -1; + } + isDownUnder = (hemi < 0); + + moonColor = {r__: g.toColor(1,0,0), + rg_: g.toColor(1,1,0), + _g_: g.toColor(0,1,0), + _gb: g.toColor(0,1,1), + __b: g.toColor(0,0,1), + r_b: g.toColor(1,0,1), + rgb: g.toColor(1,1,1), + }[c.color] || g.toColor(0,1,1); + } + + function loadConfig() { + let c = Object.assign({hemisphere: 0, // -1: S, 0: def, +1: N + color: null, // see colorNames in settings.js + }, + require('Storage').readJSON("f3bluemoon.json", true)); + setConfig(c); + return c; + } + + function drawMoon(tl_x, tl_y, left, right) { + g.reset(); + + let x0 = tl_x + 12; + let y0 = tl_y + 12; + let shadow = g.blendColor(0, moonColor, 0.25); + + // Quarter-circle, radius 11 pixels, expressed as 11 row lengths + let dx_list = new Uint8Array([11, 11, 11, 10, 10, 9, 9, 8, 7, 5, 3]); + + let dy = 0; + for (const dx of dx_list) { + // Draw a complete "shadow moon" first. + g.setColor(shadow); + g.drawLine(x0 - dx, y0 - 1 - dy, + x0 - 1 + dx, y0 - 1 - dy); + g.drawLine(x0 - dx, y0 + dy, + x0 - 1 + dx, y0 + dy); + + // Draw the partial "lit moon" on top. + let ldx = Math.round(-left * dx); + let rdx = Math.round(right * dx) - 1; + if (ldx <= rdx) { + g.setColor(moonColor); + g.drawLine(x0 + ldx, y0 - 1 - dy, + x0 + rdx, y0 - 1 - dy); + g.drawLine(x0 + ldx, y0 + dy, + x0 + rdx, y0 + dy); + } + ++dy; + } + + // Superimpose the man-in-the-moon during full moon (< 1% occlusion). + if ((left > 0.99) && (right > 0.99)) { + print("Full Moon!"); + g.setBgColor(0, 0, 0); + g.drawImage( + atob("GBiBAf////////////////9///r1f/f///16//q1f/Xbv/q1f/16////7+/3///rt/3X///////9f/6qv/9V/////////////////w=="), + tl_x, tl_y); + } + } + + function moonPhase(unixSeconds) { + // See https://en.wikipedia.org/wiki/Julian_day (ignoring leap seconds?) + let jd = (unixSeconds / 86400) + 2440587.5; // Julian date + // Julian centuries, relative to J2000.0 epoch + let T = (jd - 2451545) / 36525; + + // Moon math adapted from https://github.com/pjain03/moon_phases, which was + // adapted from "Astronomical Algorithms (2nd Ed)", Jean Meeus (1999). + // + // L_prime = light_time_moon(T) + // D = mean_elongation_moon(T) + // M_prime = mean_anomaly_moon(T) + // F = mean_latitude_moon(T) + let L_prime = (3.81034102 + 8399.70911 * T); + let D = (5.19846674 + 7771.37714 * T); + let M_prime = (2.35555589 + 8328.69142 * T); + let F = (1.62790523 + 8433.46615 * T); + + // sl = kepler_coeff_longitude(D, M, M_prime, F, E, A1, A2, L_prime) + // sb = kepler_coeff_latitude(D, M, M_prime, F, E, L_prime, A3, A1) + let sl = ( 1.09759812e-1 * Math.sin(M_prime) + + 2.22359659e-2 * Math.sin(2 * D - M_prime) + + 1.14897468e-2 * Math.sin(2 * D) + ); + let sb = 8.95026133e-2 * Math.sin(F); + + // Lo = mean_longitude_sun(T) + // M = mean_anomaly_sun(T) + // C = center_of_sun(T, M) + // L = true_longitude_sun(Lo, C) + let Lo = (4.89506299 + 628.33196678 * T); + let M = (6.24005996 + 628.30195532 * T); + let C = (3.34160738e-2 - 8.40725100e-05 * T) * Math.sin(M); + let L = Lo + C; + + // l_moon = apparent_longitude_moon(L_prime, sl) + // al = apparent_longitude_sun(L, T) + let l_moon = L_prime + sl; + let b_moon = sb; + let l_sun = L; + + let l_sun_to_moon = (l_moon - l_sun) % (2 * Math.PI); + + // phi here is Sun-Earth-Moon angle --- not Sun-Moon-Earth, + // but close enough since Sun is so much farther away. + let cosPhi = Math.cos(b_moon) * Math.cos(l_sun_to_moon); + + return { seconds: unixSeconds, + jd: jd, + fraction: (1 - cosPhi) / 2, + isLeadingSun: l_sun_to_moon > Math.PI, + }; + } + + const width = 24; + const height = 24; + let moon = {}; // cached calculated moon state + let forceSeconds = 0; + + function drawWidget() { + // Recalculate moon state hourly-ish. + let nowSeconds = forceSeconds || getTime(); + if (!moon.seconds || ((nowSeconds - moon.seconds) >= 3600)) { + moon = moonPhase(nowSeconds); + } + + let left = (2 * moon.fraction) - 1; + let right = 1; + if (moon.isLeadingSun ^ isDownUnder) { + let t = left; left = right; right = t; + } + drawMoon(this.x, this.y, left, right); + } + + function drawTest() { + // sanitycheck.js does not want to see "g.reset().clear" in widgets, + // but here, in test code, it should be ok, so cheekily obfuscate it. + const insaniG = g; // + insaniG.reset().clear().setColor(1,0,0).fillRect(0,0, 175,175); + + let x = 8; + let y = 4; + let draw = (p) => { + let left = (p >= 0) ? (2 * p) - 1 : 1; + let right = (p < 0) ? (2 * -p) - 1 : 1; + drawMoon(x, y, left, right, false); + g.setFont("4x6").setColor(0).drawString(p, x + 20, y + 20); + y += 24; + } + let nextColumn = () => { y = 4; x += 40; } + + for (let p of [0.0, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5]) { draw(p); } + nextColumn(); + for (let p of [1.0, 0.95, 0.9, 0.8, 0.7, 0.6]) { draw(p); } + nextColumn(); + for (let p of [-1.0, -0.95, -0.9, -0.8, -0.7, -0.6]) { draw(p); } + nextColumn(); + for (let p of [0.0, -0.05, -0.1, -0.2, -0.3, -0.4, -0.5]) { draw(p) } + } + + function dumpIcon() { + // Draw some moons in top-left corner. + g.reset().setColor(0); + g.fillRect(0, 0, 2 * width - 1, 2 * height - 1); + setConfig({hemisphere: 1, color: null}); + drawMoon(0, 0, -0.9, 1); + drawMoon(width, 0, -0.5, 1); + drawMoon(0, height, 0.2, 1); + drawMoon(width, height, 0.8, 1); + // Extract and dump. + let G = Graphics.createArrayBuffer(48, 48, 4); + G.drawImage(g.asImage({x: 0, y:0, w: 48, h: 48})); + G.dump(); + } + + loadConfig(); + + WIDGETS["f3bluemoon"] = { + area: "tr", + width: width, + draw: drawWidget, + setConfig: setConfig, + loadConfig: loadConfig, + // For testing/development: + forceSeconds: function (s) { forceSeconds = s; this.draw(); }, + drawTest: drawTest, + dumpIcon: dumpIcon + }; +})(); diff --git a/apps/f3bluemoon/metadata.json b/apps/f3bluemoon/metadata.json new file mode 100644 index 0000000000..77af57d569 --- /dev/null +++ b/apps/f3bluemoon/metadata.json @@ -0,0 +1,16 @@ +{ "id": "f3bluemoon", + "name": "F3 Blue Moon Widget", + "shortName":"F3 Blue Moon Widget", + "version":"1.00", + "author": "mdoggydog", + "description": "A simple, delightful, and accurate moon-phase widget, brought to you by the Fat Finger Federation.", + "icon": "f3bluemoon-icon.png", + "type": "widget", + "tags": "widget", + "supports" : ["BANGLEJS", "BANGLEJS2"], + "readme": "README.md", + "storage": [ {"name": "f3bluemoon.wid.js", "url": "f3bluemoon.wid.js"}, + {"name": "f3bluemoon.settings.js", "url": "f3bluemoon.settings.js"} + ], + "data": [ {"name": "f3bluemoon.json"} ] +}