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
+42
View File
@@ -0,0 +1,42 @@
const fs = require('node:fs');
const vm = require('node:vm');
const assert = require('node:assert/strict');
const ux = fs.readFileSync('src/pages/index/index.ux', 'utf8');
const script = ux.match(/<script>([\s\S]*?)<\/script>/)[1];
function page() {
const spec = vm.runInNewContext(script.replace('export default', 'globalThis.spec =') + '\nspec');
return Object.assign({}, spec.private, spec);
}
const p = page();
assert.equal(typeof p.press, 'function', 'Keypress handler must exist');
['2','+','3','='].forEach(k => p.press(k));
assert.equal(p.display, '5');
console.log('PASS addition through page keypress handler');
const cases = [
['subtract',['9','-','4','='],'5'],
['multiply',['6','x','7','='],'42'],
['divide',['8','/','2','='],'4'],
['decimals',['0','.','1','+','0','.','2','='],'0.3'],
['duplicate dot',['1','.','.','2'],'1.2'],
['sign',['5','+/-','x','2','='],'-10'],
['percent',['5','0','%'],'0.5'],
['percent add',['2','0','0','+','1','0','%','='],'220'],
['percent multiply',['2','0','0','x','1','0','%','='],'20'],
['clear',['9','+','AC','2','='],'2'],
['zero division',['8','/','0','='],'Error'],
['recover',['8','/','0','=','3'],'3'],
['chain',['2','+','3','x','4','='],'20'],
['replace operator',['2','+','x','3','='],'6'],
['repeat equals',['2','+','3','=','=', '='],'11'],
['fresh entry',['2','+','3','=','7'],'7'],
['input cap',Array(20).fill('9'),'999999999'],
['leading zero',['0','0','2'],'2']
];
for(const [name,keys,expected] of cases) {
const c=page(); keys.forEach(k=>c.press(k)); assert.equal(c.display,expected,name);
}
assert.match(ux, /\{\{ display \}\}/);
const keys=[...ux.matchAll(/<text[^>]*onclick="press\('([^']+)'\)"/g)].map(m=>m[1]);
assert.equal(keys.length,19,'every button must have handler');
assert.equal(new Set(keys).size,19);
console.log('PASS',cases.length,'edge cases and all 19 button bindings');
+32
View File
@@ -0,0 +1,32 @@
// Compatibility contract, NOT a reproduction of the watch's native crash.
// Marker must use documented numeric positioning, not chained transforms.
const fs = require('node:fs');
const vm = require('node:vm');
const assert = require('node:assert/strict');
const solar = require('../src/common/solar.js');
const ux = fs.readFileSync('src/pages/index/index.ux', 'utf8');
const body = ux.match(/<script>([\s\S]*?)<\/script>/)[1]
.replace(/import\s+solar\s+from\s+'[^']*';?/, '')
.replace('export default', '');
const spec = vm.runInNewContext('(' + body + ')', {solar, Date, Math, String, Number});
const page = Object.assign({}, spec, JSON.parse(JSON.stringify(spec.private)));
page.onInit();
page.update();
assert.ok(page.markerStyle && Number.isFinite(parseFloat(page.markerStyle.left)),
'marker must expose a finite left coordinate via a bound style object');
assert.match(ux, /style="{{\s*markerStyle\s*}}"/);
assert.doesNotMatch(ux, /transform\s*:/);
assert.doesNotMatch(ux, /width:\s*0px/);
const size = Number(ux.match(/\.sunDot\s*\{[^}]*width:\s*(\d+)px/)[1]);
// Check actual bound marker box, not an unused constant.
for (let angle = -270; angle <= 90; angle++) {
page.sunAngle = angle;
page.positionMarker();
const x = parseFloat(page.markerStyle.left), y = parseFloat(page.markerStyle.top);
assert.ok(Number.isInteger(x) && Number.isInteger(y));
for (const dx of [0, size]) for (const dy of [0, size]) {
assert.ok(Math.hypot(x + dx - 233, y + dy - 233) <= 200,
`marker box leaves the 400px dial at angle ${angle}`);
}
}
console.log('MARKER PASS: bound coordinates, no transforms, actual box fits all angles');
+46
View File
@@ -0,0 +1,46 @@
// Test the page's update() logic: clock, phase, angle mapping, countdown wiring.
const fs = require('node:fs');
const assert = require('node:assert/strict');
const solar = require('../src/common/solar.js');
const ux = fs.readFileSync('src/pages/index/index.ux', 'utf8');
const script = ux.match(/<script>([\s\S]*?)<\/script>/)[1];
// strip imports, provide solar as global
const body = script.replace(/import\s+solar\s+from\s+'[^']*';?/, '');
const sandbox = { solar, setInterval: () => 0, clearInterval: () => {}, Date, Math, String, Number, console };
const vm = require('node:vm');
const ctx = vm.createContext(sandbox);
sandbox.spec = vm.runInContext('(' + body.replace('export default', '') + ')', ctx);
const spec = sandbox.spec;
function mk() {
const o = Object.assign({}, JSON.parse(JSON.stringify(spec.private || {})));
for (const k in spec) if (k !== 'private' && k !== 'computed') o[k] = spec[k].bind(o);
o.$element = () => null;
spec.onInit.call(o);
return o;
}
// midday Paris: DAY, angle between -90 and +90, countdown mentions sunset
let c = mk();
let fixedNow = new Date('2026-09-09T12:00:00Z');
const realDate = Date;
class FakeDate extends realDate {
constructor(...args) { super(...(args.length ? args : [fixedNow])); }
static now() { return fixedNow.getTime(); }
}
sandbox.Date = FakeDate;
c.update();
assert.match(c.clock, /^\d{2}:\d{2}$/);
assert.equal(c.isDay, true, 'Paris noon is day');
assert.ok(c.sunAngle > -90 && c.sunAngle < 90, `day angle within upper arc, got ${c.sunAngle}`);
assert.match(c.countdown, /sunset in/);
// night: angle in lower arc, countdown mentions sunrise
fixedNow = new Date('2026-09-09T22:00:00Z');
c.update();
assert.equal(c.isDay, false, 'Paris 22:00Z is night');
assert.ok(c.sunAngle < -90 || c.sunAngle > 90, `night angle in lower arc, got ${c.sunAngle}`);
assert.match(c.countdown, /sunrise in/);
console.log('PAGE LOGIC PASS (day arc, night arc, countdowns)');
+40
View File
@@ -0,0 +1,40 @@
// TDD: solar position math — RFC-accurate NOAA algorithm, pure functions.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const src = fs.readFileSync('src/common/solar.js', 'utf8');
const mod = {};
new Function('module', 'exports', src)(mod, mod.exports || (mod.exports = {}));
const { solarPosition, dayProgress, formatCountdown } = mod.exports;
// Reference: NOAA solar calculator, 2026-09-09, Paris (48.8566, 2.3522), 12:00 UTC ≈ solar noon-ish
const p = solarPosition(new Date('2026-09-09T12:00:00Z'), 48.8566, 2.3522);
assert.ok(Number.isFinite(p.altitude) && Number.isFinite(p.azimuth), 'finite angles');
assert.ok(p.altitude > 35 && p.altitude < 60, `Paris noon September altitude ~45°, got ${p.altitude}`);
// Night: same place, 00:30 UTC → sun below horizon
const n = solarPosition(new Date('2026-09-09T00:30:00Z'), 48.8566, 2.3522);
assert.ok(n.altitude < -20, `midnight Paris altitude strongly negative, got ${n.altitude}`);
// Southern hemisphere summer: Sydney January noon → high sun
const syd = solarPosition(new Date('2026-01-15T03:00:00Z'), -33.8688, 151.2093);
assert.ok(syd.altitude > 55, `Sydney Jan noon altitude high, got ${syd.altitude}`);
// dayProgress: 0 at sunrise, 1 at sunset, 0.5 ≈ solar noon
const dp = dayProgress(new Date('2026-09-09T12:00:00Z'), 48.8566, 2.3522);
assert.ok(dp > 0.4 && dp < 0.6, `midday progress ≈ 0.5, got ${dp}`);
assert.equal(dayProgress(new Date('2026-09-09T00:30:00Z'), 48.8566, 2.3522), 0, 'before sunrise → 0');
assert.equal(dayProgress(new Date('2026-09-09T22:00:00Z'), 48.8566, 2.3522), 1, 'after sunset → 1');
// Polar night / midnight sun don't crash
solarPosition(new Date('2026-06-21T12:00:00Z'), 78.22, 15.63); // Svalbard
solarPosition(new Date('2026-12-21T12:00:00Z'), 78.22, 15.63);
const polar = dayProgress(new Date('2026-12-21T12:00:00Z'), 78.22, 15.63);
assert.ok(polar === 0 || polar === 1, 'polar night returns boundary');
// countdown formatting
assert.equal(formatCountdown(3725), '1h 2m');
assert.equal(formatCountdown(600), '10m');
assert.equal(formatCountdown(45), '<1m');
assert.equal(formatCountdown(0), '0m');
console.log('ALL SOLAR MATH PASS');
+34
View File
@@ -0,0 +1,34 @@
// Circle geometry: sun marker orbit, horizon line, center text block — all inside 466px circle.
const fs = require('node:fs');
const assert = require('node:assert/strict');
const ux = fs.readFileSync('src/pages/index/index.ux', 'utf8');
const num = (re) => { const m = ux.match(re); assert.ok(m, 'missing: '+re); return Number(m[1]); };
const SCREEN = 466, R = 233;
const skyD = num(/\.sky\s*{[^}]*width:\s*(\d+)px/);
assert.ok(skyD <= SCREEN - 40, 'sky dial should leave bezel margin: ' + skyD);
// horizon line spans within circle at y=233 (widest point) → width <= 466
const hzW = num(/\.horizon\s*{[^}]*width:\s*(\d+)px/);
assert.ok(hzW <= SCREEN, 'horizon width fits');
// Actual template-bound coordinates are exercised in marker.cjs.
const orbit = num(/orbitR:\s*(\d+)/);
assert.ok(orbit <= skyD / 2 - 30, `sun orbit ${orbit} must stay within sky dial ${skyD/2} minus marker radius`);
// center text block vertical span inside circle
const cTop = num(/\.center\s*{[^}]*top:\s*(\d+)px/);
const bigH = num(/\.big\s*{[^}]*height:\s*(\d+)px/);
const phaseH = num(/\.phase\s*{[^}]*height:\s*(\d+)px/);
const countH = num(/\.count\s*{[^}]*height:\s*(\d+)px/);
const cBot = cTop + bigH + phaseH + countH;
// text content is centered and narrow (~240px wide worst case = clock at 66px font); check half-width 120px fits chord
const TEXT_HALF = 120;
for (const y of [cTop, cBot]) {
const halfW = Math.sqrt(Math.max(0, R*R - Math.abs(y-R)**2));
assert.ok(TEXT_HALF <= halfW, `center text at y=${y} exceeds chord: needs ${TEXT_HALF}, has ${halfW.toFixed(0)}`);
}
// contrast
const lum = h => { const c=h.match(/\w\w/g).map(x=>parseInt(x,16)/255).map(v=>v<=0.03928?v/12.92:((v+0.055)/1.055)**2.4); return .2126*c[0]+.7152*c[1]+.0722*c[2]; };
const ratio = (a,b)=>(Math.max(lum(a),lum(b))+.05)/(Math.min(lum(a),lum(b))+.05);
assert.ok(ratio('#ffffff','#000000') >= 4.5, 'clock contrast');
assert.ok(ratio('#f3a633','#000000') >= 4.5, 'phase contrast');
assert.ok(ratio('#9aa0aa','#000000') >= 4.5, 'count contrast');
console.log('GEOMETRY ESTIMATES PASS (not renderer validation): dial', skyD, 'orbit', orbit, 'center block y', cTop, '-', cBot);