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:
Matysh
2026-08-04 09:49:30 +03:00
parent 22b588e116
commit 285d569102
9 changed files with 902 additions and 682 deletions
+48 -4
View File
@@ -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)}
+8
View File
@@ -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
View File
@@ -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). */