mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
Проверка (CI) / Классификация изменённых файлов (push) Successful in 3m7s
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Failing after 6m52s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / HACS: валидация репозитория (push) Failing after 29s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 57s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 16s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 1m25s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Failing after 1m32s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 5m6s
Проверка (CI) / Доказательство выполненных проверок (push) Failing after 18s
Issue: #649 User-Visible: no
170 lines
9.2 KiB
TypeScript
170 lines
9.2 KiB
TypeScript
/**
|
||
* #649 п.1 (docs/ISOMETRIC.md, Stage 6): device markers and opening locks as
|
||
* raised tiles in the 2.5D View — rounded body, a solid edge below it, a soft
|
||
* floor shadow in one layer under every marker.
|
||
*
|
||
* Every number is the designer lab (sketch 07, `lab.js`/`lab.css`) converted
|
||
* from its 80-unit base disc into D, the base core diameter of the space
|
||
* (`--device-base-size`) already scaled by ICON_SCALE. The edge colours are the
|
||
* lab's CSS filters (`brightness(.7) saturate(.85)`, light floor
|
||
* `brightness(.82) saturate(.8)`) evaluated here once, so the stylesheet and
|
||
* the tests read one source.
|
||
*
|
||
* Flat never reaches this module: every selector is scoped to
|
||
* `.stage.projection-iso.mode-view`.
|
||
*/
|
||
import { html, nothing, type TemplateResult } from 'lit';
|
||
import { colorLuma, parseHexColor, type Rgb } from './iso-materials';
|
||
|
||
export const ISO_ICON_SCALE = 1.12;
|
||
/** In D (base core diameter × ICON_SCALE); lab units / 80. */
|
||
export const ISO_TILE = Object.freeze({
|
||
depth: 8 / 80,
|
||
radius: 22 / 80,
|
||
radiusOfHeight: 0.3,
|
||
lift: 6 / 80,
|
||
badgeGap: 6 / 80,
|
||
shadowInset: 3 / 80,
|
||
frameWidth: 5 / 80,
|
||
frameOutset: 6 / 80,
|
||
});
|
||
|
||
export interface IsoTileShadow {
|
||
dx: number;
|
||
dy: number;
|
||
/** Gaussian σ in D (the lab's feGaussianBlur stdDeviation). */
|
||
sigma: number;
|
||
opacityWhite: number;
|
||
opacityTinted: number;
|
||
}
|
||
|
||
/**
|
||
* Computed by `getComputedStyle` from the lab in all four theme × floor
|
||
* combinations (SPEC-REVIEW-649-r1 M1): the dark-theme opacity rule is last in
|
||
* lab.css and overrides the floor opacities, not offset or blur.
|
||
*/
|
||
export function isoTileShadow(theme: 'light' | 'dark', lightFloor: boolean): IsoTileShadow {
|
||
const geometry = lightFloor
|
||
? { dx: 8 / 80, dy: 34 / 80, sigma: 11 / 80 }
|
||
: { dx: 8 / 80, dy: 30 / 80, sigma: 22 / 80 };
|
||
if (theme === 'dark') return { ...geometry, opacityWhite: 0.4, opacityTinted: 0.4 };
|
||
return lightFloor
|
||
? { ...geometry, opacityWhite: 0.3, opacityTinted: 0.42 }
|
||
: { ...geometry, opacityWhite: 0.34, opacityTinted: 0.5 };
|
||
}
|
||
|
||
/** CSS `saturate(s)` then `brightness(b)` (both linear; order-free before clamping). */
|
||
function filtered(rgb: Rgb, brightness: number, saturation: number): Rgb {
|
||
const [r, g, b] = rgb;
|
||
const s = saturation;
|
||
const R = (0.213 + 0.787 * s) * r + (0.715 - 0.715 * s) * g + (0.072 - 0.072 * s) * b;
|
||
const G = (0.213 - 0.213 * s) * r + (0.715 + 0.285 * s) * g + (0.072 - 0.072 * s) * b;
|
||
const B = (0.213 - 0.213 * s) * r + (0.715 - 0.715 * s) * g + (0.072 + 0.928 * s) * b;
|
||
return [R * brightness, G * brightness, B * brightness];
|
||
}
|
||
|
||
// + 1e-9: the filter matrices sum to 1, so float dust must not turn 178.5 into 178.
|
||
const toHex = (rgb: Rgb): string => `#${rgb.map((c) => Math.round(Math.min(255, Math.max(0, c)) + 1e-9)
|
||
.toString(16).padStart(2, '0')).join('')}`;
|
||
|
||
export const ISO_DARK_EDGE_LIGHT_THEME = '#5b5e5a';
|
||
export const ISO_DARK_EDGE_DARK_THEME = '#4a4a4a';
|
||
|
||
/** Edge (tile thickness) colour of a body colour (ТЗ #649 п.1 «Торец»). */
|
||
export function isoEdgeColor(body: string, theme: 'light' | 'dark', lightFloor: boolean): string {
|
||
const rgb = parseHexColor(body) ?? [255, 255, 255];
|
||
const dark = colorLuma(rgb) * 255 < 70;
|
||
if (dark) return theme === 'dark' ? ISO_DARK_EDGE_DARK_THEME : ISO_DARK_EDGE_LIGHT_THEME;
|
||
if (theme === 'dark' && colorLuma(rgb) > 0.98) return ISO_DARK_EDGE_DARK_THEME;
|
||
return toHex(lightFloor ? filtered(rgb, 0.82, 0.8) : filtered(rgb, 0.7, 0.85));
|
||
}
|
||
|
||
/** Body colours of the marker states (devices.styles.ts); the edge is derived from them. */
|
||
export const ISO_STATE_BODIES = Object.freeze({
|
||
on: '#F0A00C',
|
||
open: '#ff9f43',
|
||
'lock-locked': '#66D17A',
|
||
'lock-unlocked': '#F0410C',
|
||
unavail: '#B5BAC1',
|
||
alarm: '#F0410C',
|
||
} as const);
|
||
|
||
const D = 'var(--iso-d)';
|
||
const u = (k: number): string => `calc(${D} * ${Number(k.toFixed(6))})`;
|
||
const S = '.stage.projection-iso.mode-view';
|
||
|
||
/** The state table: body, glyph and edge per class, theme and floor. Order = Flat precedence. */
|
||
export function isoTileStateCss(): string {
|
||
const rules: string[] = [];
|
||
const edge = (body: string, theme: 'light' | 'dark', floor: boolean) => isoEdgeColor(body, theme, floor);
|
||
// Neutral body: the theme core colour; no theme class keeps light-dark() like Flat.
|
||
rules.push(`${S} .dev { --iso-body: var(--device-core-bg); --iso-fg: var(--device-core-fg);`
|
||
+ ` --iso-edge: light-dark(${edge('#ffffff', 'light', false)}, ${ISO_DARK_EDGE_DARK_THEME});`
|
||
+ ` --iso-badge-edge: light-dark(${edge('#ffffff', 'light', false)}, ${ISO_DARK_EDGE_DARK_THEME}); }`);
|
||
rules.push(`${S} .dev.iso-floor-light { --iso-edge: light-dark(${edge('#ffffff', 'light', true)}, ${ISO_DARK_EDGE_DARK_THEME});`
|
||
+ ` --iso-badge-edge: light-dark(${edge('#ffffff', 'light', true)}, ${ISO_DARK_EDGE_DARK_THEME}); }`);
|
||
rules.push(`${S} .dev.theme-light { --iso-edge: ${edge('#ffffff', 'light', false)}; --iso-badge-edge: ${edge('#ffffff', 'light', false)}; }`);
|
||
rules.push(`${S} .dev.theme-light.iso-floor-light { --iso-edge: ${edge('#ffffff', 'light', true)}; --iso-badge-edge: ${edge('#ffffff', 'light', true)}; }`);
|
||
rules.push(`${S} .dev.theme-dark { --iso-edge: ${ISO_DARK_EDGE_DARK_THEME}; --iso-badge-edge: ${ISO_DARK_EDGE_DARK_THEME}; }`);
|
||
for (const [state, body] of Object.entries(ISO_STATE_BODIES)) {
|
||
rules.push(`${S} .dev.${state} { --iso-body: ${body}; --iso-fg: light-dark(#fff, #252525);`
|
||
+ ` --iso-edge: ${edge(body, 'light', false)}; }`);
|
||
rules.push(`${S} .dev.${state}.iso-floor-light { --iso-edge: ${edge(body, 'light', true)}; }`);
|
||
rules.push(`${S} .dev.theme-light.${state} { --iso-fg: #fff; }`);
|
||
// The edge follows the theme too (a dark state body would take #4a4a4a there).
|
||
rules.push(`${S} .dev.theme-dark.${state} { --iso-fg: #252525; --iso-edge: ${edge(body, 'dark', false)}; }`);
|
||
rules.push(`${S} .dev.theme-dark.${state}.iso-floor-light { --iso-edge: ${edge(body, 'dark', true)}; }`);
|
||
}
|
||
// Locks: the same body language.
|
||
rules.push(`${S} .oplock { --iso-body: var(--oplock-core-bg); --iso-fg: var(--oplock-core-fg);`
|
||
+ ` --iso-edge: light-dark(${edge('#ffffff', 'light', false)}, ${ISO_DARK_EDGE_DARK_THEME}); }`);
|
||
rules.push(`${S} .oplock.iso-floor-light { --iso-edge: light-dark(${edge('#ffffff', 'light', true)}, ${ISO_DARK_EDGE_DARK_THEME}); }`);
|
||
rules.push(`${S} .oplock.theme-dark { --iso-edge: ${ISO_DARK_EDGE_DARK_THEME}; }`);
|
||
for (const [cls, body] of [['locked', '#66D17A'], ['unlocked', '#F0410C']] as const) {
|
||
rules.push(`${S} .oplock.${cls} { --iso-edge: ${edge(body, 'light', false)}; }`);
|
||
rules.push(`${S} .oplock.${cls}.iso-floor-light { --iso-edge: ${edge(body, 'light', true)}; }`);
|
||
}
|
||
// Floor shadows, theme × floor (a later rule of equal weight wins: dark last).
|
||
const tinted = ':is(.on, .open, .lock-locked, .lock-unlocked, .unavail, .alarm, .locked, .unlocked)';
|
||
const shadow = (sel: string, s: IsoTileShadow, white = true) => `${sel} {`
|
||
+ ` --iso-sh-dx: ${u(s.dx)}; --iso-sh-dy: ${u(s.dy)}; --iso-sh-blur: ${u(s.sigma * 2)};`
|
||
+ ` --iso-sh-a: ${white ? s.opacityWhite : s.opacityTinted}; --iso-sh-a-white: ${s.opacityWhite}; }`;
|
||
const G = `${S} .iso-tile-shadow`;
|
||
rules.push(shadow(G, isoTileShadow('light', false)));
|
||
rules.push(shadow(`${G}${tinted}`, isoTileShadow('light', false), false));
|
||
rules.push(shadow(`${G}.iso-floor-light`, isoTileShadow('light', true)));
|
||
rules.push(shadow(`${G}.iso-floor-light${tinted}`, isoTileShadow('light', true), false));
|
||
rules.push(shadow(`${G}.theme-dark.theme-dark`, isoTileShadow('dark', false)));
|
||
rules.push(shadow(`${G}.theme-dark.theme-dark.iso-floor-light`, isoTileShadow('dark', true)));
|
||
rules.push(shadow(`${G}.theme-dark.theme-dark${tinted}`, isoTileShadow('dark', false), false));
|
||
rules.push(shadow(`${G}.theme-dark.theme-dark.iso-floor-light${tinted}`, isoTileShadow('dark', true), false));
|
||
return rules.join('\n');
|
||
}
|
||
|
||
/** Geometry of the tile language, as CSS custom properties in D. */
|
||
export function isoTileGeometryCss(): string {
|
||
return `${S} .devlayer { --iso-d: calc(var(--device-base-size, 2.25cqw) * ${ISO_ICON_SCALE}); }
|
||
${S} .dev, ${S} .oplock {
|
||
--iso-depth: ${u(ISO_TILE.depth)};
|
||
--iso-lift: ${u(ISO_TILE.lift)};
|
||
--iso-frame-w: ${u(ISO_TILE.frameWidth)};
|
||
--iso-frame-out: ${u(ISO_TILE.frameOutset + ISO_TILE.frameWidth / 2)};
|
||
--iso-shadow-inset: ${u(ISO_TILE.shadowInset)};
|
||
}
|
||
${S} .dev { --iso-radius: min(${u(ISO_TILE.radius)}, calc(var(--dev-size) * ${ISO_TILE.radiusOfHeight})); }
|
||
${S} .dev .value-badge { --iso-badge-radius: min(${u(ISO_TILE.radius)}, calc(var(--dev-size) * .7875 * ${ISO_TILE.radiusOfHeight})); }
|
||
${S} .dev .device-shell.with-values { gap: ${u(ISO_TILE.badgeGap)}; }
|
||
${S} .oplock { --iso-radius: min(${u(ISO_TILE.radius)}, calc(var(--oplock-core-size) * ${ISO_TILE.radiusOfHeight})); }`;
|
||
}
|
||
|
||
/**
|
||
* The floor-shadow twin of a marker or lock: the same classes and geometry,
|
||
* painted only as the inset, blurred box-shadow of its bodies. It lives in one
|
||
* layer under every marker, so a shadow never lands on a neighbour's tile.
|
||
*/
|
||
export function renderIsoTileShadow(kind: 'dev' | 'oplock', owner: string, classes: string, style: string,
|
||
face: TemplateResult | typeof nothing): TemplateResult {
|
||
return html`<div class="${kind} iso-tile-shadow ${classes}" data-shadow-of=${owner} style=${style}
|
||
aria-hidden="true">${face}</div>`;
|
||
}
|