Sun rays: brighter, and a hard 3 degree threshold with a 2 s fade

Owner 2026-08-03: «лучи поярче, иногда плохо видны. Убрать плавное
затухание — появляться и исчезать анимацией в 2 секунды при переходе
через 3 градуса над горизонтом».

RAY_MAX_ALPHA 0.18 -> 0.30: checked against both hard cases, a daylight
sun on white paper and a low sun over the dark glow canvas
(demo/shot_sun_bright.mjs writes the pair).

The gradual ramp-in over the first ~2 degrees is gone. rayAlpha() is now a
threshold: 0 below RAY_ELEVATION_MIN (3), rayPeakAlpha(cloud) at or above
it — cloud cover stays the only multiplier. Crossing it animates the
LAYER, never the geometry: <g class='sunlayer'> fades in/out over exactly
RAY_FADE_MS = 2 s (hp-sunfade-in / hp-sunfade-out), and the card keeps the
group mounted with .out for those two seconds so the dissolve can play at
all. prefers-reduced-motion skips it. Every other reason to drop the
wedges — editor, feature off, night, rain — stays instant.

Units: rayAlpha rewritten (ramp tests dropped), raysVisible/rayPeakAlpha/
RAY_MAX_ALPHA covered. Smoke: smoke_sun gains a threshold section (8 of
its checks fail on the previous build). docs/SUN.md + TESTING.md updated.
This commit is contained in:
houseplan dev
2026-08-03 22:25:03 +03:00
parent bb4d4e1f6e
commit 79142d9334
11 changed files with 399 additions and 82 deletions
+42 -6
View File
@@ -31,7 +31,7 @@ import {
} from './resize';
import {
computeSunRays, dayPhase, northDegOf, bgModeOf, sunRaysOn, weatherEntityOf,
sunStateOf, cloudFactor, rayAlpha, rayColor, type SunRay,
sunStateOf, cloudFactor, rayPeakAlpha, raysVisible, rayColor, RAY_FADE_MS, type SunRay,
} from './sun';
import { ContentSigner } from './signing';
import { mdiHomeCityOutline } from '@mdi/js';
@@ -4655,14 +4655,36 @@ class HouseplanCard extends LitElement {
*/
private _renderSunRays(space: SpaceModel): TemplateResult {
const empty = svg`` as unknown as TemplateResult;
if (this._editing || !this._effSunRays()) return empty;
// HARD gates — the feature is simply not on: leaving an editor, a space
// with rays off, night, rain. Those never fade, they just are not there.
if (this._editing || !this._effSunRays()) { this._sunFadeReset(); return empty; }
const north = this._effNorth();
const sun = north !== null ? sunStateOf(this.hass) : null;
if (!sun || sun.elevation <= 0) return empty;
if (!sun || sun.elevation <= 0) { this._sunFadeReset(); return empty; }
const weather = weatherEntityOf(this._sunGlobal());
const cloud = cloudFactor(weather ? this.hass?.states?.[weather]?.state : null);
const alpha = rayAlpha(sun.elevation, cloud);
if (alpha <= 0) return empty;
const alpha = rayPeakAlpha(cloud);
if (alpha <= 0) { this._sunFadeReset(); return empty; }
// The ONE thing that fades (owner 2026-08-03): the 3° threshold. Above it
// the layer is there at full strength, below it gone — with a 2 s CSS
// fade either way. The keep-alive below is what lets the fade-OUT play at
// all: without it the layer would leave the DOM in the same frame.
if (raysVisible(sun.elevation)) {
if (this._sunOutTimer) { clearTimeout(this._sunOutTimer); this._sunOutTimer = 0; }
this._sunOut = false;
this._sunShown = true;
} else {
if (!this._sunShown) return empty; // never lit: nothing to dissolve
if (!this._sunOut) {
this._sunOut = true;
this._sunOutTimer = window.setTimeout(() => {
this._sunOutTimer = 0;
this._sunShown = false;
this._sunOut = false;
this.requestUpdate();
}, RAY_FADE_MS);
}
}
// DEV-B701-01: the geometry signal must be _cfgEpoch, not _cfgRev.
// Every local mutation ends in _saveConfig(), which bumps the epoch
// SYNCHRONOUSLY; _cfgRev only moves after the debounced WS write is
@@ -4692,12 +4714,26 @@ class HouseplanCard extends LitElement {
</linearGradient>`;
})}
</defs>
<g class="sunlayer">
<g class="sunlayer ${this._sunOut ? 'out' : ''}">
${rays.map((r, i) => r.polys.map((p) => svg`<polygon
points="${p.map((q) => q[0] + ',' + q[1]).join(' ')}" fill="url(#hp-sun-${i})"></polygon>`))}
</g>` as unknown as TemplateResult;
}
/** Sun-ray layer keep-alive: `shown` = in the DOM, `out` = playing the
* 2 s dissolve before it leaves (plain fields — the timer requests the
* update, render just reads them). */
private _sunShown = false;
private _sunOut = false;
private _sunOutTimer = 0;
/** 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; }
this._sunShown = false;
this._sunOut = false;
}
/** One drag/click sample on the compass dial → dialog north_deg. */
private _compassPoint(ev: PointerEvent): void {
const el = ev.currentTarget as SVGSVGElement;
+24
View File
@@ -1111,6 +1111,30 @@ export const cardStyles = css`
sense rings, the vacuum puck's 2.2s period, and a moderate opacity so
two curtains travelling at once never turn into a strobe. The plate
itself stays neutral — yellow means «включено», nothing else. */
/* Sun wedges (docs/SUN.md). The layer is present ONLY above the 3°
threshold; crossing it fades the whole layer in or out over EXACTLY
2 s (owner 2026-08-03 — RAY_FADE_MS in src/sun.ts must match). The
geometry is untouched: this is a plain opacity animation on the group,
so overlapping wedges keep their own blending while it plays. */
.sunlayer {
animation: hp-sunfade-in 2s linear both;
}
.sunlayer.out {
animation: hp-sunfade-out 2s linear both;
}
@keyframes hp-sunfade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes hp-sunfade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
/* no fade at all: the rays are simply there or simply gone */
.sunlayer, .sunlayer.out { animation: none; }
.sunlayer.out { opacity: 0; }
}
.dev.covermove::after {
content: '';
position: absolute;
+33 -5
View File
@@ -220,13 +220,41 @@ export function computeSunRays(
// ---------------- wedge dressing ----------------
/** Peak wedge opacity; two overlapping wedges stay readable (docs/SUN.md). */
export const RAY_MAX_ALPHA = 0.18;
/**
* Peak wedge opacity (owner 2026-08-03: «лучи поярче, иногда плохо видны» —
* raised from 0.18). Two overlapping wedges still stay under a readable
* ceiling on white paper AND on the dark glow canvas (docs/SUN.md).
*/
export const RAY_MAX_ALPHA = 0.3;
/** Wedge opacity: ramps in over the first ~2° so sunrise never pops. */
/**
* The elevation threshold, degrees. Below it there are NO rays at all, above
* it they are at FULL strength — the owner's 2026-08-03 contract replacing
* the old gradual ramp-in. The switch itself is not instant: the card fades
* the whole layer in/out over RAY_FADE_MS (CSS, not geometry).
*/
export const RAY_ELEVATION_MIN = 3;
/** Duration of that fade, ms — «ровно 2 секунды» (mirrored in styles.ts). */
export const RAY_FADE_MS = 2000;
/** Are the wedges present at this elevation at all? (The hard 3° threshold.) */
export function raysVisible(elevation: number): boolean {
return Number(elevation) >= RAY_ELEVATION_MIN;
}
/**
* Full-strength wedge opacity for the current cloud cover — elevation plays
* no part. The card feeds this into the gradient and lets CSS fade the layer,
* so a wedge dissolving at the threshold keeps its colour while it goes.
*/
export function rayPeakAlpha(cloud = 1): number {
return RAY_MAX_ALPHA * clamp01(cloud);
}
/** Wedge opacity: nothing below the threshold, full strength above it. */
export function rayAlpha(elevation: number, cloud = 1): number {
if (!(elevation > 0)) return 0;
return RAY_MAX_ALPHA * Math.min(1, elevation / 2) * clamp01(cloud);
return raysVisible(elevation) ? rayPeakAlpha(cloud) : 0;
}
/** Wedge color: warm orange at the horizon → neutral daylight. */