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:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
sign/*.pem
|
||||||
|
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# Xiaomi Vela Hello World for Watch S4
|
||||||
|
|
||||||
|
This is the smallest practical Xiaomi Vela JS watch application. It shows two centered lines on a round black screen:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Hello World!
|
||||||
|
Xiaomi Watch S4
|
||||||
|
```
|
||||||
|
|
||||||
|
It compiles entirely from a Linux terminal. Xiaomi AIoT-IDE is not required.
|
||||||
|
|
||||||
|
## 1. What you need
|
||||||
|
|
||||||
|
- A 64-bit Linux system
|
||||||
|
- Node.js and npm
|
||||||
|
- OpenSSL, but only if you want a signed release package
|
||||||
|
- Internet access for the first `npm install`
|
||||||
|
|
||||||
|
Node.js 20 LTS is a conservative choice. The included project has also been build-tested with Node.js 24 and npm 11.
|
||||||
|
|
||||||
|
On Ubuntu or Debian, install the basic tools:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y curl openssl
|
||||||
|
```
|
||||||
|
|
||||||
|
Install Node.js using your preferred package manager or a version manager such as `nvm`. Confirm that it works:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Understand the tiny project
|
||||||
|
|
||||||
|
```text
|
||||||
|
xiaomi-vela-s4-hello-world/
|
||||||
|
├── package.json
|
||||||
|
├── README.md
|
||||||
|
├── sign/
|
||||||
|
└── src/
|
||||||
|
├── app.ux
|
||||||
|
├── manifest.json
|
||||||
|
├── common/
|
||||||
|
│ ├── icon.png
|
||||||
|
│ └── icon.svg
|
||||||
|
└── pages/
|
||||||
|
└── index/
|
||||||
|
└── index.ux
|
||||||
|
```
|
||||||
|
|
||||||
|
- `package.json` installs Xiaomi's command-line build tool and defines the build commands.
|
||||||
|
- `src/manifest.json` defines the package, icon, watch target, design width, and start page.
|
||||||
|
- `src/app.ux` contains application lifecycle hooks.
|
||||||
|
- `src/pages/index/index.ux` contains the UI and its styles.
|
||||||
|
- `sign/` is where release certificate files go. Keep private keys private.
|
||||||
|
|
||||||
|
Vela `.ux` files resemble small single-file web components. A page may contain `template`, `style`, and `script` blocks. This Hello World page needs only `template` and `style`.
|
||||||
|
|
||||||
|
## 3. Compile a debug RPK without Xiaomi's IDE
|
||||||
|
|
||||||
|
Open a terminal in the project folder and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The important output is created under `dist/` and ends in `.debug.rpk`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
find dist -maxdepth 1 -type f -name '*.rpk' -print
|
||||||
|
```
|
||||||
|
|
||||||
|
The `build/` directory contains intermediate generated files. You normally do not edit either `build/` or `dist/`.
|
||||||
|
|
||||||
|
To include a separate source map while debugging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:source-map
|
||||||
|
```
|
||||||
|
|
||||||
|
To see every compiler option supported by your installed toolkit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run help
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Create a signed release RPK
|
||||||
|
|
||||||
|
The release builder expects these two files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
sign/private.pem
|
||||||
|
sign/certificate.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate a local development certificate from the project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl req \
|
||||||
|
-newkey rsa:2048 \
|
||||||
|
-nodes \
|
||||||
|
-keyout sign/private.pem \
|
||||||
|
-x509 \
|
||||||
|
-days 3650 \
|
||||||
|
-out sign/certificate.pem \
|
||||||
|
-subj '/CN=Hello Vela Development/O=Example/C=US'
|
||||||
|
```
|
||||||
|
|
||||||
|
Then compile the release package:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release
|
||||||
|
find dist -maxdepth 1 -type f -name '*.release.*.rpk' -print
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not commit or share `sign/private.pem`. If a companion Android application communicates with the watch app, Xiaomi's wearable integration may require matching signing identity; changing certificates later can also prevent an update from being accepted as the same app.
|
||||||
|
|
||||||
|
## 5. Change the app
|
||||||
|
|
||||||
|
Edit `src/pages/index/index.ux`. For example, replace:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<text class="hello">Hello World!</text>
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<text class="hello">My first app</text>
|
||||||
|
```
|
||||||
|
|
||||||
|
Then rebuild:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
When publishing an update, increase `versionCode` in `src/manifest.json`. Also update `versionName` when you want the visible version string to change.
|
||||||
|
|
||||||
|
## 6. Important Vela rules
|
||||||
|
|
||||||
|
- A template must have exactly one root element.
|
||||||
|
- Visible text must be inside a `<text>` component.
|
||||||
|
- Vela uses flex layout, but its CSS support is smaller than a browser's CSS support.
|
||||||
|
- Package names should use reverse-domain form, such as `com.yourname.hello`.
|
||||||
|
- `designWidth: 480` lets Vela scale the 480-pixel design to the physical display.
|
||||||
|
- APIs such as sensors, storage, network, or vibration must usually be declared in the manifest's `features` array before use. This Hello World app requests no APIs.
|
||||||
|
|
||||||
|
## 7. Installing on a real Watch S4
|
||||||
|
|
||||||
|
Compilation and installation are separate. The terminal workflow above reliably creates the `.rpk`; transferring it to a physical watch depends on the exact Watch S4 model, firmware region, Mi Fitness version, and whether developer or debugging access is enabled for that firmware.
|
||||||
|
|
||||||
|
Do not treat the `.rpk` as an Android APK, and do not flash random firmware. Use Xiaomi's supported wearable deployment route for your device/account. Some retail or global firmware builds do not expose the same development or sideload controls as Chinese developer setups.
|
||||||
|
|
||||||
|
## 8. Troubleshooting
|
||||||
|
|
||||||
|
### `aiot: command not found`
|
||||||
|
|
||||||
|
Always run the local tool through npm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not rely on a global `aiot` installation.
|
||||||
|
|
||||||
|
### Dependency installation fails
|
||||||
|
|
||||||
|
Clear only this project's installed dependencies and retry:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf node_modules
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
If you use a company proxy, configure npm for that proxy instead of disabling TLS verification.
|
||||||
|
|
||||||
|
### No `.rpk` appears
|
||||||
|
|
||||||
|
Read the first compiler error, then verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx aiot build -h
|
||||||
|
find src -maxdepth 4 -type f -print
|
||||||
|
```
|
||||||
|
|
||||||
|
The required files are `src/manifest.json`, `src/app.ux`, and the routed page component.
|
||||||
|
|
||||||
|
### Release build says signing files are missing
|
||||||
|
|
||||||
|
Run the OpenSSL command in section 4 and confirm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test -s sign/private.pem && test -s sign/certificate.pem && echo 'Signing files exist'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Official references
|
||||||
|
|
||||||
|
- Xiaomi Vela JS documentation: <https://iot.mi.com/vela/quickapp/en/>
|
||||||
|
- Project structure: <https://iot.mi.com/vela/quickapp/zh/guide/start/project-overview.html>
|
||||||
|
- Manifest configuration: <https://iot.mi.com/vela/quickapp/zh/guide/framework/manifest.html>
|
||||||
|
- Compiler parameters: <https://iot.mi.com/vela/quickapp/zh/guide/start/toolkit-params.html>
|
||||||
|
- UI introduction: <https://iot.mi.com/vela/quickapp/zh/guide/start/user-interface.html>
|
||||||
|
|
||||||
|
The project intentionally uses only the public `aiot-toolkit` command-line package. It does not require Xiaomi AIoT-IDE.
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
/tmp/hw/node_modules
|
||||||
Generated
+10223
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "xiaomi-vela-s4-hello-world",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Minimal Xiaomi Vela JS Hello World app for Xiaomi Watch S4",
|
||||||
|
"scripts": {
|
||||||
|
"build": "aiot build",
|
||||||
|
"release": "aiot release",
|
||||||
|
"build:source-map": "aiot build --devtool=source-map",
|
||||||
|
"help": "aiot build -h"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"aiot-toolkit": "2.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
onCreate() {
|
||||||
|
console.log('Hello Vela app created')
|
||||||
|
},
|
||||||
|
onDestroy() {
|
||||||
|
console.log('Hello Vela app destroyed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -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 |
@@ -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 }
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"package": "com.ilias.sundial",
|
||||||
|
"name": "SUNDIAL",
|
||||||
|
"icon": "/common/icon.png",
|
||||||
|
"versionName": "0.3",
|
||||||
|
"versionCode": 3,
|
||||||
|
"minAPILevel": 1,
|
||||||
|
"deviceTypeList": [
|
||||||
|
"watch"
|
||||||
|
],
|
||||||
|
"config": {
|
||||||
|
"logLevel": "debug",
|
||||||
|
"designWidth": 466
|
||||||
|
},
|
||||||
|
"router": {
|
||||||
|
"entry": "pages/index",
|
||||||
|
"pages": {
|
||||||
|
"pages/index": {
|
||||||
|
"component": "index",
|
||||||
|
"path": "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"display": {
|
||||||
|
"backgroundColor": "#000000"
|
||||||
|
},
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"name": "system.router"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<stack class="dial">
|
||||||
|
<!-- outer bezel ring -->
|
||||||
|
<div class="bezel"></div>
|
||||||
|
<!-- sky disc: upper half = day sky -->
|
||||||
|
<div class="sky"></div>
|
||||||
|
<!-- lower half = night -->
|
||||||
|
<div class="nightHalf"></div>
|
||||||
|
<!-- sun glow halo -->
|
||||||
|
<div class="sunHalo {{ isDay ? '' : 'moonHalo' }}" style="{{ haloStyle }}"></div>
|
||||||
|
<!-- sun / moon marker -->
|
||||||
|
<div class="sunDot {{ isDay ? '' : 'moon' }}" style="{{ markerStyle }}"></div>
|
||||||
|
<!-- horizon line -->
|
||||||
|
<div class="horizon"></div>
|
||||||
|
<!-- hour tick marks around the rim -->
|
||||||
|
<div class="tick" style="left:230px;top:20px"></div>
|
||||||
|
<div class="tick" style="left:335px;top:48px"></div>
|
||||||
|
<div class="tick" style="left:412px;top:125px"></div>
|
||||||
|
<div class="tick" style="left:440px;top:230px"></div>
|
||||||
|
<div class="tick" style="left:412px;top:335px"></div>
|
||||||
|
<div class="tick" style="left:335px;top:412px"></div>
|
||||||
|
<div class="tick" style="left:230px;top:440px"></div>
|
||||||
|
<div class="tick" style="left:125px;top:412px"></div>
|
||||||
|
<div class="tick" style="left:48px;top:335px"></div>
|
||||||
|
<div class="tick" style="left:20px;top:230px"></div>
|
||||||
|
<div class="tick" style="left:48px;top:125px"></div>
|
||||||
|
<div class="tick" style="left:125px;top:48px"></div>
|
||||||
|
<!-- noon marker -->
|
||||||
|
<div class="tick noon" style="left:230px;top:20px"></div>
|
||||||
|
<!-- center readouts -->
|
||||||
|
<div class="center">
|
||||||
|
<text class="big">{{ clock }}</text>
|
||||||
|
<text class="phase">{{ phaseLabel }}</text>
|
||||||
|
<text class="count">{{ countdown }}</text>
|
||||||
|
</div>
|
||||||
|
</stack>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import solar from '../../common/solar.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
private: {
|
||||||
|
display: '',
|
||||||
|
clock: '--:--',
|
||||||
|
phaseLabel: '',
|
||||||
|
countdown: '',
|
||||||
|
sunAngle: 0,
|
||||||
|
isDay: true,
|
||||||
|
orbitR: 150,
|
||||||
|
markerStyle: { left: '211px', top: '73px' },
|
||||||
|
haloStyle: { left: '205px', top: '67px' }
|
||||||
|
},
|
||||||
|
onInit() {
|
||||||
|
this.LAT = 48.8566
|
||||||
|
this.LON = 2.3522
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.update()
|
||||||
|
this.timer = setInterval(() => this.update(), 30000) // 30s cadence — gentle on battery
|
||||||
|
},
|
||||||
|
onHide() {
|
||||||
|
clearInterval(this.timer)
|
||||||
|
},
|
||||||
|
onDestroy() {
|
||||||
|
clearInterval(this.timer)
|
||||||
|
},
|
||||||
|
update() {
|
||||||
|
const now = new Date()
|
||||||
|
const hh = String(now.getHours()).padStart(2, '0')
|
||||||
|
const mm = String(now.getMinutes()).padStart(2, '0')
|
||||||
|
this.clock = hh + ':' + mm
|
||||||
|
|
||||||
|
const pos = solar.solarPosition(now, this.LAT, this.LON)
|
||||||
|
this.isDay = pos.altitude > 0
|
||||||
|
|
||||||
|
const prog = solar.dayProgress(now, this.LAT, this.LON)
|
||||||
|
let frac = prog
|
||||||
|
if (!this.isDay) {
|
||||||
|
frac = this.nightFrac(now)
|
||||||
|
}
|
||||||
|
// -90° (sunrise, left horizon) → 0° (noon, top) → +90° (sunset, right)
|
||||||
|
this.sunAngle = this.isDay ? -90 + prog * 180 : -90 - (1 - frac) * 180
|
||||||
|
this.positionMarker()
|
||||||
|
|
||||||
|
this.phaseLabel = this.isDay ? 'DAYLIGHT' : 'NIGHT'
|
||||||
|
const cd = this.nextTransitionSeconds(now)
|
||||||
|
this.countdown = this.isDay ? ('sunset in ' + solar.formatCountdown(cd)) : ('sunrise in ' + solar.formatCountdown(cd))
|
||||||
|
},
|
||||||
|
positionMarker() {
|
||||||
|
const angle = this.sunAngle * Math.PI / 180
|
||||||
|
const cx = 233 + this.orbitR * Math.sin(angle)
|
||||||
|
const cy = 233 - this.orbitR * Math.cos(angle)
|
||||||
|
this.markerStyle = {
|
||||||
|
left: Math.round(cx - 22) + 'px',
|
||||||
|
top: Math.round(cy - 22) + 'px'
|
||||||
|
}
|
||||||
|
this.haloStyle = {
|
||||||
|
left: Math.round(cx - 28) + 'px',
|
||||||
|
top: Math.round(cy - 28) + 'px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nightFrac(now) {
|
||||||
|
const secs = this.nextTransitionSeconds(now)
|
||||||
|
const NIGHT = 12 * 3600
|
||||||
|
return Math.max(0, Math.min(1, 1 - secs / NIGHT))
|
||||||
|
},
|
||||||
|
nextTransitionSeconds(now) {
|
||||||
|
const isDayNow = solar.solarPosition(now, this.LAT, this.LON).altitude > 0
|
||||||
|
for (let m = 5; m <= 48 * 60; m += 5) {
|
||||||
|
const t = new Date(now.getTime() + m * 60e3)
|
||||||
|
const day = solar.solarPosition(t, this.LAT, this.LON).altitude > 0
|
||||||
|
if (day !== isDayNow) return m * 60
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
width: 466px; height: 466px;
|
||||||
|
background-color: #000000;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.dial {
|
||||||
|
width: 466px; height: 466px;
|
||||||
|
}
|
||||||
|
.bezel {
|
||||||
|
position: absolute; left: 12px; top: 12px;
|
||||||
|
width: 438px; height: 438px;
|
||||||
|
border-radius: 220px;
|
||||||
|
border-width: 2px;
|
||||||
|
border-color: #2a3040;
|
||||||
|
}
|
||||||
|
.sky {
|
||||||
|
position: absolute; left: 33px; top: 33px;
|
||||||
|
width: 400px; height: 400px;
|
||||||
|
border-radius: 200px;
|
||||||
|
background-color: #0e1f3a;
|
||||||
|
}
|
||||||
|
.nightHalf {
|
||||||
|
position: absolute; left: 33px; top: 233px;
|
||||||
|
width: 400px; height: 200px;
|
||||||
|
border-radius: 0px 0px 200px 200px;
|
||||||
|
background-color: #060a14;
|
||||||
|
}
|
||||||
|
.horizon {
|
||||||
|
position: absolute; left: 33px; top: 233px;
|
||||||
|
width: 400px; height: 4px;
|
||||||
|
background-color: #4a5a7a;
|
||||||
|
}
|
||||||
|
.sunHalo {
|
||||||
|
position: absolute;
|
||||||
|
width: 56px; height: 56px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background-color: #ffc93c;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
.moonHalo {
|
||||||
|
background-color: #cfd6e4;
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
.sunDot {
|
||||||
|
position: absolute;
|
||||||
|
width: 44px; height: 44px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background-color: #ffc93c;
|
||||||
|
}
|
||||||
|
.moon {
|
||||||
|
background-color: #cfd6e4;
|
||||||
|
}
|
||||||
|
.tick {
|
||||||
|
position: absolute;
|
||||||
|
width: 6px; height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: #3a4052;
|
||||||
|
}
|
||||||
|
.tick.noon {
|
||||||
|
background-color: #f3a633;
|
||||||
|
}
|
||||||
|
.center {
|
||||||
|
position: absolute; left: 0px; top: 170px;
|
||||||
|
width: 466px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.big {
|
||||||
|
height: 84px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 66px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.phase {
|
||||||
|
height: 32px;
|
||||||
|
color: #f3a633;
|
||||||
|
font-size: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.count {
|
||||||
|
height: 36px;
|
||||||
|
color: #9aa0aa;
|
||||||
|
font-size: 22px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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');
|
||||||
@@ -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');
|
||||||
@@ -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)');
|
||||||
@@ -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');
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user