From a409dfcf53dbda4d212d086838c66ddb34650f04 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 05:14:13 +0000 Subject: [PATCH] =?UTF-8?q?Sun=20card:=20AstroWeather=20moon=20data=20+=20?= =?UTF-8?q?azimuth=20positioning=20=E2=80=94=20v0.17.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the sun card's after-sunset moon view: - The moon glyph is now positioned on the sky by the moon's azimuth (moon_azimuth_entity, or an azimuth/moon_azimuth attribute on the moon sensor): E->S->W maps left->top->right along the dome, with altitude scaling its height when available. No azimuth falls back to a centred disk. - New chips below the phase row: moonrise / moonset, "Full in Xd" (next_full_moon_entity), and "Dark ..." (next_dark_night_entity — a timestamp or a duration sensor such as AstroWeather's deep-sky darkness hours). - The tile grows to 4 rows when any optional moon sensor is configured, to fit the chip row. Fixed a relFuture rollover ("4d 24h" -> "5d"). These pair with the AstroWeather integration (moon_azimuth, moon_next_full_moon, deep_sky_darkness) but accept any matching sensor. Editor Moon section, smoke fixtures/case, preview demo, README + docs updated. Verified azimuth placement and chips in Chromium. VERSION 0.17.0 -> 0.17.1. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014cpNPVU8j1iiU9CnXgr5JY --- CLAUDE.md | 24 +++++++ README.md | 8 ++- docs/cards.md | 29 +++++++-- prism.js | 148 ++++++++++++++++++++++++++++++++---------- src/prism-shared.js | 2 +- src/prism-sun-card.js | 144 ++++++++++++++++++++++++++++++---------- test/preview.html | 7 +- test/smoke.js | 7 +- 8 files changed, 287 insertions(+), 82 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 59dce74..878c038 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,6 +132,30 @@ showcase updates itself. (Manual local regen still works too; the sandbox needs Newest first. One short entry per working session: what shipped + open threads. +### 2026-07-21 — Sun card: AstroWeather moon data + azimuth positioning (v0.17.1) +- **User:** add "next full moon", "next dark night", and make the moon graphic + reflect the moon azimuth — data from the AstroWeather integration + (mawinkler/astroweather). +- `_moonView` now positions the moon glyph on the sky **by azimuth**: a dedicated + `moon_azimuth_entity` (or an `azimuth`/`moon_azimuth` attribute on the moon + sensor) maps E(90)→S(180)→W(270) to left→top→right along a dome; `moon_altitude` + (when present as an attr) scales how high it rides. No azimuth → centred disk + fallback. Verified E/S/W → cx 40/100/160. +- Added chips below the ends row: moonrise ↑ / moonset ↓ (moved off the old + subrow), **Full in Xd** (`next_full_moon_entity`, a timestamp via `relFuture`), + and **Dark …** (`next_dark_night_entity` — a timestamp → "in Xh", or a duration + sensor like AstroWeather `deep_sky_darkness` → "6.2 h"). Fixed a `relFuture` + rollover bug ("4d 24h" → "5d"; minute carry too). +- `getGridOptions`/`getCardSize` bump to **4 rows** when any optional moon sensor + is set (room for the chip row); 3 otherwise. New config keys: `moon_azimuth_entity`, + `next_full_moon_entity`, `next_dark_night_entity` (+ existing moonrise/moonset/ + illumination). Editor Moon section extended + hint mentions AstroWeather. +- Wired smoke (moon azimuth/next-full-moon/deep-sky-darkness fixtures + case) and + preview (Sun & Moon demo now passes all sensors). README + docs/cards.md option + table, AstroWeather example, sizing note. VERSION 0.17.0 → 0.17.1. Verified in + Chromium (azimuth placement, chips, 4-row grid, no console errors); build + + smoke green. + ### 2026-07-21 — Sun card: moon-phase view after sunset (v0.17.0) - **User:** refactor the sun card to also show moon phases / rise / set, with an option to show the moon info after sunset. Kept the daytime sun-path arc as-is diff --git a/README.md b/README.md index 951edf8..b493022 100644 --- a/README.md +++ b/README.md @@ -236,8 +236,12 @@ title: Sun accent: amber show_moon: true # after sunset: moon phase + illumination moon_entity: sensor.moon # optional (defaults to sensor.moon) -# moonrise_entity: sensor.moonrise # optional -# moonset_entity: sensor.moonset # optional +# Optional (AstroWeather): azimuth positions the moon; plus full-moon / dark-night chips +# moon_azimuth_entity: sensor.astroweather_moon_azimuth +# moonrise_entity: sensor.astroweather_moonrise +# moonset_entity: sensor.astroweather_moonset +# next_full_moon_entity: sensor.astroweather_moon_next_full_moon +# next_dark_night_entity: sensor.astroweather_deep_sky_darkness ``` ```yaml diff --git a/docs/cards.md b/docs/cards.md index b178fd1..5ef5051 100644 --- a/docs/cards.md +++ b/docs/cards.md @@ -544,7 +544,7 @@ units: us Flat **sun-path arc**: a dome of the sky from sunrise to sunset with the sun riding along it, the **traveled portion filled with the accent**, **sunrise/sunset times** at each end, and a live **"sets in / rises in"** countdown in the middle. -Optionally, **after sunset** it flips to a **moon view**: a moon-phase glyph drawn to the current illuminated fraction, the phase name + **illumination %**, the next **sunrise**, and — when their sensors are configured — **moonrise / moonset**. +Optionally, **after sunset** it flips to a **moon view**: a moon-phase glyph drawn to the current illuminated fraction and **positioned on the sky by the moon's azimuth**, the phase name + **illumination %**, the next **sunrise**, and chips for **moonrise / moonset**, **next full moon**, and **next dark night** when their sensors are configured. | Key | Type | Default | Notes | |-----|------|---------|-------| @@ -553,11 +553,16 @@ Optionally, **after sunset** it flips to a **moon view**: a moon-phase glyph dra | `title` | string | — | Card header. | | `accent` | string | `amber` | Colours the traveled arc and the sun. | | `show_moon` | bool | `true` | After sunset, show the moon view instead of the resting sun arc. | -| `moon_entity` | string | `sensor.moon` | Moon-phase sensor (the Moon integration's `sensor.moon`). Its state is a phase name like `waxing_gibbous`, or a numeric phase. | -| `moonrise_entity` | string | — | Optional sensor with a moonrise timestamp. | -| `moonset_entity` | string | — | Optional sensor with a moonset timestamp. | +| `moon_entity` | string | `sensor.moon` | Moon-phase sensor (the Moon integration's `sensor.moon`). State is a phase name like `waxing_gibbous`, or a numeric phase. | +| `moon_azimuth_entity` | string | — | Optional azimuth sensor (degrees) — positions the moon on the sky (E→S→W maps left→top→right). Also read from an `azimuth`/`moon_azimuth` attribute on the moon sensor. AstroWeather: `moon_azimuth`. | +| `moonrise_entity` | string | — | Optional moonrise timestamp sensor. | +| `moonset_entity` | string | — | Optional moonset timestamp sensor. | +| `next_full_moon_entity` | string | — | Optional timestamp sensor → a "Full in Xd" chip. AstroWeather: `moon_next_full_moon`. | +| `next_dark_night_entity` | string | — | Optional. A timestamp → "Dark in Xh", or a duration sensor (e.g. AstroWeather `deep_sky_darkness` hours) → "Dark 6.2 h". | | `moon_illumination_entity` | string | — | Optional illumination-% sensor. If unset, illumination is computed from the phase. | +The azimuth / next-full-moon / dark-night values are designed to pair with the **[AstroWeather](https://github.com/mawinkler/astroweather)** integration, but any sensor providing those works. When any of the optional moon sensors are set, the tile is one grid row taller to fit the chip row. + Daytime progress uses the next setting as today's sunset and approximates today's sunrise from the next rising. At night, with `show_moon` on and a moon sensor available, the card shows the moon phase; otherwise the arc rests with a moon on the horizon. Tap opens more-info. ```yaml @@ -579,6 +584,20 @@ moonrise_entity: sensor.moonrise # optional moonset_entity: sensor.moonset # optional ``` +```yaml +# Full moon view with AstroWeather sensors. +type: custom:prism-sun-card +entity: sun.sun +title: Sun & Moon +accent: amber +moon_entity: sensor.astroweather_moon_phase +moon_azimuth_entity: sensor.astroweather_moon_azimuth +moonrise_entity: sensor.astroweather_moonrise +moonset_entity: sensor.astroweather_moonset +next_full_moon_entity: sensor.astroweather_moon_next_full_moon +next_dark_night_entity: sensor.astroweather_deep_sky_darkness +``` + --- ## `custom:prism-uv-card` @@ -730,7 +749,7 @@ Cards implement `getGridOptions()` for the sections layout: | Wind | 3 | 6 | | Weather | 3 | 6 | | Forecast | 3 | 12 | -| Sun | 3 | 6 | +| Sun | 3 (4 with moon chips) | 6 | | UV | 3 | 6 | | Rain | 3 | 6 | | Air Quality | 3 | 6 | diff --git a/prism.js b/prism.js index d7742e4..b5a36f7 100644 --- a/prism.js +++ b/prism.js @@ -20,7 +20,7 @@ if (window.PrismUI && window.PrismUI.version) return; // already loaded - const VERSION = '0.17.0'; + const VERSION = '0.17.1'; // ── Named accent presets ────────────────────────────────────────── // Selectable in every card editor; a card may also use a raw hex value. @@ -4809,10 +4809,12 @@ * times at each end, and a live "sets in / rises in" countdown. Reads `sun.sun`. * * Optionally, after sunset it flips to a **moon view**: a moon-phase glyph - * drawn to the current illuminated fraction, the phase name + illumination, the - * next sunrise, and (when sensors are configured) moonrise / moonset. Reads a - * moon-phase sensor (`sensor.moon` from the Moon integration) plus optional - * moonrise / moonset / illumination sensors. + * drawn to the current illuminated fraction and positioned on the sky by the + * moon's azimuth, the phase name + illumination, the next sunrise, and chips + * for moonrise / moonset / next full moon / next dark night. The extra data + * (azimuth, next full moon, dark-night duration) is designed to pair with the + * AstroWeather integration (github.com/mawinkler/astroweather), but any sensor + * providing those values works. * * type: custom:prism-sun-card */ @@ -4827,6 +4829,22 @@ const m = Math.round(ms / 60000), h = Math.floor(m / 60), mm = m % 60; return h ? `${h}h ${mm}m` : `${mm}m`; } + // Coarse "in Xd / Xh / Xm" for a future timestamp (used for the next full moon). + function relFuture(t) { + if (isNaN(t)) return ''; + const d = t - Date.now(); + if (d <= 0) return 'now'; + let days = Math.floor(d / DAY); + if (days >= 1) { + let h = Math.round((d - days * DAY) / 3600000); + if (h >= 24) { days += Math.floor(h / 24); h %= 24; } // avoid "4d 24h" + return h ? `${days}d ${h}h` : `${days}d`; + } + let h = Math.floor(d / 3600000); + let mm = Math.round((d % 3600000) / 60000); + if (mm >= 60) { h += 1; mm = 0; } + return h ? `${h}h ${mm}m` : `${mm}m`; + } // Moon phase names (HA `sensor.moon`) → label + phase fraction k // (0 = new, 0.25 = first quarter, 0.5 = full, 0.75 = last quarter). @@ -4843,7 +4861,7 @@ const illumFromK = (k) => (1 - Math.cos(2 * Math.PI * k)) / 2; // Resolve a moon-phase state into { label, k }. Accepts a phase name, or a - // numeric value (0–1 fraction or 0–100 percent → nearest named phase feel). + // numeric value (0–1 fraction or 0–100 percent). function moonInfo(state) { const s = String(state == null ? '' : state).toLowerCase().trim(); if (MOON[s]) return MOON[s]; @@ -4852,7 +4870,6 @@ const n = parseFloat(s); if (!isNaN(n)) { const k = n > 1 ? (n / 100) % 1 : n % 1; // percent or fraction → cycle pos - // Nearest named phase for the label. let best = 'new_moon', bd = Infinity; for (const name in MOON) { const d = Math.abs(MOON[name].k - k); if (d < bd) { bd = d; best = name; } } return { label: MOON[best].label, k }; @@ -4885,10 +4902,13 @@ this._section('Moon'), this._switch('Show moon after sunset', c.show_moon !== false, (v) => this._patch('show_moon', v)), this._picker('Moon phase sensor', c.moon_entity, (v) => this._patch('moon_entity', v), { domains: ['sensor'] }), + this._picker('Moon azimuth sensor (optional)', c.moon_azimuth_entity, (v) => this._patch('moon_azimuth_entity', v), { domains: ['sensor'] }), this._picker('Moonrise sensor (optional)', c.moonrise_entity, (v) => this._patch('moonrise_entity', v), { domains: ['sensor'] }), this._picker('Moonset sensor (optional)', c.moonset_entity, (v) => this._patch('moonset_entity', v), { domains: ['sensor'] }), + this._picker('Next full moon sensor (optional)', c.next_full_moon_entity, (v) => this._patch('next_full_moon_entity', v), { domains: ['sensor'] }), + this._picker('Next dark night sensor (optional)', c.next_dark_night_entity, (v) => this._patch('next_dark_night_entity', v), { domains: ['sensor'] }), this._picker('Illumination sensor (optional)', c.moon_illumination_entity, (v) => this._patch('moon_illumination_entity', v), { domains: ['sensor'] }), - this._hint('Defaults to sensor.moon (Moon integration) for the phase. Moonrise/moonset need their own sensors; illumination is computed from the phase if no sensor is set.') + this._hint('Defaults to sensor.moon for the phase. Azimuth positions the moon on the sky; next full moon / next dark night pair with the AstroWeather integration (moon_azimuth, moon_next_full_moon, deep_sky_darkness). Illumination is computed from the phase if no sensor is set.') ); } } @@ -4910,8 +4930,18 @@ set hass(hass) { this._hass = hass; this._render(); } - getCardSize() { return 3; } - getGridOptions() { return { rows: 3, columns: 6, min_rows: 3, min_columns: 4 }; } + // The moon view can carry an extra chip row, so make the tile a touch taller + // when any of the optional moon sensors are configured. + _hasMoonExtras() { + const c = this._config || {}; + return c.show_moon !== false && !!(c.moonrise_entity || c.moonset_entity || c.next_full_moon_entity || c.next_dark_night_entity); + } + getCardSize() { return this._hasMoonExtras() ? 4 : 3; } + getGridOptions() { + return this._hasMoonExtras() + ? { rows: 4, columns: 6, min_rows: 3, min_columns: 4 } + : { rows: 3, columns: 6, min_rows: 3, min_columns: 4 }; + } static getConfigElement() { return document.createElement('prism-sun-card-editor'); } static getStubConfig(hass) { @@ -4925,11 +4955,38 @@ })); } - _num(id) { - return id && this._hass.states[id] ? parseFloat(this._hass.states[id].state) : NaN; - } - _time(id) { - return id && this._hass.states[id] ? Date.parse(this._hass.states[id].state) : NaN; + _num(id) { return id && this._hass.states[id] ? parseFloat(this._hass.states[id].state) : NaN; } + _time(id) { return id && this._hass.states[id] ? Date.parse(this._hass.states[id].state) : NaN; } + + // Moon azimuth: a dedicated sensor, else an azimuth attribute on the moon + // sensor (AstroWeather exposes moon_azimuth as both). + _azimuth(c, moonSt) { + const a = this._num(c.moon_azimuth_entity); + if (!isNaN(a)) return ((a % 360) + 360) % 360; + const at = (moonSt && moonSt.attributes) || {}; + const n = parseFloat(at.azimuth != null ? at.azimuth : at.moon_azimuth); + return isNaN(n) ? NaN : ((n % 360) + 360) % 360; + } + _altitude(c, moonSt) { + const src = (c.moon_azimuth_entity && this._hass.states[c.moon_azimuth_entity]) || moonSt; + const at = (src && src.attributes) || {}; + const n = parseFloat(at.elevation != null ? at.elevation : (at.altitude != null ? at.altitude : at.moon_altitude)); + return isNaN(n) ? NaN : n; + } + // "Next dark night" chip text: a timestamp → "in Xh", or a duration sensor + // (e.g. AstroWeather deep-sky darkness hours) → "6.2 h". + _darkText(id) { + const st = id && this._hass.states[id]; + if (!st) return ''; + const s = st.state; + if (s == null || s === '' || s === 'unknown' || s === 'unavailable') return ''; + if (/^\d{4}-\d\d-\d\dt/i.test(String(s))) { + const t = Date.parse(s); + if (!isNaN(t)) { const r = relFuture(t); return r === 'now' ? fmtT(t) : `in ${r}`; } + } + const n = parseFloat(s); + if (!isNaN(n)) { const u = st.attributes.unit_of_measurement || 'h'; return `${P.fmtNumber(n, n < 10 ? 1 : 0)} ${u}`; } + return String(s); } _render() { @@ -4946,8 +5003,6 @@ const elev = parseFloat(a.elevation); const isDay = st ? (st.state === 'above_horizon' || (!isNaN(elev) && elev > 0)) : false; - // Should we render the moon view? Only after sunset, when enabled and a - // moon-phase sensor is available. const moonId = c.moon_entity || 'sensor.moon'; const moonSt = this._hass.states[moonId]; const showMoon = c.show_moon !== false && !isDay && !!moonSt; @@ -4968,9 +5023,10 @@ .moon-lit { fill:#cdd6e5; stroke:var(--_text-2); stroke-width:1.2; } .star { fill:var(--_text-2); } .moon .end ha-icon { color:var(--_text-2); } - .subrow { display:flex; align-items:center; justify-content:center; gap:10px; margin-top:6px; - font-size:12px; font-weight:600; color:var(--_text-2); --mdc-icon-size:15px; } - .subrow .rs { display:inline-flex; align-items:center; gap:4px; } + .chips { display:flex; flex-wrap:wrap; justify-content:center; gap:6px; margin-top:8px; } + .chip { display:inline-flex; align-items:center; gap:4px; padding:3px 9px 3px 6px; border-radius:999px; + background:var(--_surface-2); font-size:11px; font-weight:600; color:var(--_text-2); + --mdc-icon-size:14px; white-space:nowrap; } `; const inner = showMoon @@ -4987,7 +5043,7 @@ P.bindTap(this.shadowRoot.querySelector('.prism-card'), () => this._moreInfo(), () => this._moreInfo()); } - // Daytime sun-path arc (unchanged behaviour). + // Daytime sun-path arc. _sunView(a, st, rise, set, now, isDay, accent, name) { let f = 0, haveArc = false; if (isDay && !isNaN(rise) && !isNaN(set)) { @@ -5046,32 +5102,52 @@ `; } - // Night moon view: phase glyph + phase name / illumination, next sunrise, - // and moonrise / moonset when their sensors are configured. + // Night moon view: phase glyph positioned by azimuth, phase / illumination, + // next sunrise, and chips for moonrise / moonset / full moon / dark night. _moonView(moonSt, sunRise, c, name) { const mi = moonInfo(moonSt.state) || { label: (moonSt.state || 'Moon'), k: 0.5 }; const illumSensor = this._num(c.moon_illumination_entity); const illum = !isNaN(illumSensor) ? illumSensor : Math.round(illumFromK(mi.k) * 100); const moonrise = this._time(c.moonrise_entity); const moonset = this._time(c.moonset_entity); + const az = this._azimuth(c, moonSt); + const alt = this._altitude(c, moonSt); + + // Place the moon on the sky dome by azimuth (E→S→W maps left→top→right); + // altitude, when available, scales how high it rides. Falls back to a + // centred disk when there's no azimuth. + let cx = 100, cy = 40, r = 22; + const haveAz = !isNaN(az); + if (haveAz) { + const R = 60, CXo = 100, CYo = 78; + const fr = P.clamp((az - 90) / 180, 0, 1); + const th = Math.PI * (1 - fr); + const altScale = !isNaN(alt) ? P.clamp(alt / 70, 0.18, 1) : 0.85; + cx = CXo + R * Math.cos(th); + cy = CYo - R * Math.sin(th) * altScale; + r = 18; + } - // Moon glyph (viewBox 200×96): a disk with the lit fraction + a few stars. - const CX = 100, CY = 40, R = 26; - const stars = [[46, 22], [58, 52], [150, 26], [162, 58], [134, 16]] + const stars = [[46, 22], [58, 52], [150, 26], [162, 58], [134, 16], [92, 12]] .map(([x, y], i) => ``).join(''); - const litPath = mi.k <= 0.001 ? '' // new moon: nothing lit - : mi.k >= 0.499 && mi.k <= 0.501 - ? `` // full - : ``; + const litPath = mi.k <= 0.001 ? '' + : (mi.k >= 0.499 && mi.k <= 0.501) + ? `` + : ``; - const rsBits = []; - if (!isNaN(moonrise)) rsBits.push(`Moonrise ${fmtT(moonrise)}`); - if (!isNaN(moonset)) rsBits.push(`Moonset ${fmtT(moonset)}`); + const chip = (icon, text) => `${P.esc(text)}`; + const chips = []; + if (!isNaN(moonrise)) chips.push(chip('mdi:arrow-up-thin', `Rise ${fmtT(moonrise)}`)); + if (!isNaN(moonset)) chips.push(chip('mdi:arrow-down-thin', `Set ${fmtT(moonset)}`)); + const ffm = this._time(c.next_full_moon_entity); + if (!isNaN(ffm)) chips.push(chip('mdi:moon-full', `Full in ${relFuture(ffm)}`)); + const dark = this._darkText(c.next_dark_night_entity); + if (dark) chips.push(chip('mdi:weather-night', `Dark ${dark}`)); return ` ${stars} - + ${litPath} @@ -5080,7 +5156,7 @@ ${P.esc(mi.label)} ${fmtT(sunRise)} - ${rsBits.length ? `
${rsBits.join('')}
` : ''}`; + ${chips.length ? `
${chips.join('')}
` : ''}`; } } @@ -5088,7 +5164,7 @@ P.registerCard({ type: 'prism-sun-card', name: 'Prism Sun Card', - description: 'Flat sun-path arc with sunrise/sunset + countdown; optionally flips to a moon-phase view after sunset.', + description: 'Flat sun-path arc with sunrise/sunset + countdown; optionally an azimuth-positioned moon-phase view (phase, illumination, next full moon, dark night) after sunset.', }); })(); diff --git a/src/prism-shared.js b/src/prism-shared.js index 1708362..8c8bc0d 100644 --- a/src/prism-shared.js +++ b/src/prism-shared.js @@ -16,7 +16,7 @@ if (window.PrismUI && window.PrismUI.version) return; // already loaded - const VERSION = '0.17.0'; + const VERSION = '0.17.1'; // ── Named accent presets ────────────────────────────────────────── // Selectable in every card editor; a card may also use a raw hex value. diff --git a/src/prism-sun-card.js b/src/prism-sun-card.js index 5776d1b..0307028 100644 --- a/src/prism-sun-card.js +++ b/src/prism-sun-card.js @@ -5,10 +5,12 @@ * times at each end, and a live "sets in / rises in" countdown. Reads `sun.sun`. * * Optionally, after sunset it flips to a **moon view**: a moon-phase glyph - * drawn to the current illuminated fraction, the phase name + illumination, the - * next sunrise, and (when sensors are configured) moonrise / moonset. Reads a - * moon-phase sensor (`sensor.moon` from the Moon integration) plus optional - * moonrise / moonset / illumination sensors. + * drawn to the current illuminated fraction and positioned on the sky by the + * moon's azimuth, the phase name + illumination, the next sunrise, and chips + * for moonrise / moonset / next full moon / next dark night. The extra data + * (azimuth, next full moon, dark-night duration) is designed to pair with the + * AstroWeather integration (github.com/mawinkler/astroweather), but any sensor + * providing those values works. * * type: custom:prism-sun-card */ @@ -23,6 +25,22 @@ const m = Math.round(ms / 60000), h = Math.floor(m / 60), mm = m % 60; return h ? `${h}h ${mm}m` : `${mm}m`; } + // Coarse "in Xd / Xh / Xm" for a future timestamp (used for the next full moon). + function relFuture(t) { + if (isNaN(t)) return ''; + const d = t - Date.now(); + if (d <= 0) return 'now'; + let days = Math.floor(d / DAY); + if (days >= 1) { + let h = Math.round((d - days * DAY) / 3600000); + if (h >= 24) { days += Math.floor(h / 24); h %= 24; } // avoid "4d 24h" + return h ? `${days}d ${h}h` : `${days}d`; + } + let h = Math.floor(d / 3600000); + let mm = Math.round((d % 3600000) / 60000); + if (mm >= 60) { h += 1; mm = 0; } + return h ? `${h}h ${mm}m` : `${mm}m`; + } // Moon phase names (HA `sensor.moon`) → label + phase fraction k // (0 = new, 0.25 = first quarter, 0.5 = full, 0.75 = last quarter). @@ -39,7 +57,7 @@ const illumFromK = (k) => (1 - Math.cos(2 * Math.PI * k)) / 2; // Resolve a moon-phase state into { label, k }. Accepts a phase name, or a - // numeric value (0–1 fraction or 0–100 percent → nearest named phase feel). + // numeric value (0–1 fraction or 0–100 percent). function moonInfo(state) { const s = String(state == null ? '' : state).toLowerCase().trim(); if (MOON[s]) return MOON[s]; @@ -48,7 +66,6 @@ const n = parseFloat(s); if (!isNaN(n)) { const k = n > 1 ? (n / 100) % 1 : n % 1; // percent or fraction → cycle pos - // Nearest named phase for the label. let best = 'new_moon', bd = Infinity; for (const name in MOON) { const d = Math.abs(MOON[name].k - k); if (d < bd) { bd = d; best = name; } } return { label: MOON[best].label, k }; @@ -81,10 +98,13 @@ this._section('Moon'), this._switch('Show moon after sunset', c.show_moon !== false, (v) => this._patch('show_moon', v)), this._picker('Moon phase sensor', c.moon_entity, (v) => this._patch('moon_entity', v), { domains: ['sensor'] }), + this._picker('Moon azimuth sensor (optional)', c.moon_azimuth_entity, (v) => this._patch('moon_azimuth_entity', v), { domains: ['sensor'] }), this._picker('Moonrise sensor (optional)', c.moonrise_entity, (v) => this._patch('moonrise_entity', v), { domains: ['sensor'] }), this._picker('Moonset sensor (optional)', c.moonset_entity, (v) => this._patch('moonset_entity', v), { domains: ['sensor'] }), + this._picker('Next full moon sensor (optional)', c.next_full_moon_entity, (v) => this._patch('next_full_moon_entity', v), { domains: ['sensor'] }), + this._picker('Next dark night sensor (optional)', c.next_dark_night_entity, (v) => this._patch('next_dark_night_entity', v), { domains: ['sensor'] }), this._picker('Illumination sensor (optional)', c.moon_illumination_entity, (v) => this._patch('moon_illumination_entity', v), { domains: ['sensor'] }), - this._hint('Defaults to sensor.moon (Moon integration) for the phase. Moonrise/moonset need their own sensors; illumination is computed from the phase if no sensor is set.') + this._hint('Defaults to sensor.moon for the phase. Azimuth positions the moon on the sky; next full moon / next dark night pair with the AstroWeather integration (moon_azimuth, moon_next_full_moon, deep_sky_darkness). Illumination is computed from the phase if no sensor is set.') ); } } @@ -106,8 +126,18 @@ set hass(hass) { this._hass = hass; this._render(); } - getCardSize() { return 3; } - getGridOptions() { return { rows: 3, columns: 6, min_rows: 3, min_columns: 4 }; } + // The moon view can carry an extra chip row, so make the tile a touch taller + // when any of the optional moon sensors are configured. + _hasMoonExtras() { + const c = this._config || {}; + return c.show_moon !== false && !!(c.moonrise_entity || c.moonset_entity || c.next_full_moon_entity || c.next_dark_night_entity); + } + getCardSize() { return this._hasMoonExtras() ? 4 : 3; } + getGridOptions() { + return this._hasMoonExtras() + ? { rows: 4, columns: 6, min_rows: 3, min_columns: 4 } + : { rows: 3, columns: 6, min_rows: 3, min_columns: 4 }; + } static getConfigElement() { return document.createElement('prism-sun-card-editor'); } static getStubConfig(hass) { @@ -121,11 +151,38 @@ })); } - _num(id) { - return id && this._hass.states[id] ? parseFloat(this._hass.states[id].state) : NaN; + _num(id) { return id && this._hass.states[id] ? parseFloat(this._hass.states[id].state) : NaN; } + _time(id) { return id && this._hass.states[id] ? Date.parse(this._hass.states[id].state) : NaN; } + + // Moon azimuth: a dedicated sensor, else an azimuth attribute on the moon + // sensor (AstroWeather exposes moon_azimuth as both). + _azimuth(c, moonSt) { + const a = this._num(c.moon_azimuth_entity); + if (!isNaN(a)) return ((a % 360) + 360) % 360; + const at = (moonSt && moonSt.attributes) || {}; + const n = parseFloat(at.azimuth != null ? at.azimuth : at.moon_azimuth); + return isNaN(n) ? NaN : ((n % 360) + 360) % 360; + } + _altitude(c, moonSt) { + const src = (c.moon_azimuth_entity && this._hass.states[c.moon_azimuth_entity]) || moonSt; + const at = (src && src.attributes) || {}; + const n = parseFloat(at.elevation != null ? at.elevation : (at.altitude != null ? at.altitude : at.moon_altitude)); + return isNaN(n) ? NaN : n; } - _time(id) { - return id && this._hass.states[id] ? Date.parse(this._hass.states[id].state) : NaN; + // "Next dark night" chip text: a timestamp → "in Xh", or a duration sensor + // (e.g. AstroWeather deep-sky darkness hours) → "6.2 h". + _darkText(id) { + const st = id && this._hass.states[id]; + if (!st) return ''; + const s = st.state; + if (s == null || s === '' || s === 'unknown' || s === 'unavailable') return ''; + if (/^\d{4}-\d\d-\d\dt/i.test(String(s))) { + const t = Date.parse(s); + if (!isNaN(t)) { const r = relFuture(t); return r === 'now' ? fmtT(t) : `in ${r}`; } + } + const n = parseFloat(s); + if (!isNaN(n)) { const u = st.attributes.unit_of_measurement || 'h'; return `${P.fmtNumber(n, n < 10 ? 1 : 0)} ${u}`; } + return String(s); } _render() { @@ -142,8 +199,6 @@ const elev = parseFloat(a.elevation); const isDay = st ? (st.state === 'above_horizon' || (!isNaN(elev) && elev > 0)) : false; - // Should we render the moon view? Only after sunset, when enabled and a - // moon-phase sensor is available. const moonId = c.moon_entity || 'sensor.moon'; const moonSt = this._hass.states[moonId]; const showMoon = c.show_moon !== false && !isDay && !!moonSt; @@ -164,9 +219,10 @@ .moon-lit { fill:#cdd6e5; stroke:var(--_text-2); stroke-width:1.2; } .star { fill:var(--_text-2); } .moon .end ha-icon { color:var(--_text-2); } - .subrow { display:flex; align-items:center; justify-content:center; gap:10px; margin-top:6px; - font-size:12px; font-weight:600; color:var(--_text-2); --mdc-icon-size:15px; } - .subrow .rs { display:inline-flex; align-items:center; gap:4px; } + .chips { display:flex; flex-wrap:wrap; justify-content:center; gap:6px; margin-top:8px; } + .chip { display:inline-flex; align-items:center; gap:4px; padding:3px 9px 3px 6px; border-radius:999px; + background:var(--_surface-2); font-size:11px; font-weight:600; color:var(--_text-2); + --mdc-icon-size:14px; white-space:nowrap; } `; const inner = showMoon @@ -183,7 +239,7 @@ P.bindTap(this.shadowRoot.querySelector('.prism-card'), () => this._moreInfo(), () => this._moreInfo()); } - // Daytime sun-path arc (unchanged behaviour). + // Daytime sun-path arc. _sunView(a, st, rise, set, now, isDay, accent, name) { let f = 0, haveArc = false; if (isDay && !isNaN(rise) && !isNaN(set)) { @@ -242,32 +298,52 @@ `; } - // Night moon view: phase glyph + phase name / illumination, next sunrise, - // and moonrise / moonset when their sensors are configured. + // Night moon view: phase glyph positioned by azimuth, phase / illumination, + // next sunrise, and chips for moonrise / moonset / full moon / dark night. _moonView(moonSt, sunRise, c, name) { const mi = moonInfo(moonSt.state) || { label: (moonSt.state || 'Moon'), k: 0.5 }; const illumSensor = this._num(c.moon_illumination_entity); const illum = !isNaN(illumSensor) ? illumSensor : Math.round(illumFromK(mi.k) * 100); const moonrise = this._time(c.moonrise_entity); const moonset = this._time(c.moonset_entity); + const az = this._azimuth(c, moonSt); + const alt = this._altitude(c, moonSt); + + // Place the moon on the sky dome by azimuth (E→S→W maps left→top→right); + // altitude, when available, scales how high it rides. Falls back to a + // centred disk when there's no azimuth. + let cx = 100, cy = 40, r = 22; + const haveAz = !isNaN(az); + if (haveAz) { + const R = 60, CXo = 100, CYo = 78; + const fr = P.clamp((az - 90) / 180, 0, 1); + const th = Math.PI * (1 - fr); + const altScale = !isNaN(alt) ? P.clamp(alt / 70, 0.18, 1) : 0.85; + cx = CXo + R * Math.cos(th); + cy = CYo - R * Math.sin(th) * altScale; + r = 18; + } - // Moon glyph (viewBox 200×96): a disk with the lit fraction + a few stars. - const CX = 100, CY = 40, R = 26; - const stars = [[46, 22], [58, 52], [150, 26], [162, 58], [134, 16]] + const stars = [[46, 22], [58, 52], [150, 26], [162, 58], [134, 16], [92, 12]] .map(([x, y], i) => ``).join(''); - const litPath = mi.k <= 0.001 ? '' // new moon: nothing lit - : mi.k >= 0.499 && mi.k <= 0.501 - ? `` // full - : ``; + const litPath = mi.k <= 0.001 ? '' + : (mi.k >= 0.499 && mi.k <= 0.501) + ? `` + : ``; - const rsBits = []; - if (!isNaN(moonrise)) rsBits.push(`Moonrise ${fmtT(moonrise)}`); - if (!isNaN(moonset)) rsBits.push(`Moonset ${fmtT(moonset)}`); + const chip = (icon, text) => `${P.esc(text)}`; + const chips = []; + if (!isNaN(moonrise)) chips.push(chip('mdi:arrow-up-thin', `Rise ${fmtT(moonrise)}`)); + if (!isNaN(moonset)) chips.push(chip('mdi:arrow-down-thin', `Set ${fmtT(moonset)}`)); + const ffm = this._time(c.next_full_moon_entity); + if (!isNaN(ffm)) chips.push(chip('mdi:moon-full', `Full in ${relFuture(ffm)}`)); + const dark = this._darkText(c.next_dark_night_entity); + if (dark) chips.push(chip('mdi:weather-night', `Dark ${dark}`)); return ` ${stars} - + ${litPath} @@ -276,7 +352,7 @@ ${P.esc(mi.label)} ${fmtT(sunRise)} - ${rsBits.length ? `
${rsBits.join('')}
` : ''}`; + ${chips.length ? `
${chips.join('')}
` : ''}`; } } @@ -284,6 +360,6 @@ P.registerCard({ type: 'prism-sun-card', name: 'Prism Sun Card', - description: 'Flat sun-path arc with sunrise/sunset + countdown; optionally flips to a moon-phase view after sunset.', + description: 'Flat sun-path arc with sunrise/sunset + countdown; optionally an azimuth-positioned moon-phase view (phase, illumination, next full moon, dark night) after sunset.', }); })(); diff --git a/test/preview.html b/test/preview.html index 7c78930..f5583b2 100644 --- a/test/preview.html +++ b/test/preview.html @@ -108,9 +108,12 @@

Prism — Card Preview

'weather.home': { state: 'partlycloudy', attributes: { friendly_name: 'Home', temperature: 18, apparent_temperature: 16, temperature_unit: '°C', humidity: 62, pressure: 1013, pressure_unit: 'hPa', wind_speed: 24, wind_bearing: 315, wind_gust_speed: 41, wind_speed_unit: 'km/h' } }, 'sun.sun': { state: 'above_horizon', attributes: { friendly_name: 'Sun', next_rising: (() => { const d = new Date(); d.setDate(d.getDate() + 1); d.setHours(6, 12, 0, 0); return d.toISOString(); })(), next_setting: (() => { const d = new Date(); d.setHours(20, 24, 0, 0); return d.toISOString(); })(), elevation: 38, azimuth: 232 } }, 'sun.after_sunset': { state: 'below_horizon', attributes: { friendly_name: 'Sun', next_rising: (() => { const d = new Date(); d.setDate(d.getDate() + 1); d.setHours(6, 14, 0, 0); return d.toISOString(); })(), next_setting: (() => { const d = new Date(); d.setDate(d.getDate() + 1); d.setHours(20, 22, 0, 0); return d.toISOString(); })(), elevation: -14, azimuth: 300 } }, - 'sensor.moon': { state: 'waxing_gibbous', attributes: { friendly_name: 'Moon' } }, + 'sensor.moon': { state: 'waxing_gibbous', attributes: { friendly_name: 'Moon', azimuth: 205 } }, 'sensor.moonrise': { state: (() => { const d = new Date(); d.setHours(15, 42, 0, 0); return d.toISOString(); })(), attributes: { friendly_name: 'Moonrise', device_class: 'timestamp' } }, 'sensor.moonset': { state: (() => { const d = new Date(); d.setDate(d.getDate() + 1); d.setHours(2, 8, 0, 0); return d.toISOString(); })(), attributes: { friendly_name: 'Moonset', device_class: 'timestamp' } }, + 'sensor.moon_azimuth': { state: '205.4', attributes: { friendly_name: 'Moon Azimuth', unit_of_measurement: '°', elevation: 34 } }, + 'sensor.next_full_moon': { state: (() => { const d = new Date(); d.setDate(d.getDate() + 5); d.setHours(21, 0, 0, 0); return d.toISOString(); })(), attributes: { friendly_name: 'Next Full Moon', device_class: 'timestamp' } }, + 'sensor.deep_sky_darkness': { state: '6.2', attributes: { friendly_name: 'Deep Sky Darkness', unit_of_measurement: 'h' } }, 'sensor.uv_index': { state: '7', attributes: { friendly_name: 'UV Index' } }, 'sensor.eco_temp': { state: '19.4', attributes: { friendly_name: 'Outdoor Temp', unit_of_measurement: '°C', device_class: 'temperature' } }, 'sensor.eco_hum': { state: '58', attributes: { friendly_name: 'Outdoor Humidity', unit_of_measurement: '%', device_class: 'humidity' } }, @@ -219,7 +222,7 @@

Prism — Card Preview

{ type: 'prism-weather-card', cfg: { entity: 'weather.home', title: 'Weather (local sensors)', accent: 'blue', temperature_entity: 'sensor.eco_temp', humidity_entity: 'sensor.eco_hum', wind_speed_entity: 'sensor.eco_wind', wind_bearing_entity: 'sensor.eco_dir' } }, { type: 'prism-forecast-card', cfg: { entity: 'weather.home', title: 'Forecast', forecast_type: 'daily', count: 7, accent: 'blue' }, wide: true }, { type: 'prism-sun-card', cfg: { entity: 'sun.sun', title: 'Sun', accent: 'amber' } }, - { type: 'prism-sun-card', cfg: { entity: 'sun.after_sunset', title: 'Sun & Moon', accent: 'amber', moon_entity: 'sensor.moon', moonrise_entity: 'sensor.moonrise', moonset_entity: 'sensor.moonset' } }, + { type: 'prism-sun-card', cfg: { entity: 'sun.after_sunset', title: 'Sun & Moon', accent: 'amber', moon_entity: 'sensor.moon', moon_azimuth_entity: 'sensor.moon_azimuth', moonrise_entity: 'sensor.moonrise', moonset_entity: 'sensor.moonset', next_full_moon_entity: 'sensor.next_full_moon', next_dark_night_entity: 'sensor.deep_sky_darkness' } }, { type: 'prism-uv-card', cfg: { entity: 'sensor.uv_index', title: 'UV Index' } }, { type: 'prism-rain-card', cfg: { title: 'Rainfall', accent: 'blue', event_entity: 'sensor.rain_event', intensity_entity: 'sensor.rain_rate', hourly_entity: 'sensor.rain_hourly', daily_entity: 'sensor.rain_daily', weekly_entity: 'sensor.rain_weekly', monthly_entity: 'sensor.rain_monthly' } }, { type: 'prism-aqi-card', cfg: { title: 'Air Quality', entity: 'sensor.aqi' } }, diff --git a/test/smoke.js b/test/smoke.js index a9d61dd..1966587 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -64,9 +64,12 @@ const hass = { 'sensor.wind_dir': { state: 'NW', attributes: { friendly_name: 'Wind Dir' } }, 'sun.sun': { state: 'above_horizon', attributes: { friendly_name: 'Sun', next_rising: new Date(now + 20 * 3600000).toISOString(), next_setting: new Date(now + 6 * 3600000).toISOString(), elevation: 34, azimuth: 210 } }, 'sun.night': { state: 'below_horizon', attributes: { friendly_name: 'Sun', next_rising: new Date(now + 9 * 3600000).toISOString(), next_setting: new Date(now + 20 * 3600000).toISOString(), elevation: -18, azimuth: 300 } }, - 'sensor.moon': { state: 'waxing_gibbous', attributes: { friendly_name: 'Moon' } }, + 'sensor.moon': { state: 'waxing_gibbous', attributes: { friendly_name: 'Moon', azimuth: 205 } }, 'sensor.moonrise': { state: new Date(now + 3 * 3600000).toISOString(), attributes: { friendly_name: 'Moonrise', device_class: 'timestamp' } }, 'sensor.moonset': { state: new Date(now + 11 * 3600000).toISOString(), attributes: { friendly_name: 'Moonset', device_class: 'timestamp' } }, + 'sensor.moon_azimuth': { state: '205.4', attributes: { friendly_name: 'Moon Azimuth', unit_of_measurement: '°', elevation: 32 } }, + 'sensor.next_full_moon': { state: new Date(now + 5 * 86400000).toISOString(), attributes: { friendly_name: 'Next Full Moon', device_class: 'timestamp' } }, + 'sensor.deep_sky_darkness': { state: '6.2', attributes: { friendly_name: 'Deep Sky Darkness', unit_of_measurement: 'h' } }, 'sensor.uv_index': { state: '6.2', attributes: { friendly_name: 'UV Index' } }, 'sensor.eco_temp': { state: '19.4', attributes: { friendly_name: 'Outdoor Temp', unit_of_measurement: '°C' } }, 'sensor.eco_hum': { state: '58', attributes: { friendly_name: 'Outdoor Humidity', unit_of_measurement: '%' } }, @@ -164,7 +167,7 @@ check('parses compact history', async () => {}); ['prism-forecast-card', { source: 'nws', location: 'custom', latitude: 40.7128, longitude: -74.006, forecast_type: 'hourly', count: 8 }], ['prism-sun-card', { entity: 'sun.sun', title: 'Sun' }], ['prism-sun-card', { entity: 'sun.sun', accent: 'orange' }], - ['prism-sun-card', { entity: 'sun.night', show_moon: true, moon_entity: 'sensor.moon', moonrise_entity: 'sensor.moonrise', moonset_entity: 'sensor.moonset' }], + ['prism-sun-card', { entity: 'sun.night', show_moon: true, moon_entity: 'sensor.moon', moonrise_entity: 'sensor.moonrise', moonset_entity: 'sensor.moonset', moon_azimuth_entity: 'sensor.moon_azimuth', next_full_moon_entity: 'sensor.next_full_moon', next_dark_night_entity: 'sensor.deep_sky_darkness' }], ['prism-sun-card', { entity: 'sun.night', show_moon: false }], ['prism-uv-card', { entity: 'sensor.uv_index', title: 'UV' }], ['prism-uv-card', { entity: 'weather.x', attribute: 'uv_index', show_advice: false }],