mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
The day/night sky catches up instead of crawling after the sun
Owner, 2026-08-04: «цвет фона не меняется сам с течением времени суток, только
после обновления страницы».
WHAT IS NOT THE BUG. The model layer was already live: `_stageBg` and the
`planDim` filter are read straight out of `hass.states['sun.sun']` on every
render, `hass` is a plain reactive property, and a bare `card.hass = {...}` in
the demo rig does move the style attribute — smoke_sun.mjs has asserted exactly
that since v1.56.0 and it has always passed.
WHAT IS. The sky is DELIVERED by a 45 s CSS transition, and a CSS transition
only advances while the element is being painted. Every second of a background
tab, another dashboard view, an editor session or a sleeping wall tablet is a
second the sun keeps moving and the sky does not; when the card comes back, the
transition restarts from the stale colour and crawls, 45 s at a time, toward a
target that has meanwhile moved again. A page reload, by contrast, paints the
right colour outright — a freshly mounted element has nothing to transition
FROM. That is the owner's sentence, word for word.
THE FIX. Measure the gap and decide. HA refreshes `sun.sun` every ~4 minutes by
day (verified on the home instance: 08:58:56, 09:02:56, 09:06:56, …), i.e. ≤1°
of elevation per update, so anything from SKY_SNAP_DEG = 3° up can only mean
"we were not watching". Such a step is painted with `transition: none` for a
single frame (`.stage.daynight.skysnap`, released on the next
requestAnimationFrame, so the very next change glides again); everything
smaller keeps the 45 s breathing untouched. `visibilitychange → visible` clears
the marker outright, so a tab that comes back is right immediately.
The elevation the sky is computed from is now rounded to 0.1° (`skyElevation`,
shared by the stage background and the plan dimming) — invisible across a 45 s
glide and it keeps lit from re-committing the style attribute on every hass
tick. The ray GEOMETRY memo is deliberately untouched and keeps its own,
coarser key: the sky is cheap, polygon clipping is not.
Tests: unit — skyNeedsSnap (null/NaN, a real 4-minute step glides, 3° in either
direction jumps), skyElevation. Smoke — demo/smoke_sun_live_bg.mjs, which
asserts the COMPUTED background of the stage (not the style attribute) after a
plain `hass` assignment with no reload and no requestUpdate, plus planDim and
the 3° ray threshold both ways. It fails on the previous tip with
dayComputedWhite, nightComputedDark, backToDayComputed, smallStepMovesSky and
returnFromHiddenSnaps, and it also pins that a REAL sun step still glides
rather than jumps.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,71 @@
|
||||
// Owner 2026-08-04: «цвет фона не меняется сам с течением времени суток, только
|
||||
// после обновления страницы». The day/night sky must follow sun.sun on a plain
|
||||
// hass tick — no reload, no requestUpdate() from the outside — and the COMPUTED
|
||||
// background of the stage (what the eye sees, not the style attribute) must
|
||||
// actually arrive at the sky colour (docs/SUN.md).
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const cfg = c._serverCfg;
|
||||
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'daynight', sun_rays: true };
|
||||
cfg.spaces.find((s) => s.id === 'f1').openings = [
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||||
];
|
||||
c._cfgEpoch++;
|
||||
// NO requestUpdate anywhere below: production only ever assigns `hass`.
|
||||
const setSun = async (az, el) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: el > 0 ? 'above_horizon' : 'below_horizon',
|
||||
attributes: { azimuth: az, elevation: el },
|
||||
} } };
|
||||
await c.updateComplete;
|
||||
// one paint so the sky lands on screen, not only in the attribute
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
};
|
||||
const stage = () => sr().querySelector('.stage');
|
||||
const comp = () => getComputedStyle(stage()).backgroundColor;
|
||||
const dim = () => (sr().querySelector('.zoomwrap').getAttribute('style') || '');
|
||||
const polys = () => sr().querySelectorAll('.sunlayer polygon').length;
|
||||
const hexToRgb = (h) => 'rgb(' + [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)).join(', ') + ')';
|
||||
|
||||
await setSun(180, 40);
|
||||
out.dayComputedWhite = comp() === 'rgb(255, 255, 255)';
|
||||
out.dayNoDim = dim().includes('brightness(1.000)');
|
||||
out.dayRays = polys() > 0;
|
||||
|
||||
// the whole point: a NEW hass, nothing else. No reload, no requestUpdate.
|
||||
await setSun(180, -10);
|
||||
out.nightComputedDark = comp() === 'rgb(10, 16, 25)';
|
||||
out.nightDimmed = dim().includes('brightness(0.900');
|
||||
out.nightNoRays = polys() === 0;
|
||||
|
||||
// ...and back, again on a plain tick
|
||||
await setSun(180, 40);
|
||||
out.backToDayComputed = comp() === 'rgb(255, 255, 255)';
|
||||
out.backToDayRays = polys() > 0;
|
||||
|
||||
// a small step still moves the sky (0.1° granularity of the model)...
|
||||
await setSun(180, 20);
|
||||
const twenty = comp();
|
||||
await setSun(180, 12);
|
||||
out.smallStepMovesSky = comp() !== twenty;
|
||||
|
||||
// ...but a step the size of a REAL sun update (~1° per 4 minutes) must still
|
||||
// GLIDE: the catch-up jump is for a card that was not watching, never for the
|
||||
// day/night breathing itself (docs/SUN.md).
|
||||
await setSun(180, 12.6);
|
||||
const target = stage().getAttribute('style').split('background:')[1];
|
||||
out.realStepNoSnapClass = !sr().querySelector('.stage.skysnap');
|
||||
out.realStepStillGlides = comp() !== hexToRgb(target); // 45 s transition, not a jump
|
||||
|
||||
// the tab came back from the background: the sky catches up at once
|
||||
await setSun(180, 25); // 12° of sun happened while we were not painting
|
||||
out.returnFromHiddenSnaps = comp() === hexToRgb(stage().getAttribute('style').split('background:')[1]);
|
||||
|
||||
return out;
|
||||
});
|
||||
await finish(browser, checkAll(res));
|
||||
+234
-226
File diff suppressed because one or more lines are too long
Vendored
+234
-226
File diff suppressed because one or more lines are too long
+25
@@ -67,6 +67,28 @@ only — no entities are created, no services are called.
|
||||
daytime room fills stay readable. Transitions are a CSS
|
||||
background/filter transition tens of seconds long;
|
||||
`prefers-reduced-motion` gets the current colors statically.
|
||||
- **Glide, but never lag behind reality** (owner 2026-08-04: «цвет фона
|
||||
не меняется сам с течением времени суток, только после
|
||||
обновления страницы»). The sky colour and the plan dimming are
|
||||
delivered by a 45 s CSS transition, and a CSS transition only advances
|
||||
while the card is being PAINTED. A card that was not painting — a
|
||||
background tab, another dashboard view, a sleeping wall tablet, an
|
||||
editor session — comes back holding a stale sky and then crawls toward
|
||||
the truth 45 s at a time; a page reload, by contrast, paints the right
|
||||
colour outright, because a freshly mounted element has nothing to
|
||||
transition FROM. So the card measures the gap: HA refreshes `sun.sun`
|
||||
every ~4 minutes by day, i.e. ≤1° per update, and anything from
|
||||
`SKY_SNAP_DEG` = 3° up therefore means "we were not watching". Such a
|
||||
step is applied with `transition: none` for a single frame
|
||||
(`.stage.daynight.skysnap`, released on the next
|
||||
`requestAnimationFrame`); everything smaller keeps the 45 s breathing.
|
||||
`visibilitychange → visible` arms the catch-up outright.
|
||||
- The elevation the sky is computed from is rounded to 0.1°
|
||||
(`skyElevation()`) — finer than the eye can tell across a 45 s glide,
|
||||
and it keeps `dayPhase` (and the style attribute lit has to commit)
|
||||
from churning on every `hass` tick. The wedge GEOMETRY keeps its own,
|
||||
coarser memo: the two have deliberately different granularity — the
|
||||
sky is cheap, the polygon clipping is not.
|
||||
- The UI is a two-option selector; the color picker shows only for
|
||||
`'static'`.
|
||||
- Backend validation: `In(['static', 'daynight'])` at both levels.
|
||||
@@ -231,5 +253,8 @@ Backend validation: string or null.
|
||||
- `demo/smoke_sun_soft.mjs` — the −30 % reach and the "always
|
||||
dissolves" contract (the gradient spans the wedge and dies at 85 %,
|
||||
the feather filter, the room clip).
|
||||
- `demo/smoke_sun_live_bg.mjs` — the sky follows `sun.sun` on a plain
|
||||
`hass` tick with no reload, asserted on the COMPUTED background of the
|
||||
stage; small steps still glide, big ones catch up at once.
|
||||
- `demo/shot_sun_short.mjs` — before/after stills at a low and a high
|
||||
sun.
|
||||
|
||||
+48
-4
@@ -32,7 +32,7 @@ import {
|
||||
import {
|
||||
computeSunRays, dayPhase, northDegOf, bgModeOf, sunRaysOn, weatherEntityOf,
|
||||
sunStateOf, cloudFactor, rayPeakAlpha, raysVisible, rayColor, RAY_FADE_MS, type SunRay,
|
||||
rayStops, raySoftness,
|
||||
rayStops, raySoftness, skyElevation, skyNeedsSnap,
|
||||
} from './sun';
|
||||
import { ContentSigner } from './signing';
|
||||
import { mdiHomeCityOutline } from '@mdi/js';
|
||||
@@ -390,6 +390,11 @@ class HouseplanCard extends LitElement {
|
||||
} | null = null;
|
||||
/** Wedge memo: recomputed only when (azimuth, elevation, north, cfg rev) change (docs/SUN.md). */
|
||||
private _sunRaysCache: { key: string; rays: SunRay[] } | null = null;
|
||||
/** Sun elevation (0.1°) the day/night sky is currently PAINTED with, and
|
||||
* whether the next paint must jump to it instead of gliding (docs/SUN.md). */
|
||||
private _skyElev: number | null = null;
|
||||
private _skySnap = false;
|
||||
private _skySnapRaf = 0;
|
||||
private _compassDrag = false;
|
||||
private _importDialog: { floors: (FloorInfo & { checked: boolean })[] } | null = null;
|
||||
private _importQueue: string[] = []; // floor titles still to create
|
||||
@@ -505,6 +510,10 @@ class HouseplanCard extends LitElement {
|
||||
private _vacVisHandler = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
this._vacJumpOnce = true;
|
||||
// A hidden tab paints nothing, so the 45 s sky transition stood still
|
||||
// while the sun kept moving: come back on the RIGHT colour, then breathe
|
||||
// again (docs/SUN.md, owner 2026-08-04).
|
||||
this._skyElev = null;
|
||||
this.requestUpdate();
|
||||
}
|
||||
};
|
||||
@@ -628,6 +637,7 @@ class HouseplanCard extends LitElement {
|
||||
public disconnectedCallback(): void {
|
||||
document.removeEventListener('visibilitychange', this._vacVisHandler);
|
||||
if (this._vacRaf) { cancelAnimationFrame(this._vacRaf); this._vacRaf = 0; }
|
||||
if (this._skySnapRaf) { cancelAnimationFrame(this._skySnapRaf); this._skySnapRaf = 0; }
|
||||
for (const rt of this._senseRt.values()) clearTimeout(rt.timer); // pending flash-window repaints
|
||||
window.removeEventListener('keydown', this._keyHandler);
|
||||
clearInterval(this._cycleTimer);
|
||||
@@ -1030,6 +1040,7 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
protected willUpdate(changed: PropertyValues): void {
|
||||
if (changed?.has?.('hass')) { this._vacTick(); this._senseTick(); }
|
||||
this._skyPlan();
|
||||
if (changed.has('hass') && this.hass) {
|
||||
this._hookConnection();
|
||||
if (!this._loadOk && !this._loading && this._loadTries < 8) {
|
||||
@@ -1040,6 +1051,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
this._skyRelease();
|
||||
const stage = this._stageEl;
|
||||
if (stage && !this._roViewport) {
|
||||
this._roViewport = new ResizeObserver(() => this._refitView());
|
||||
@@ -4969,6 +4981,38 @@ class HouseplanCard extends LitElement {
|
||||
private _sunOut = false;
|
||||
private _sunOutTimer = 0;
|
||||
|
||||
/**
|
||||
* Day/night sky bookkeeping, once per update (docs/SUN.md).
|
||||
*
|
||||
* The sky colour and the plan dimming are delivered by a 45 s CSS transition
|
||||
* — and a transition only advances while the card is being PAINTED. Whenever
|
||||
* it was not (a background tab, another dashboard view, an editor session, a
|
||||
* fresh mount), the sun moved on without it, and the transition then crawls
|
||||
* toward the truth instead of showing it: the owner's «фон не меняется сам,
|
||||
* только после обновления страницы» (2026-08-04). So: glide while we are
|
||||
* keeping up (the sun moves ≲1° between two `sun.sun` updates), JUMP once
|
||||
* when the gap says we were not watching.
|
||||
*/
|
||||
private _skyPlan(): void {
|
||||
const sun = !this._editing && this._effBgMode() === 'daynight' ? this._sunNow() : null;
|
||||
if (!sun) { this._skyElev = null; this._skySnap = false; return; }
|
||||
const e = skyElevation(sun.elevation);
|
||||
if (skyNeedsSnap(this._skyElev, e)) this._skySnap = true;
|
||||
this._skyElev = e;
|
||||
}
|
||||
|
||||
/** Hand the 45 s transition back once the jumped-to colour is on screen. */
|
||||
private _skyRelease(): void {
|
||||
if (!this._skySnap || this._skySnapRaf) return;
|
||||
this._skySnapRaf = requestAnimationFrame(() => {
|
||||
this._skySnapRaf = requestAnimationFrame(() => {
|
||||
this._skySnapRaf = 0;
|
||||
this._skySnap = false;
|
||||
this.requestUpdate();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop the layer at once: used by every gate that is NOT the 3° threshold. */
|
||||
private _sunFadeReset(): void {
|
||||
if (this._sunOutTimer) { clearTimeout(this._sunOutTimer); this._sunOutTimer = 0; }
|
||||
@@ -5025,7 +5069,7 @@ class HouseplanCard extends LitElement {
|
||||
private _stageBg(disp: SpaceDisplay): string {
|
||||
if (this._effBgMode() === 'daynight') {
|
||||
const sun = this._sunNow();
|
||||
if (sun) return dayPhase(sun.elevation).bg;
|
||||
if (sun) return dayPhase(skyElevation(sun.elevation)).bg;
|
||||
}
|
||||
const gd = this._settingsDialog;
|
||||
const sd = this._spaceDialog;
|
||||
@@ -5553,7 +5597,7 @@ class HouseplanCard extends LitElement {
|
||||
const stageBg = this._editing ? '' : this._stageBg(disp);
|
||||
// day/night breathing: armed only with a compass AND sun.sun (docs/SUN.md)
|
||||
const dayNight = !this._editing && this._effBgMode() === 'daynight' ? this._sunNow() : null;
|
||||
const planDim = dayNight ? dayPhase(dayNight.elevation).planDim : 0;
|
||||
const planDim = dayNight ? dayPhase(skyElevation(dayNight.elevation)).planDim : 0;
|
||||
// opening rulers: the drag of an existing one OR the placement preview
|
||||
const opMeasure = this._opMeasureView;
|
||||
|
||||
@@ -5631,7 +5675,7 @@ class HouseplanCard extends LitElement {
|
||||
${this._markup ? this._renderMarkupBar() : this._mode === 'devices' ? this._renderDevicesBar() : this._mode === 'decor' ? this._renderDecorBar() : nothing}
|
||||
</div>
|
||||
|
||||
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'openwall' && this._openWallHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}${dayNight ? ' daynight' : ''}${this._booting ? ' hpboot' : ''}${this._bootSoft ? ' hpsettle' : ''}"
|
||||
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'openwall' && this._openWallHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}${dayNight ? ' daynight' : ''}${dayNight && this._skySnap ? ' skysnap' : ''}${this._booting ? ' hpboot' : ''}${this._bootSoft ? ' hpsettle' : ''}"
|
||||
style="height:${this._kiosk ? '100dvh' : `calc(100dvh - ${this._hdrH}px)`}${stageBg ? `;background:${stageBg}` : ''}"
|
||||
@click=${(e: MouseEvent) => this._markupClick(e)}
|
||||
@wheel=${(e: WheelEvent) => this._onWheel(e)}
|
||||
|
||||
@@ -184,6 +184,14 @@ export const cardStyles = css`
|
||||
.stage.daynight.hpsettle {
|
||||
transition: height 0.25s ease, background-color 45s linear;
|
||||
}
|
||||
/* Catch-up frame (docs/SUN.md): the sky is out of date because the card
|
||||
was not painting — show the truth at once, the glide comes back on the
|
||||
very next frame. */
|
||||
.stage.daynight.skysnap,
|
||||
.stage.daynight.skysnap .zoomwrap,
|
||||
.stage.daynight.skysnap.hpsettle {
|
||||
transition: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.stage.daynight,
|
||||
.stage.daynight .zoomwrap,
|
||||
|
||||
+33
@@ -310,6 +310,39 @@ export function raySoftness(len: number): number {
|
||||
return Math.max(3, Math.min(18, len * 0.07));
|
||||
}
|
||||
|
||||
/**
|
||||
* Day/night sky: how far the painted sky may drift from the real sun before
|
||||
* the card stops gliding and simply JUMPS to the right colour.
|
||||
*
|
||||
* The stage colour is delivered by a 45 s CSS transition, and a transition only
|
||||
* advances while the card is actually painting. A card that was not painting —
|
||||
* a background tab, another dashboard view, a sleeping wall tablet — comes back
|
||||
* with a stale sky and then crawls toward the truth 45 s at a time, which is
|
||||
* exactly the owner's 2026-08-04 report («цвет фона не меняется сам с течением
|
||||
* времени суток, только после обновления страницы»: a reload paints the right
|
||||
* colour outright, because a freshly mounted element has nothing to transition
|
||||
* FROM). The sun never moves more than ~1° between two `sun.sun` updates (HA
|
||||
* refreshes the position every 4 minutes by day), so a gap this big can only
|
||||
* mean "we were not watching" — catch up at once, then breathe again.
|
||||
*/
|
||||
export const SKY_SNAP_DEG = 3;
|
||||
|
||||
/** Should the sky jump rather than glide from `prev`° to `next`°? */
|
||||
export function skyNeedsSnap(prev: number | null, next: number): boolean {
|
||||
return prev === null || !Number.isFinite(prev)
|
||||
|| Math.abs(next - prev) >= SKY_SNAP_DEG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sky granularity: the elevation the background is computed from, rounded to
|
||||
* 0.1°. Finer than the eye can tell on a 45 s glide, and it keeps `dayPhase`
|
||||
* (and therefore the style attribute lit has to commit) from churning on every
|
||||
* hass tick while the ray GEOMETRY keeps its own, coarser memo.
|
||||
*/
|
||||
export function skyElevation(elevation: number): number {
|
||||
return Math.round((Number(elevation) || 0) * 10) / 10;
|
||||
}
|
||||
|
||||
// ---------------- cloud cover ----------------
|
||||
|
||||
/** weather.* state → wedge opacity multiplier (docs/SUN.md table). */
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
rayAlpha, rayColor, cloudFactor, RAY_MAX_ALPHA,
|
||||
raysVisible, rayPeakAlpha, RAY_ELEVATION_MIN, RAY_FADE_MS,
|
||||
RAY_LENGTH_K, RAY_FADE_END, rayStops, raySoftness,
|
||||
SKY_SNAP_DEG, skyNeedsSnap, skyElevation,
|
||||
northDegOf, bgModeOf, sunRaysOn, weatherEntityOf, sunStateOf,
|
||||
} from '../test-build/sun.js';
|
||||
|
||||
@@ -145,6 +146,20 @@ test('raySoftness: a feather proportional to the shaft, clamped both ends', () =
|
||||
assert.ok(raySoftness(200) > raySoftness(100));
|
||||
});
|
||||
|
||||
test('skyNeedsSnap / skyElevation: glide with the sun, jump when we were away', () => {
|
||||
assert.equal(SKY_SNAP_DEG, 3);
|
||||
assert.equal(skyNeedsSnap(null, 12), true); // nothing painted yet
|
||||
assert.equal(skyNeedsSnap(NaN, 12), true);
|
||||
assert.equal(skyNeedsSnap(12, 12), false);
|
||||
assert.equal(skyNeedsSnap(12, 13), false); // a real 4-minute sun step
|
||||
assert.equal(skyNeedsSnap(12, 14.9), false);
|
||||
assert.equal(skyNeedsSnap(12, 15), true); // ~12 minutes unwatched
|
||||
assert.equal(skyNeedsSnap(12, 9), true); // and in both directions
|
||||
assert.equal(skyElevation(12.3456), 12.3);
|
||||
assert.equal(skyElevation(-0.04), -0);
|
||||
assert.equal(skyElevation('nonsense'), 0);
|
||||
});
|
||||
|
||||
test('rayQuad + clipToRoom: the wedge is cut by the room outline', () => {
|
||||
const quad = rayQuad([100, 270], [100, 330], [1, 0], 1000); // way past the wall
|
||||
const clipped = clipToRoom(quad, ROOMS[0].poly);
|
||||
|
||||
Reference in New Issue
Block a user