diff --git a/CLAUDE.md b/CLAUDE.md index 15611d6..59dce74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,6 +132,35 @@ 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: 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 + and added a **night moon view** gated by `show_moon` (default true) + a moon + sensor being present. +- `prism-sun-card` now branches in `_render`: daytime → `_sunView` (the existing + arc, extracted verbatim into a method); after sunset (state `below_horizon` / + elevation ≤ 0) with `show_moon` on and the moon entity available → `_moonView`. + The moon view draws a **moon-phase glyph** via `moonLitPath(cx,cy,r,k)` (outer + semicircle on the lit side + a terminator half-ellipse of x-radius + `|cos(2πk)|·r`; waxing lights the right), with new-moon = nothing lit and + full-moon = full circle special-cased. Shows illumination % (from an optional + sensor, else computed `(1-cos2πk)/2`), the phase label, the next sunrise, and a + moonrise/moonset subrow when those sensors are configured. `moonInfo()` maps + the 8 HA phase names (or a numeric phase) → `{label, k}`. +- Config adds `show_moon`, `moon_entity` (default `sensor.moon`), `moonrise_entity`, + `moonset_entity`, `moon_illumination_entity`. Editor gains a **Moon** section + (switch + 4 pickers + hint). `getStubConfig` sets `moon_entity` when + `sensor.moon` exists. Moon colours are theme-safe (`#cdd6e5` lit + `--_text-2` + outline, `--_surface-2` unlit disk), moon icons use `--_text-2` (not accent). +- Wired: smoke gains a `sun.night` + `sensor.moon`/moonrise/moonset fixtures and + 2 cases; preview gains a `sun.after_sunset` + moon mocks and a "Sun & Moon" + demo (renders offline, so the showcase picks it up). README table + quick-start, + docs/cards.md option table + example. VERSION 0.16.5 → 0.17.0. +- Verified in real Chromium across all **8 phases**: moon view active at night, + correct lit geometry (first/last quarter = half-width, gibbous > half, full = + circle, crescents thin), illumination 0→100%, phase labels, moonrise/moonset + subrow, zero console errors. `bash build.sh` + `node test/smoke.js` green. + ### 2026-07-21 — Editor icon fields: HA icon-picker dropdown w/ fallback (v0.16.5) - **User:** can the visual editor use the icon picker dropdown? Added a shared `_iconField(value, onChange, label?)` on `PrismEditor`: uses HA's searchable diff --git a/README.md b/README.md index 6288d38..951edf8 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Prism is two things working together: | **Wind** | `custom:prism-wind-card` | Wind tile: dominant speed, a flat compass rose with an accent direction arrow, Beaufort descriptor, gusts, and animated flat wind-streak accents that intensify with the wind. Reads a `weather.*` entity or individual sensors. | | **Weather** | `custom:prism-weather-card` | Current-conditions tile: big temperature, a flat animated condition icon, feels-like + today's high/low, and humidity / wind / pressure chips. Reads a `weather.*` entity. | | **Forecast** | `custom:prism-forecast-card` | Daily or hourly forecast strip: a flat condition icon per period, high/low temps, and precipitation-chance chips. Reads a `weather.*` entity **or** the free US National Weather Service API (weather.gov). | -| **Sun** | `custom:prism-sun-card` | Sun-path arc: sunrise/sunset times, the sun riding its arc (a moon below the horizon at night), the traveled portion filled with the accent, and a live "sets in / rises in" countdown. Reads `sun.sun`. | +| **Sun** | `custom:prism-sun-card` | Sun-path arc: sunrise/sunset times, the sun riding its arc, the traveled portion filled with the accent, and a live "sets in / rises in" countdown. After sunset it can flip to a moon-phase view (phase, illumination, moonrise/moonset). Reads `sun.sun` (+ optional `sensor.moon`). | | **UV** | `custom:prism-uv-card` | UV-index tile: a big value coloured by its WHO risk band, the category + sun-protection advice, and a flat UV-ramp scale (green→purple) with a value marker. Reads a UV sensor or a `weather.*` `uv_index`. | | **Rain** | `custom:prism-rain-card` | Animated rain-gauge tile: a measuring cylinder that fills with the event total, raindrops falling in at a rate set by the intensity, the event amount + a rain-rate descriptor, and chips for hourly / 24h / weekly / monthly. | | **Air Quality** | `custom:prism-aqi-card` | AQI tile: a big value coloured by its US EPA category, the category + health advice, and a flat Good→Hazardous scale with a value marker. Reads an AQI sensor. | @@ -229,10 +229,15 @@ title: UV Index ```yaml # Sun — sunrise/sunset arc with a live countdown. Reads sun.sun. +# After sunset it can show the moon phase (needs the Moon integration). type: custom:prism-sun-card entity: sun.sun 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 ``` ```yaml diff --git a/docs/cards.md b/docs/cards.md index 617232b..b178fd1 100644 --- a/docs/cards.md +++ b/docs/cards.md @@ -542,7 +542,9 @@ units: us ## `custom:prism-sun-card` -Flat **sun-path arc**: a dome of the sky from sunrise to sunset with the sun riding along it (a moon below the horizon at night), the **traveled portion filled with the accent**, **sunrise/sunset times** at each end, and a live **"sets in / rises in"** countdown in the middle. +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**. | Key | Type | Default | Notes | |-----|------|---------|-------| @@ -550,8 +552,13 @@ Flat **sun-path arc**: a dome of the sky from sunrise to sunset with the sun rid | `name` | string | friendly name | Used in the accessible label. | | `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_illumination_entity` | string | — | Optional illumination-% sensor. If unset, illumination is computed from the phase. | -Daytime progress uses the next setting as today's sunset and approximates today's sunrise from the next rising; at night the arc rests and a moon sits on the horizon. Tap opens more-info. +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 type: custom:prism-sun-card @@ -560,6 +567,18 @@ title: Sun accent: amber ``` +```yaml +# With moon phase after sunset (needs the Moon integration for sensor.moon). +type: custom:prism-sun-card +entity: sun.sun +title: Sun & Moon +accent: amber +show_moon: true +moon_entity: sensor.moon +moonrise_entity: sensor.moonrise # optional +moonset_entity: sensor.moonset # optional +``` + --- ## `custom:prism-uv-card` diff --git a/prism.js b/prism.js index e74d082..d7742e4 100644 --- a/prism.js +++ b/prism.js @@ -20,7 +20,7 @@ if (window.PrismUI && window.PrismUI.version) return; // already loaded - const VERSION = '0.16.5'; + const VERSION = '0.17.0'; // ── Named accent presets ────────────────────────────────────────── // Selectable in every card editor; a card may also use a raw hex value. @@ -4805,9 +4805,14 @@ /** * prism-sun-card * Flat sun-path tile: an arc of the sky from sunrise to sunset with the sun - * riding along it (a moon below the horizon at night), the traveled portion - * filled with the accent, sunrise/sunset times at each end, and a live - * "sets in / rises in" countdown. Reads the `sun.sun` entity. + * riding along it, the traveled portion filled with the accent, sunrise/sunset + * 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. * * type: custom:prism-sun-card */ @@ -4823,6 +4828,50 @@ 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). + const MOON = { + new_moon: { label: 'New Moon', k: 0.0 }, + waxing_crescent: { label: 'Waxing Crescent', k: 0.125 }, + first_quarter: { label: 'First Quarter', k: 0.25 }, + waxing_gibbous: { label: 'Waxing Gibbous', k: 0.375 }, + full_moon: { label: 'Full Moon', k: 0.5 }, + waning_gibbous: { label: 'Waning Gibbous', k: 0.625 }, + last_quarter: { label: 'Last Quarter', k: 0.75 }, + waning_crescent: { label: 'Waning Crescent', k: 0.875 }, + }; + 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). + function moonInfo(state) { + const s = String(state == null ? '' : state).toLowerCase().trim(); + if (MOON[s]) return MOON[s]; + const key = s.replace(/[\s-]+/g, '_'); + if (MOON[key]) return MOON[key]; + 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 }; + } + return null; + } + + // SVG path for the illuminated portion of a moon disk at phase fraction k. + function moonLitPath(cx, cy, r, k) { + const b = Math.cos(2 * Math.PI * k); // 1 (new) … -1 (full) + const rx = Math.abs(b) * r; // terminator ellipse x-radius + const waxing = k < 0.5; // northern hemisphere: lit on the right + const outerSweep = waxing ? 1 : 0; + const termSweep = waxing ? (b > 0 ? 0 : 1) : (b > 0 ? 1 : 0); + return `M${cx.toFixed(1)},${(cy - r).toFixed(1)}` + + ` A${r},${r} 0 0 ${outerSweep} ${cx.toFixed(1)},${(cy + r).toFixed(1)}` + + ` A${rx.toFixed(1)},${r} 0 0 ${termSweep} ${cx.toFixed(1)},${(cy - r).toFixed(1)} Z`; + } + // ── Editor ──────────────────────────────────────────────────────── class PrismSunCardEditor extends P.PrismEditor { _fields(stack) { @@ -4832,7 +4881,14 @@ this._picker('Sun entity', c.entity, (v) => this._patch('entity', v), { domains: ['sun'] }), this._tf('Name (optional)', c.name, (v) => this._patch('name', v)), this._accentField(c.accent, (v) => this._patch('accent', v)), - this._hint('Defaults to sun.sun. Uses its next_rising / next_setting / elevation attributes.') + this._hint('Defaults to sun.sun. Uses its next_rising / next_setting / elevation attributes.'), + 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('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('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.') ); } } @@ -4848,7 +4904,7 @@ } setConfig(config) { - this._config = { entity: 'sun.sun', accent: 'amber', ...config }; + this._config = { entity: 'sun.sun', accent: 'amber', show_moon: true, ...config }; if (this._hass) this._render(); } @@ -4858,14 +4914,24 @@ getGridOptions() { return { rows: 3, columns: 6, min_rows: 3, min_columns: 4 }; } static getConfigElement() { return document.createElement('prism-sun-card-editor'); } - static getStubConfig() { return { entity: 'sun.sun', accent: 'amber' }; } + static getStubConfig(hass) { + const hasMoon = hass && hass.states && hass.states['sensor.moon']; + return { entity: 'sun.sun', accent: 'amber', ...(hasMoon ? { moon_entity: 'sensor.moon' } : {}) }; + } - _moreInfo() { + _moreInfo(id) { this.dispatchEvent(new CustomEvent('hass-more-info', { - detail: { entityId: this._config.entity }, bubbles: true, composed: true, + detail: { entityId: id || this._config.entity }, bubbles: true, composed: true, })); } + _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; + } + _render() { if (!this._config || !this._hass) return; const c = this._config; @@ -4880,8 +4946,49 @@ const elev = parseFloat(a.elevation); const isDay = st ? (st.state === 'above_horizon' || (!isNaN(elev) && elev > 0)) : false; - // Daylight progress: today's sunset is the next setting; approximate - // today's sunrise as the next rising minus a day (good enough for a viz). + // 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; + + const styles = ` + `; + + const inner = showMoon + ? this._moonView(moonSt, rise, c, name) + : this._sunView(a, st, rise, set, now, isDay, accent, name); + + this.shadowRoot.innerHTML = ` + ${styles} +
+ ${P.titleHead(c.title)} + ${inner} +
`; + + P.bindTap(this.shadowRoot.querySelector('.prism-card'), () => this._moreInfo(), () => this._moreInfo()); + } + + // Daytime sun-path arc (unchanged behaviour). + _sunView(a, st, rise, set, now, isDay, accent, name) { let f = 0, haveArc = false; if (isDay && !isNaN(rise) && !isNaN(set)) { const riseToday = rise - DAY, setToday = set; @@ -4895,7 +5002,6 @@ : remain ? `${nextIsSet ? 'Sets' : 'Rises'} in ${remain}` : (isDay ? 'Daytime' : 'Night'); - // Arc geometry (wide, short dome). const R = 70, CX = 100, CY = 80, N = 48; const px = (th) => CX + R * Math.cos(th); const py = (th) => CY - R * Math.sin(th); @@ -4915,7 +5021,6 @@ const sx = haveArc ? px(sunTh) : CX; const sy = haveArc ? py(sunTh) : CY + 6; - // Sun (day) or moon (night) marker. let rays = ''; for (let i = 0; i < 8; i++) { const ang = (i * 45 * Math.PI) / 180; @@ -4927,35 +5032,55 @@ ? `${rays}` : ``; - this.shadowRoot.innerHTML = ` - -
- ${P.titleHead(c.title)} - - - ${trav ? `` : ''} - - ${marker} - -
- ${fmtT(isDay ? rise - DAY : rise)} - ${P.esc(midText)} - ${fmtT(set)} -
+ return ` + + + ${trav ? `` : ''} + + ${marker} + +
+ ${fmtT(isDay ? rise - DAY : rise)} + ${P.esc(midText)} + ${fmtT(set)}
`; + } - P.bindTap(this.shadowRoot.querySelector('.prism-card'), () => this._moreInfo(), () => this._moreInfo()); + // Night moon view: phase glyph + phase name / illumination, next sunrise, + // and moonrise / moonset when their sensors are configured. + _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); + + // 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]] + .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 rsBits = []; + if (!isNaN(moonrise)) rsBits.push(`Moonrise ${fmtT(moonrise)}`); + if (!isNaN(moonset)) rsBits.push(`Moonset ${fmtT(moonset)}`); + + return ` + + ${stars} + + ${litPath} + + +
+ ${isNaN(illum) ? '—' : `${Math.round(illum)}%`} + ${P.esc(mi.label)} + ${fmtT(sunRise)} +
+ ${rsBits.length ? `
${rsBits.join('')}
` : ''}`; } } @@ -4963,7 +5088,7 @@ P.registerCard({ type: 'prism-sun-card', name: 'Prism Sun Card', - description: 'Flat sun-path arc: sunrise/sunset times, the sun riding its arc, and a live sets-in / rises-in countdown.', + description: 'Flat sun-path arc with sunrise/sunset + countdown; optionally flips to a moon-phase view after sunset.', }); })(); diff --git a/src/prism-shared.js b/src/prism-shared.js index 059efc5..1708362 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.16.5'; + const VERSION = '0.17.0'; // ── 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 1b0762a..5776d1b 100644 --- a/src/prism-sun-card.js +++ b/src/prism-sun-card.js @@ -1,9 +1,14 @@ /** * prism-sun-card * Flat sun-path tile: an arc of the sky from sunrise to sunset with the sun - * riding along it (a moon below the horizon at night), the traveled portion - * filled with the accent, sunrise/sunset times at each end, and a live - * "sets in / rises in" countdown. Reads the `sun.sun` entity. + * riding along it, the traveled portion filled with the accent, sunrise/sunset + * 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. * * type: custom:prism-sun-card */ @@ -19,6 +24,50 @@ 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). + const MOON = { + new_moon: { label: 'New Moon', k: 0.0 }, + waxing_crescent: { label: 'Waxing Crescent', k: 0.125 }, + first_quarter: { label: 'First Quarter', k: 0.25 }, + waxing_gibbous: { label: 'Waxing Gibbous', k: 0.375 }, + full_moon: { label: 'Full Moon', k: 0.5 }, + waning_gibbous: { label: 'Waning Gibbous', k: 0.625 }, + last_quarter: { label: 'Last Quarter', k: 0.75 }, + waning_crescent: { label: 'Waning Crescent', k: 0.875 }, + }; + 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). + function moonInfo(state) { + const s = String(state == null ? '' : state).toLowerCase().trim(); + if (MOON[s]) return MOON[s]; + const key = s.replace(/[\s-]+/g, '_'); + if (MOON[key]) return MOON[key]; + 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 }; + } + return null; + } + + // SVG path for the illuminated portion of a moon disk at phase fraction k. + function moonLitPath(cx, cy, r, k) { + const b = Math.cos(2 * Math.PI * k); // 1 (new) … -1 (full) + const rx = Math.abs(b) * r; // terminator ellipse x-radius + const waxing = k < 0.5; // northern hemisphere: lit on the right + const outerSweep = waxing ? 1 : 0; + const termSweep = waxing ? (b > 0 ? 0 : 1) : (b > 0 ? 1 : 0); + return `M${cx.toFixed(1)},${(cy - r).toFixed(1)}` + + ` A${r},${r} 0 0 ${outerSweep} ${cx.toFixed(1)},${(cy + r).toFixed(1)}` + + ` A${rx.toFixed(1)},${r} 0 0 ${termSweep} ${cx.toFixed(1)},${(cy - r).toFixed(1)} Z`; + } + // ── Editor ──────────────────────────────────────────────────────── class PrismSunCardEditor extends P.PrismEditor { _fields(stack) { @@ -28,7 +77,14 @@ this._picker('Sun entity', c.entity, (v) => this._patch('entity', v), { domains: ['sun'] }), this._tf('Name (optional)', c.name, (v) => this._patch('name', v)), this._accentField(c.accent, (v) => this._patch('accent', v)), - this._hint('Defaults to sun.sun. Uses its next_rising / next_setting / elevation attributes.') + this._hint('Defaults to sun.sun. Uses its next_rising / next_setting / elevation attributes.'), + 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('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('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.') ); } } @@ -44,7 +100,7 @@ } setConfig(config) { - this._config = { entity: 'sun.sun', accent: 'amber', ...config }; + this._config = { entity: 'sun.sun', accent: 'amber', show_moon: true, ...config }; if (this._hass) this._render(); } @@ -54,14 +110,24 @@ getGridOptions() { return { rows: 3, columns: 6, min_rows: 3, min_columns: 4 }; } static getConfigElement() { return document.createElement('prism-sun-card-editor'); } - static getStubConfig() { return { entity: 'sun.sun', accent: 'amber' }; } + static getStubConfig(hass) { + const hasMoon = hass && hass.states && hass.states['sensor.moon']; + return { entity: 'sun.sun', accent: 'amber', ...(hasMoon ? { moon_entity: 'sensor.moon' } : {}) }; + } - _moreInfo() { + _moreInfo(id) { this.dispatchEvent(new CustomEvent('hass-more-info', { - detail: { entityId: this._config.entity }, bubbles: true, composed: true, + detail: { entityId: id || this._config.entity }, bubbles: true, composed: true, })); } + _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; + } + _render() { if (!this._config || !this._hass) return; const c = this._config; @@ -76,8 +142,49 @@ const elev = parseFloat(a.elevation); const isDay = st ? (st.state === 'above_horizon' || (!isNaN(elev) && elev > 0)) : false; - // Daylight progress: today's sunset is the next setting; approximate - // today's sunrise as the next rising minus a day (good enough for a viz). + // 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; + + const styles = ` + `; + + const inner = showMoon + ? this._moonView(moonSt, rise, c, name) + : this._sunView(a, st, rise, set, now, isDay, accent, name); + + this.shadowRoot.innerHTML = ` + ${styles} +
+ ${P.titleHead(c.title)} + ${inner} +
`; + + P.bindTap(this.shadowRoot.querySelector('.prism-card'), () => this._moreInfo(), () => this._moreInfo()); + } + + // Daytime sun-path arc (unchanged behaviour). + _sunView(a, st, rise, set, now, isDay, accent, name) { let f = 0, haveArc = false; if (isDay && !isNaN(rise) && !isNaN(set)) { const riseToday = rise - DAY, setToday = set; @@ -91,7 +198,6 @@ : remain ? `${nextIsSet ? 'Sets' : 'Rises'} in ${remain}` : (isDay ? 'Daytime' : 'Night'); - // Arc geometry (wide, short dome). const R = 70, CX = 100, CY = 80, N = 48; const px = (th) => CX + R * Math.cos(th); const py = (th) => CY - R * Math.sin(th); @@ -111,7 +217,6 @@ const sx = haveArc ? px(sunTh) : CX; const sy = haveArc ? py(sunTh) : CY + 6; - // Sun (day) or moon (night) marker. let rays = ''; for (let i = 0; i < 8; i++) { const ang = (i * 45 * Math.PI) / 180; @@ -123,35 +228,55 @@ ? `${rays}` : ``; - this.shadowRoot.innerHTML = ` - -
- ${P.titleHead(c.title)} - - - ${trav ? `` : ''} - - ${marker} - -
- ${fmtT(isDay ? rise - DAY : rise)} - ${P.esc(midText)} - ${fmtT(set)} -
+ return ` + + + ${trav ? `` : ''} + + ${marker} + +
+ ${fmtT(isDay ? rise - DAY : rise)} + ${P.esc(midText)} + ${fmtT(set)}
`; + } - P.bindTap(this.shadowRoot.querySelector('.prism-card'), () => this._moreInfo(), () => this._moreInfo()); + // Night moon view: phase glyph + phase name / illumination, next sunrise, + // and moonrise / moonset when their sensors are configured. + _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); + + // 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]] + .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 rsBits = []; + if (!isNaN(moonrise)) rsBits.push(`Moonrise ${fmtT(moonrise)}`); + if (!isNaN(moonset)) rsBits.push(`Moonset ${fmtT(moonset)}`); + + return ` + + ${stars} + + ${litPath} + + +
+ ${isNaN(illum) ? '—' : `${Math.round(illum)}%`} + ${P.esc(mi.label)} + ${fmtT(sunRise)} +
+ ${rsBits.length ? `
${rsBits.join('')}
` : ''}`; } } @@ -159,6 +284,6 @@ P.registerCard({ type: 'prism-sun-card', name: 'Prism Sun Card', - description: 'Flat sun-path arc: sunrise/sunset times, the sun riding its arc, and a live sets-in / rises-in countdown.', + description: 'Flat sun-path arc with sunrise/sunset + countdown; optionally flips to a moon-phase view after sunset.', }); })(); diff --git a/test/preview.html b/test/preview.html index 7faf5f9..7c78930 100644 --- a/test/preview.html +++ b/test/preview.html @@ -107,6 +107,10 @@

Prism — Card Preview

'media_player.speaker': { state: 'playing', attributes: { friendly_name: 'Speaker', media_title: 'Midnight City', media_artist: 'M83', volume_level: 0.4, supported_features: 16437 } }, '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.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.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' } }, @@ -215,6 +219,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-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 7fe13a8..a9d61dd 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -63,6 +63,10 @@ const hass = { 'weather.x': { 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', uv_index: 3 } }, '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.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.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: '%' } }, @@ -160,6 +164,8 @@ 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: false }], ['prism-uv-card', { entity: 'sensor.uv_index', title: 'UV' }], ['prism-uv-card', { entity: 'weather.x', attribute: 'uv_index', show_advice: false }], ['prism-rain-card', { title: 'Rain', 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' }],