mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 22:49:16 +00:00
feat: background color around the plan — global + per-space (HP-1554)
New setting 'background around the plan' (#rrggbb): - global: config.settings.bg_color, edited in the gear dialog with a live preview and a 'theme default' reset (empty = keep the stylesheet default); - per-space override in the space dialog next to the room colors, empty = inherit the general setting (the show_lqi/fill_mode pattern); - applied to the stage in view and kiosk modes (editors keep their own canvas) and to the static houseplan-space-card; - backend validates both keys with the same strict #rrggbb match as room_color; garbage strings are rejected (test_bg_color_setting). smoke_bg_color covers apply/override/inherit/reset, dialog previews, the wire format of the cleared override, kiosk and the static card.
This commit is contained in:
+65
-5
@@ -19,7 +19,7 @@ import {
|
||||
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
||||
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
|
||||
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors, runServiceFor, RUN_TARGET_DOMAINS,
|
||||
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
||||
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, stageBgOf,
|
||||
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
||||
referencedContentUrls,
|
||||
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES,
|
||||
@@ -294,7 +294,7 @@ class HouseplanCard extends LitElement {
|
||||
private _onboardingShown = false; // the auto space dialog is shown once per session
|
||||
|
||||
private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null;
|
||||
private _settingsDialog: { colors: FillColors; glowRadius: number; busy: boolean } | null = null;
|
||||
private _settingsDialog: { colors: FillColors; glowRadius: number; bgColor: string | null; busy: boolean } | null = null;
|
||||
private _importDialog: { floors: (FloorInfo & { checked: boolean })[] } | null = null;
|
||||
private _importQueue: string[] = []; // floor titles still to create
|
||||
private _importTotal = 0;
|
||||
@@ -363,6 +363,7 @@ class HouseplanCard extends LitElement {
|
||||
showNames: boolean;
|
||||
roomColor: string;
|
||||
roomOpacity: number; // 0..1
|
||||
bgColor: string | null; // background around the plan; null = inherit general
|
||||
fillMode: 'none' | 'lqi' | 'light' | 'temp' | 'glow';
|
||||
tempMin: number;
|
||||
tempMax: number;
|
||||
@@ -3776,6 +3777,7 @@ class HouseplanCard extends LitElement {
|
||||
source: sp.plan_url ? 'file' : 'draw',
|
||||
showBorders: disp.showBorders, showNames: disp.showNames,
|
||||
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
|
||||
bgColor: disp.bgColor,
|
||||
tempMin: disp.tempMin, tempMax: disp.tempMax,
|
||||
showLqi: disp.showLqi ?? this._config?.show_signal ?? true,
|
||||
cardFontScale: disp.cardFontScale,
|
||||
@@ -3790,6 +3792,7 @@ class HouseplanCard extends LitElement {
|
||||
source: 'file',
|
||||
showBorders: false, showNames: false,
|
||||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'glow',
|
||||
bgColor: null,
|
||||
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
||||
showLqi: this._config?.show_signal ?? true,
|
||||
cardFontScale: 1,
|
||||
@@ -4017,6 +4020,7 @@ class HouseplanCard extends LitElement {
|
||||
show_names: draw && d.mode === 'create' ? true : d.showNames,
|
||||
room_color: d.roomColor,
|
||||
room_opacity: d.roomOpacity,
|
||||
bg_color: d.bgColor || undefined, // empty = inherit the general setting
|
||||
fill_mode: d.fillMode,
|
||||
temp_min: Number.isFinite(d.tempMin) ? Math.min(d.tempMin, d.tempMax) : DEFAULT_TEMP_MIN,
|
||||
temp_max: Number.isFinite(d.tempMax) ? Math.max(d.tempMin, d.tempMax) : DEFAULT_TEMP_MAX,
|
||||
@@ -4123,6 +4127,7 @@ class HouseplanCard extends LitElement {
|
||||
source: 'file',
|
||||
showBorders: false, showNames: false,
|
||||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'glow',
|
||||
bgColor: null,
|
||||
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
||||
showLqi: this._config?.show_signal ?? true,
|
||||
cardFontScale: 1,
|
||||
@@ -4178,6 +4183,31 @@ class HouseplanCard extends LitElement {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective stage background: per-space override → global setting → '' (the
|
||||
* theme default from the stylesheet). An open dialog previews its pending
|
||||
* value, so the color can be picked against the live plan.
|
||||
*/
|
||||
private _stageBg(disp: SpaceDisplay): string {
|
||||
const gd = this._settingsDialog;
|
||||
const sd = this._spaceDialog;
|
||||
const globalBg = gd ? gd.bgColor || '' : stageBgOf(this._settings, { bgColor: null });
|
||||
const spaceBg = sd && sd.mode === 'edit' && sd.spaceId === this._space
|
||||
? sd.bgColor || ''
|
||||
: disp.bgColor || '';
|
||||
return spaceBg || globalBg;
|
||||
}
|
||||
|
||||
/** Current computed stage background as #rrggbb — the color input's default. */
|
||||
private _stageBgHex(): string {
|
||||
const st = this._stageEl;
|
||||
if (st) {
|
||||
const m = getComputedStyle(st).backgroundColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
||||
if (m) return '#' + m.slice(1, 4).map((n) => (+n).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
return '#111111';
|
||||
}
|
||||
|
||||
// ================= GENERAL SETTINGS =================
|
||||
|
||||
private _openSettingsDialog = (): void => {
|
||||
@@ -4187,7 +4217,12 @@ class HouseplanCard extends LitElement {
|
||||
const glowRadius = this._imperial
|
||||
? Math.round((cm / 30.48) * 10) / 10
|
||||
: Math.round(cm) / 100;
|
||||
this._settingsDialog = { colors: JSON.parse(JSON.stringify(this._fillColors)), glowRadius, busy: false };
|
||||
this._settingsDialog = {
|
||||
colors: JSON.parse(JSON.stringify(this._fillColors)),
|
||||
glowRadius,
|
||||
bgColor: stageBgOf(this._settings, { bgColor: null }) || null,
|
||||
busy: false,
|
||||
};
|
||||
};
|
||||
|
||||
private _setFillColor(key: keyof FillColors, patch: Partial<{ c: string; a: number }>): void {
|
||||
@@ -4208,6 +4243,8 @@ class HouseplanCard extends LitElement {
|
||||
const cm = this._imperial ? d.glowRadius * 30.48 : d.glowRadius * 100;
|
||||
if (Number.isFinite(cm) && cm > 0 && Math.round(cm) !== 300) settings.glow_radius_cm = Math.round(cm);
|
||||
else delete settings.glow_radius_cm;
|
||||
if (d.bgColor) settings.bg_color = d.bgColor;
|
||||
else delete settings.bg_color;
|
||||
this._serverCfg = { ...cfg, settings };
|
||||
await this._saveConfigNow();
|
||||
this._settingsDialog = null;
|
||||
@@ -4360,10 +4397,21 @@ class HouseplanCard extends LitElement {
|
||||
}} />
|
||||
<span class="opl">${this._imperial ? this._t('gs.unit_ft') : this._t('gs.unit_m')}</span>
|
||||
</div>
|
||||
<label class="dispsection">${this._t('gs.bg_group')}</label>
|
||||
<div class="colorrow gsrow">
|
||||
<span class="gsl">${this._t('gs.bg_color')}</span>
|
||||
<input type="color" .value=${this._settingsDialog!.bgColor || this._stageBgHex()}
|
||||
@input=${(e: Event) =>
|
||||
(this._settingsDialog = { ...this._settingsDialog!, bgColor: (e.target as HTMLInputElement).value })} />
|
||||
${this._settingsDialog!.bgColor
|
||||
? html`<button class="btn ghost" @click=${() =>
|
||||
(this._settingsDialog = { ...this._settingsDialog!, bgColor: null })}>${this._t('gs.bg_default')}</button>`
|
||||
: html`<span class="opl">${this._t('gs.bg_theme')}</span>`}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="btn ghost" @click=${() =>
|
||||
(this._settingsDialog = { ...this._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: this._imperial ? 9.8 : 3 })}>
|
||||
(this._settingsDialog = { ...this._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: this._imperial ? 9.8 : 3, bgColor: null })}>
|
||||
${this._t('gs.reset')}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
@@ -4557,6 +4605,9 @@ class HouseplanCard extends LitElement {
|
||||
const cfgSize = this._config.icon_size ?? 2.5;
|
||||
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
|
||||
const view = this._viewOr(vb);
|
||||
// Background around the plan (view/kiosk; editors keep their own canvas).
|
||||
// Both settings dialogs preview their pending value live.
|
||||
const stageBg = this._editing ? '' : this._stageBg(disp);
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
@@ -4628,7 +4679,7 @@ class HouseplanCard extends LitElement {
|
||||
</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}"
|
||||
style="height:${this._kiosk ? '100dvh' : `calc(100dvh - ${this._hdrH}px)`}"
|
||||
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)}
|
||||
@pointerdown=${(e: PointerEvent) => { this._notePointer(e); this._stagePointerDown(e); }}
|
||||
@@ -6727,6 +6778,15 @@ class HouseplanCard extends LitElement {
|
||||
@input=${(e: Event) => (this._spaceDialog = { ...d, roomOpacity: Number((e.target as HTMLInputElement).value) / 100 })} />
|
||||
<span class="opv">${Math.round(d.roomOpacity * 100)}%</span>
|
||||
</div>
|
||||
<label>${this._t('space.bg_color')}</label>
|
||||
<div class="colorrow">
|
||||
<input type="color" .value=${d.bgColor || stageBgOf(this._settings, { bgColor: null }) || this._stageBgHex()}
|
||||
@input=${(e: Event) => (this._spaceDialog = { ...d, bgColor: (e.target as HTMLInputElement).value })} />
|
||||
${d.bgColor
|
||||
? html`<button class="btn ghost" @click=${() => (this._spaceDialog = { ...d, bgColor: null })}>
|
||||
${this._t('space.bg_inherit')}</button>`
|
||||
: html`<span class="opl">${this._t('space.bg_inherited')}</span>`}
|
||||
</div>
|
||||
<label>${this._t('space.fill_label')}</label>
|
||||
${SPACE_FILL_MODES.map((v) => [v, 'fill.' + v] as const).map(
|
||||
([v, k]) => html`<label class="srcrow">
|
||||
|
||||
+8
-1
@@ -379,5 +379,12 @@
|
||||
"vac.fit_mirror": "Mirror",
|
||||
"vac.trail_never": "Never",
|
||||
"vac.trail_cleaning": "While cleaning",
|
||||
"vac.trail_always": "Always"
|
||||
"vac.trail_always": "Always",
|
||||
"gs.bg_group": "Stage background",
|
||||
"gs.bg_color": "Background around the plan",
|
||||
"gs.bg_default": "Theme default",
|
||||
"gs.bg_theme": "theme default",
|
||||
"space.bg_color": "Background around the plan",
|
||||
"space.bg_inherit": "Inherit general",
|
||||
"space.bg_inherited": "inherits general settings"
|
||||
}
|
||||
|
||||
+8
-1
@@ -379,5 +379,12 @@
|
||||
"vac.fit_mirror": "Отразить",
|
||||
"vac.trail_never": "Не показывать никогда",
|
||||
"vac.trail_cleaning": "Во время уборки",
|
||||
"vac.trail_always": "Показывать всегда"
|
||||
"vac.trail_always": "Показывать всегда",
|
||||
"gs.bg_group": "Фон сцены",
|
||||
"gs.bg_color": "Цвет фона вокруг плана",
|
||||
"gs.bg_default": "Как в теме",
|
||||
"gs.bg_theme": "по умолчанию — из темы",
|
||||
"space.bg_color": "Цвет фона вокруг плана",
|
||||
"space.bg_inherit": "Наследовать общий",
|
||||
"space.bg_inherited": "наследуется из общих настроек"
|
||||
}
|
||||
|
||||
@@ -739,6 +739,8 @@ export interface SpaceDisplay {
|
||||
labelHum: boolean;
|
||||
labelLqi: boolean;
|
||||
labelLight: boolean;
|
||||
/** Background around the plan; null = inherit the global setting / theme. */
|
||||
bgColor: string | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_ROOM_COLOR = '#3ea6ff';
|
||||
@@ -766,9 +768,21 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
|
||||
labelHum: s.label_hum === true,
|
||||
labelLqi: s.label_lqi === true,
|
||||
labelLight: s.label_light === true,
|
||||
bgColor: typeof s.bg_color === 'string' && /^#[0-9a-f]{6}$/i.test(s.bg_color) ? s.bg_color : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective background color around the plan: the per-space override wins,
|
||||
* then the global config.settings.bg_color, then '' — meaning "keep the
|
||||
* theme default" (the stage's stylesheet background stays untouched).
|
||||
*/
|
||||
export function stageBgOf(settings: any, disp: { bgColor: string | null }): string {
|
||||
if (disp.bgColor) return disp.bgColor;
|
||||
const g = settings?.bg_color;
|
||||
return typeof g === 'string' && /^#[0-9a-f]{6}$/i.test(g) ? g : '';
|
||||
}
|
||||
|
||||
// ---------------- global fill colors ----------------
|
||||
|
||||
export interface FillColorEntry {
|
||||
|
||||
+4
-2
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { html, svg, nothing, type TemplateResult } from 'lit';
|
||||
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
|
||||
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf } from './logic';
|
||||
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf, stageBgOf } from './logic';
|
||||
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
|
||||
import { t, type Lang } from './i18n';
|
||||
import type { ServerConfig } from './types';
|
||||
@@ -146,9 +146,11 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
: [];
|
||||
|
||||
const bgHref = space.bg ? (o.displayUrl ? o.displayUrl(space.bg.href) : space.bg.href) : '';
|
||||
// the static card paints the same background around the plan as the full one
|
||||
const stageBg = stageBgOf(o.cfg?.settings, disp);
|
||||
|
||||
return html`
|
||||
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}">
|
||||
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''}">
|
||||
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
|
||||
${bgHref
|
||||
? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}" preserveAspectRatio="none" />`
|
||||
|
||||
Reference in New Issue
Block a user