mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
feat v1.24.0: general settings (global fill palette) + per-space LQI toggle
- General settings dialog: fill colors grouped by mode (light on/off, temp cold/ok/hot, lqi weak/strong), each with its own opacity; lqi fill lerps between the endpoints; stored in settings.fill_colors (defaults omitted); space-card uses the same palette - per-space show_lqi toggle (badges + room tooltip line), inherits the card's show_signal when unset - fillColorsOf/lerpColor/roomFillStyle helpers (+4 tests), backend schemas, smoke_general_settings; TESTING.md updated in the same commit
This commit is contained in:
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
||||
FILES_URL = "/houseplan_files/files"
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.23.2"
|
||||
VERSION = "1.24.0"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.23.2"
|
||||
"version": "1.24.0"
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
|
||||
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp"]),
|
||||
vol.Optional("temp_min"): vol.Coerce(float),
|
||||
vol.Optional("temp_max"): vol.Coerce(float),
|
||||
vol.Optional("show_lqi"): bool,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
@@ -154,7 +155,21 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("spaces"): [SPACE_SCHEMA],
|
||||
vol.Optional("markers", default=list): [MARKER_SCHEMA],
|
||||
vol.Optional("settings", default=dict): vol.Schema({}, extra=vol.ALLOW_EXTRA),
|
||||
vol.Optional("settings", default=dict): vol.Schema(
|
||||
{
|
||||
vol.Optional("fill_colors"): vol.Schema(
|
||||
{
|
||||
str: vol.Schema(
|
||||
{
|
||||
vol.Required("c"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||
}
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
|
||||
)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// 1) диалог общих настроек: 7 цветовых строк, 3 группы
|
||||
c._openSettingsDialog(); await c.updateComplete;
|
||||
out.rows = sr().querySelectorAll('.gsrow').length;
|
||||
out.groups = [...sr().querySelectorAll('.dialog .dispsection')].map((l) => l.textContent.trim());
|
||||
// 2) сменить цвет light_on и сохранить
|
||||
c._setFillColor('light_on', { c: '#ff00ff', a: 0.5 });
|
||||
await c._saveSettingsDialog(); await c.updateComplete;
|
||||
out.saved = c._serverCfg.settings.fill_colors?.light_on;
|
||||
// 3) заливка light использует кастомный цвет
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s,
|
||||
settings: { ...(s.settings||{}), show_borders: true, fill_mode: 'light' } })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const styles = [...sr().querySelectorAll('.room.styled')].map((r) => r.getAttribute('style') || '');
|
||||
out.customFillUsed = styles.some((st) => st.includes('#ff00ff') && st.includes('0.500'));
|
||||
// 4) show_lqi=false у пространства скрывает LQI-бейджи
|
||||
out.lqiBefore = sr().querySelectorAll('.dev .lqi').length;
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s,
|
||||
settings: { ...(s.settings||{}), show_lqi: false } })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.lqiAfter = sr().querySelectorAll('.dev .lqi').length;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
File diff suppressed because one or more lines are too long
Vendored
+167
-119
File diff suppressed because one or more lines are too long
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## v1.24.0 — 2026-07-16 (general settings: fill palette; per-space LQI toggle)
|
||||
- **New "General settings" dialog** (⚙ in the header): the fill colors used by every
|
||||
space, grouped by mode — lights (on / all off), temperature (cold / comfortable /
|
||||
hot) and zigbee signal (weak / strong endpoints of the gradient). Every color has
|
||||
its **own opacity slider**; the zigbee fill interpolates between the two configured
|
||||
endpoint colors. Stored server-side in `settings.fill_colors` (defaults are not
|
||||
persisted); the static `houseplan-space-card` uses the same palette.
|
||||
- **Per-space "Show zigbee signal (LQI)" toggle** in the space dialog: hides or shows
|
||||
the LQI badges next to zigbee devices (and the signal line in room tooltips) for
|
||||
that space; when never touched, the card-level `show_signal` option applies as before.
|
||||
- Fill opacity is now governed by the per-color setting; the space "Opacity" slider
|
||||
keeps controlling borders and names.
|
||||
- New pure helpers `fillColorsOf` / `lerpColor` / `roomFillStyle` (+4 tests: 77 → 81);
|
||||
backend schema for `fill_colors` and `show_lqi`; smoke `smoke_general_settings`.
|
||||
|
||||
## v1.23.2 — 2026-07-16 (manual upload limit raised to 50 MB)
|
||||
- The per-file limit for attached manuals (PDF etc.) is now **50 MB** (was 25).
|
||||
The limit is still enforced while the multipart body streams in, so an oversized
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.23.2** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||
| Version | **v1.24.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…**v1.23.1** (latest published 2026-07-17, bundle auto-attached by release.yml) |
|
||||
| CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) |
|
||||
| HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/9004** (queue ≈2 months as of 2026-07). Lesson: #8995 was auto-closed by hacs-bot — the PR body MUST be their exact template with every checkbox ticked and all 3 links (release, HACS action run, hassfest run); a custom body gets closed without discussion |
|
||||
|
||||
@@ -57,6 +57,13 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
smart-plug chip temperatures (`*_device_temperature`) and diagnostic-category temps are excluded [auto]
|
||||
- [ ] Space dialog is 500 px wide; the comfort-bounds inputs are compact (56 px)
|
||||
- [ ] The scale (cm per cell) input is compact (72 px), not full-width [auto]
|
||||
- [ ] General settings (⚙ in the header): fill colors grouped by mode (lights on/off,
|
||||
temp cold/comfy/hot, LQI weak/strong), each with its own opacity slider [auto];
|
||||
Reset restores defaults; saving defaults stores nothing [auto]
|
||||
- [ ] Custom fill colors apply to the full card AND the static space-card
|
||||
- [ ] LQI gradient interpolates between the configured weak/strong colors [auto]
|
||||
- [ ] Per-space "Show zigbee signal (LQI)" toggle hides/shows the badges next to
|
||||
devices and the signal line in room tooltips for that space only [auto]
|
||||
- [ ] Device icon badge is centred exactly on its point (no 1 px down-right drift) [auto]
|
||||
- [ ] Device glyph is centred within its badge (no vertical drift — real ha-icon is block+line-height) [auto]
|
||||
- [ ] Room hover highlight still works when custom borders/fills are on
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.23.1",
|
||||
"version": "1.23.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.23.1",
|
||||
"version": "1.23.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.23.2",
|
||||
"version": "1.24.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+111
-8
@@ -17,7 +17,8 @@ import {
|
||||
pointOnBoundary, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary,
|
||||
snapToWall, openingAmount,
|
||||
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
||||
spaceDisplayOf, roomFillColor, isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
||||
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors,
|
||||
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
||||
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
||||
} from './logic';
|
||||
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp } from './devices';
|
||||
@@ -30,7 +31,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.23.2';
|
||||
const CARD_VERSION = '1.24.0';
|
||||
const LS_KEY = 'houseplan_card_layout_v1';
|
||||
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||
@@ -118,6 +119,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; busy: boolean } | null = null;
|
||||
private _importDialog: { floors: (FloorInfo & { checked: boolean })[] } | null = null;
|
||||
private _importQueue: string[] = []; // floor titles still to create
|
||||
private _importTotal = 0;
|
||||
@@ -159,6 +161,7 @@ class HouseplanCard extends LitElement {
|
||||
fillMode: 'none' | 'lqi' | 'light' | 'temp';
|
||||
tempMin: number;
|
||||
tempMax: number;
|
||||
showLqi: boolean;
|
||||
cellCm: number; // real-world cm represented by one grid cell
|
||||
busy: boolean;
|
||||
} | null = null;
|
||||
@@ -208,6 +211,7 @@ class HouseplanCard extends LitElement {
|
||||
_spaceDialog: { state: true },
|
||||
_infoCard: { state: true },
|
||||
_rulesDialog: { state: true },
|
||||
_settingsDialog: { state: true },
|
||||
_importDialog: { state: true },
|
||||
_markerDialog: { state: true },
|
||||
_zoom: { state: true },
|
||||
@@ -379,6 +383,11 @@ class HouseplanCard extends LitElement {
|
||||
return this._rulesCompiled;
|
||||
}
|
||||
|
||||
/** Global fill palette (config.settings.fill_colors over the defaults). */
|
||||
private get _fillColors(): FillColors {
|
||||
return fillColorsOf(this._settings);
|
||||
}
|
||||
|
||||
private get _excluded(): Set<string> {
|
||||
const list = this._settings.exclude_integrations;
|
||||
return list ? new Set(list) : EXCLUDED_DOMAINS;
|
||||
@@ -1853,6 +1862,7 @@ class HouseplanCard extends LitElement {
|
||||
showBorders: disp.showBorders, showNames: disp.showNames,
|
||||
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
|
||||
tempMin: disp.tempMin, tempMax: disp.tempMax,
|
||||
showLqi: disp.showLqi ?? this._config?.show_signal ?? true,
|
||||
cellCm: Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5,
|
||||
busy: false,
|
||||
};
|
||||
@@ -1863,6 +1873,7 @@ class HouseplanCard extends LitElement {
|
||||
showBorders: false, showNames: false,
|
||||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
|
||||
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
||||
showLqi: this._config?.show_signal ?? true,
|
||||
cellCm: 5,
|
||||
busy: false,
|
||||
};
|
||||
@@ -1946,6 +1957,7 @@ class HouseplanCard extends LitElement {
|
||||
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,
|
||||
show_lqi: d.showLqi,
|
||||
};
|
||||
sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0 ? d.cellCm : 5;
|
||||
await this._saveConfigNow();
|
||||
@@ -2037,6 +2049,7 @@ class HouseplanCard extends LitElement {
|
||||
showBorders: false, showNames: false,
|
||||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
|
||||
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
||||
showLqi: this._config?.show_signal ?? true,
|
||||
cellCm: 5,
|
||||
busy: false,
|
||||
};
|
||||
@@ -2088,6 +2101,85 @@ class HouseplanCard extends LitElement {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ================= GENERAL SETTINGS =================
|
||||
|
||||
private _openSettingsDialog = (): void => {
|
||||
if (!this._norm) return;
|
||||
// deep copy so the dialog edits do not leak into the live palette
|
||||
this._settingsDialog = { colors: JSON.parse(JSON.stringify(this._fillColors)), busy: false };
|
||||
};
|
||||
|
||||
private _setFillColor(key: keyof FillColors, patch: Partial<{ c: string; a: number }>): void {
|
||||
const d = this._settingsDialog!;
|
||||
this._settingsDialog = { ...d, colors: { ...d.colors, [key]: { ...d.colors[key], ...patch } } };
|
||||
}
|
||||
|
||||
private async _saveSettingsDialog(): Promise<void> {
|
||||
const d = this._settingsDialog;
|
||||
if (!d || d.busy) return;
|
||||
this._settingsDialog = { ...d, busy: true };
|
||||
try {
|
||||
const cfg = this._serverCfg!;
|
||||
const isDefault = JSON.stringify(d.colors) === JSON.stringify(DEFAULT_FILL_COLORS);
|
||||
const settings: any = { ...cfg.settings };
|
||||
if (isDefault) delete settings.fill_colors;
|
||||
else settings.fill_colors = d.colors;
|
||||
this._serverCfg = { ...cfg, settings };
|
||||
await this._saveConfigNow();
|
||||
this._settingsDialog = null;
|
||||
this.requestUpdate();
|
||||
this._showToast(this._t('gs.saved'));
|
||||
} catch (e: any) {
|
||||
this._settingsDialog = { ...this._settingsDialog!, busy: false };
|
||||
this._showToast(this._t('toast.error', { err: this._errText(e) }));
|
||||
}
|
||||
}
|
||||
|
||||
private _renderColorRow(key: keyof FillColors, labelKey: string): TemplateResult {
|
||||
const d = this._settingsDialog!;
|
||||
const v = d.colors[key];
|
||||
return html`<div class="colorrow gsrow">
|
||||
<span class="gsl">${this._t(labelKey as any)}</span>
|
||||
<input type="color" .value=${v.c}
|
||||
@input=${(e: Event) => this._setFillColor(key, { c: (e.target as HTMLInputElement).value })} />
|
||||
<input type="range" min="0" max="100" .value=${String(Math.round(v.a * 100))}
|
||||
@input=${(e: Event) => this._setFillColor(key, { a: Number((e.target as HTMLInputElement).value) / 100 })} />
|
||||
<span class="opv">${Math.round(v.a * 100)}%</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderSettingsDialog(): TemplateResult {
|
||||
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="dialog wide" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="hd"><ha-icon icon="mdi:cog-outline"></ha-icon>${this._t('gs.title')}</div>
|
||||
<div class="body">
|
||||
<div class="rhint">${this._t('gs.hint')}</div>
|
||||
<label class="dispsection">${this._t('gs.light_group')}</label>
|
||||
${this._renderColorRow('light_on', 'gs.light_on')}
|
||||
${this._renderColorRow('light_off', 'gs.light_off')}
|
||||
<label class="dispsection">${this._t('gs.temp_group')}</label>
|
||||
${this._renderColorRow('temp_cold', 'gs.temp_cold')}
|
||||
${this._renderColorRow('temp_ok', 'gs.temp_ok')}
|
||||
${this._renderColorRow('temp_hot', 'gs.temp_hot')}
|
||||
<label class="dispsection">${this._t('gs.lqi_group')}</label>
|
||||
${this._renderColorRow('lqi_low', 'gs.lqi_low')}
|
||||
${this._renderColorRow('lqi_high', 'gs.lqi_high')}
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="btn ghost" @click=${() =>
|
||||
(this._settingsDialog = { ...this._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)) })}>
|
||||
${this._t('gs.reset')}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn ghost" @click=${() => (this._settingsDialog = null)}>${this._t('btn.cancel')}</button>
|
||||
<button class="btn on" @click=${this._saveSettingsDialog} ?disabled=${this._settingsDialog!.busy}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>${this._settingsDialog!.busy ? '…' : this._t('btn.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ================= ICON RULES EDITOR =================
|
||||
|
||||
private _openRulesDialog = (): void => {
|
||||
@@ -2221,6 +2313,7 @@ class HouseplanCard extends LitElement {
|
||||
const vb = space.vb;
|
||||
const devs = this._devices.filter((d) => d.space === space.id);
|
||||
const disp = spaceDisplayOf(this._curSpaceCfg);
|
||||
const showLqi = disp.showLqi ?? this._config.show_signal ?? true;
|
||||
const cfgSize = this._config.icon_size ?? 2.5;
|
||||
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
|
||||
const view = this._viewOr(vb);
|
||||
@@ -2284,6 +2377,9 @@ class HouseplanCard extends LitElement {
|
||||
</button>
|
||||
<button class="btn" @click=${this._openRulesDialog} title=${this._t('title.icon_rules')}>
|
||||
<ha-icon icon="mdi:shape-plus-outline"></ha-icon>
|
||||
</button>
|
||||
<button class="btn" @click=${this._openSettingsDialog} title=${this._t('title.general_settings')}>
|
||||
<ha-icon icon="mdi:cog-outline"></ha-icon>
|
||||
</button>`
|
||||
: nothing}
|
||||
<button class="btn ${this._markup ? 'on' : ''}" @click=${this._toggleMarkup}
|
||||
@@ -2319,24 +2415,25 @@ class HouseplanCard extends LitElement {
|
||||
// keep the stroke colour even when borders are hidden, so hover can reveal it
|
||||
st.push(`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`);
|
||||
const fillC = r.area
|
||||
? roomFillColor(
|
||||
? roomFillStyle(
|
||||
disp.fill,
|
||||
disp.fill === 'lqi' ? this._roomLqi(r.area) : null,
|
||||
disp.fill === 'light' ? areaLights(this.hass, this._devices, r.area) : 'none',
|
||||
disp.fill === 'temp' ? areaTemp(this.hass, this._devices, r.area) : null,
|
||||
disp.tempMin,
|
||||
disp.tempMax,
|
||||
this._fillColors,
|
||||
)
|
||||
: null;
|
||||
if (fillC) {
|
||||
cls += ' filled';
|
||||
st.push(`--room-fill:${fillC}`, `--room-fill-op:${(0.3 * disp.opacity).toFixed(3)}`);
|
||||
st.push(`--room-fill:${fillC.c}`, `--room-fill-op:${fillC.a.toFixed(3)}`);
|
||||
} else st.push('--room-fill:transparent', '--room-fill-op:0');
|
||||
style = st.join(';');
|
||||
}
|
||||
const tip = (e: MouseEvent) =>
|
||||
this._showTip(e, r.name, this._t('tip.room'),
|
||||
this._config?.show_signal ? this._roomLqi(r.area) : null,
|
||||
showLqi ? this._roomLqi(r.area) : null,
|
||||
r.area ? areaTemp(this.hass, this._devices, r.area) : null);
|
||||
const label = (!space.bg && !disp.showNames) || this._markup;
|
||||
const c = this._roomCenter(r);
|
||||
@@ -2354,7 +2451,7 @@ class HouseplanCard extends LitElement {
|
||||
${this._renderOpenings(disp)}
|
||||
</svg>
|
||||
<div class="devlayer" style="--icon-size:${((iconPct * vb[2]) / view.w).toFixed(3)}cqw">
|
||||
${devs.map((d) => this._renderDevice(d, view))}
|
||||
${devs.map((d) => this._renderDevice(d, view, showLqi))}
|
||||
${this._renderOpeningLocks(view)}
|
||||
${disp.showNames && !this._markup
|
||||
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
|
||||
@@ -2377,6 +2474,7 @@ class HouseplanCard extends LitElement {
|
||||
${this._markerDialog ? this._renderMarkerDialog() : nothing}
|
||||
${this._infoCard ? this._renderInfoCard() : nothing}
|
||||
${this._rulesDialog ? this._renderRulesDialog() : nothing}
|
||||
${this._settingsDialog ? this._renderSettingsDialog() : nothing}
|
||||
${this._importDialog ? this._renderImportDialog() : nothing}
|
||||
${this._tip
|
||||
? html`<div class="tip" style="left:${this._tip.x + 12}px;top:${this._tip.y + 12}px">
|
||||
@@ -2395,14 +2493,14 @@ class HouseplanCard extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderDevice(d: DevItem, view: { x: number; y: number; w: number; h: number }): TemplateResult {
|
||||
private _renderDevice(d: DevItem, view: { x: number; y: number; w: number; h: number }, showLqi = true): TemplateResult {
|
||||
const p = this._pos(d);
|
||||
const left = ((p.x - view.x) / view.w) * 100;
|
||||
const top = ((p.y - view.y) / view.h) * 100;
|
||||
const cls = this._stateClass(d);
|
||||
const temp = this._liveTemp(d);
|
||||
const hum = this._liveHum(d);
|
||||
const lqi = this._config?.show_signal && !d.virtual ? lqiFor(this.hass, d.entities) : null;
|
||||
const lqi = showLqi && !d.virtual ? lqiFor(this.hass, d.entities) : null;
|
||||
const m = d.marker;
|
||||
const disp = m?.display || 'badge';
|
||||
const ripple = disp === 'ripple' || disp === 'icon_ripple';
|
||||
@@ -3061,6 +3159,11 @@ class HouseplanCard extends LitElement {
|
||||
@change=${(e: Event) => (this._spaceDialog = { ...d, showNames: (e.target as HTMLInputElement).checked })} />
|
||||
<span>${this._t('space.show_names')}</span>
|
||||
</label>
|
||||
<label class="srcrow">
|
||||
<input type="checkbox" .checked=${d.showLqi}
|
||||
@change=${(e: Event) => (this._spaceDialog = { ...d, showLqi: (e.target as HTMLInputElement).checked })} />
|
||||
<span>${this._t('space.show_lqi')}</span>
|
||||
</label>
|
||||
<label>${this._t('space.room_color')}</label>
|
||||
<div class="colorrow">
|
||||
<input type="color" .value=${d.roomColor}
|
||||
|
||||
+17
-1
@@ -222,5 +222,21 @@
|
||||
"editor.button_label": "Button label",
|
||||
"editor.button_target": "Target dashboard path",
|
||||
"editor.aspect_ratio": "Aspect ratio (e.g. 16:9 or auto)",
|
||||
"marker.sub_entity": "entity"
|
||||
"marker.sub_entity": "entity",
|
||||
"title.general_settings": "General settings",
|
||||
"gs.title": "General settings",
|
||||
"gs.hint": "Fill colors apply to every space; each color has its own opacity. Which fill mode a space uses is set in that space's dialog.",
|
||||
"gs.light_group": "Fill: lights",
|
||||
"gs.light_on": "Lights on",
|
||||
"gs.light_off": "All lights off",
|
||||
"gs.temp_group": "Fill: temperature",
|
||||
"gs.temp_cold": "Cold",
|
||||
"gs.temp_ok": "Comfortable",
|
||||
"gs.temp_hot": "Hot",
|
||||
"gs.lqi_group": "Fill: zigbee signal",
|
||||
"gs.lqi_low": "Weak signal",
|
||||
"gs.lqi_high": "Strong signal",
|
||||
"gs.reset": "Reset to defaults",
|
||||
"gs.saved": "General settings saved",
|
||||
"space.show_lqi": "Show zigbee signal (LQI) next to devices"
|
||||
}
|
||||
|
||||
+17
-1
@@ -222,5 +222,21 @@
|
||||
"editor.button_label": "Текст кнопки",
|
||||
"editor.button_target": "Путь дашборда (куда вести)",
|
||||
"editor.aspect_ratio": "Соотношение сторон (напр. 16:9 или auto)",
|
||||
"marker.sub_entity": "сущность"
|
||||
"marker.sub_entity": "сущность",
|
||||
"title.general_settings": "Общие настройки",
|
||||
"gs.title": "Общие настройки",
|
||||
"gs.hint": "Цвета заливок действуют на все пространства; у каждого цвета своя прозрачность. Какой режим заливки использует пространство — задаётся в его диалоге.",
|
||||
"gs.light_group": "Заливка: освещение",
|
||||
"gs.light_on": "Свет включён",
|
||||
"gs.light_off": "Весь свет выключен",
|
||||
"gs.temp_group": "Заливка: температура",
|
||||
"gs.temp_cold": "Холодно",
|
||||
"gs.temp_ok": "Комфорт",
|
||||
"gs.temp_hot": "Жарко",
|
||||
"gs.lqi_group": "Заливка: зигби-сигнал",
|
||||
"gs.lqi_low": "Слабый сигнал",
|
||||
"gs.lqi_high": "Сильный сигнал",
|
||||
"gs.reset": "Сбросить к умолчаниям",
|
||||
"gs.saved": "Общие настройки сохранены",
|
||||
"space.show_lqi": "Показывать зигби-сигнал (LQI) у устройств"
|
||||
}
|
||||
|
||||
@@ -495,6 +495,8 @@ export interface SpaceDisplay {
|
||||
fill: RoomFillMode;
|
||||
tempMin: number; // comfort range lower bound, °C
|
||||
tempMax: number; // comfort range upper bound, °C
|
||||
/** Per-space LQI badges near zigbee devices; null = follow the card option. */
|
||||
showLqi: boolean | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_ROOM_COLOR = '#3ea6ff';
|
||||
@@ -514,9 +516,99 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
|
||||
fill: ['lqi', 'light', 'temp'].includes(s.fill_mode) ? s.fill_mode : 'none',
|
||||
tempMin: typeof s.temp_min === 'number' ? s.temp_min : DEFAULT_TEMP_MIN,
|
||||
tempMax: typeof s.temp_max === 'number' ? s.temp_max : DEFAULT_TEMP_MAX,
|
||||
showLqi: typeof s.show_lqi === 'boolean' ? s.show_lqi : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------- global fill colors ----------------
|
||||
|
||||
export interface FillColorEntry {
|
||||
c: string; // #rrggbb
|
||||
a: number; // 0..1 fill opacity
|
||||
}
|
||||
|
||||
/** Global fill palette, grouped by fill mode; stored in config.settings.fill_colors. */
|
||||
export interface FillColors {
|
||||
light_on: FillColorEntry;
|
||||
light_off: FillColorEntry;
|
||||
temp_cold: FillColorEntry;
|
||||
temp_ok: FillColorEntry;
|
||||
temp_hot: FillColorEntry;
|
||||
lqi_low: FillColorEntry;
|
||||
lqi_high: FillColorEntry;
|
||||
}
|
||||
|
||||
export const DEFAULT_FILL_COLORS: FillColors = {
|
||||
light_on: { c: '#ffd45c', a: 0.18 },
|
||||
light_off: { c: '#9aa0a6', a: 0.14 },
|
||||
temp_cold: { c: '#4fc3f7', a: 0.18 },
|
||||
temp_ok: { c: '#66d17a', a: 0.18 },
|
||||
temp_hot: { c: '#ffd45c', a: 0.18 },
|
||||
lqi_low: { c: '#f25a4a', a: 0.18 },
|
||||
lqi_high: { c: '#4bd28f', a: 0.18 },
|
||||
};
|
||||
|
||||
const HEX_RE = /^#[0-9a-f]{6}$/i;
|
||||
|
||||
/** Merge stored overrides over the defaults, dropping malformed entries. */
|
||||
export function fillColorsOf(settings: any): FillColors {
|
||||
const out: any = {};
|
||||
const src = settings?.fill_colors || {};
|
||||
for (const k of Object.keys(DEFAULT_FILL_COLORS) as (keyof FillColors)[]) {
|
||||
const d = DEFAULT_FILL_COLORS[k];
|
||||
const v = src[k];
|
||||
out[k] = {
|
||||
c: v && typeof v.c === 'string' && HEX_RE.test(v.c) ? v.c : d.c,
|
||||
a: v && typeof v.a === 'number' ? Math.min(1, Math.max(0, v.a)) : d.a,
|
||||
};
|
||||
}
|
||||
return out as FillColors;
|
||||
}
|
||||
|
||||
/** Linear RGB interpolation between two hex colors, t clamped to 0..1. */
|
||||
export function lerpColor(a: string, b: string, t: number): string {
|
||||
const tt = Math.min(1, Math.max(0, t));
|
||||
const pa = [1, 3, 5].map((i) => parseInt(a.slice(i, i + 2), 16));
|
||||
const pb = [1, 3, 5].map((i) => parseInt(b.slice(i, i + 2), 16));
|
||||
const mix = pa.map((v, i) => Math.round(v + (pb[i] - v) * tt));
|
||||
return '#' + mix.map((v) => v.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Room fill (color + opacity) for the selected mode, or null for "no fill",
|
||||
* using the global palette. The LQI gradient interpolates lqi_low → lqi_high
|
||||
* over the 40..180 LQI window (same thresholds as the badge color).
|
||||
*/
|
||||
export function roomFillStyle(
|
||||
mode: RoomFillMode,
|
||||
lqi: number | null,
|
||||
lights: 'on' | 'off' | 'none',
|
||||
temp: number | null | undefined,
|
||||
tempMin: number,
|
||||
tempMax: number,
|
||||
colors: FillColors,
|
||||
): FillColorEntry | null {
|
||||
if (mode === 'lqi') {
|
||||
if (lqi == null) return null;
|
||||
const t = (lqi - 40) / 140;
|
||||
return { c: lerpColor(colors.lqi_low.c, colors.lqi_high.c, t),
|
||||
a: colors.lqi_low.a + (colors.lqi_high.a - colors.lqi_low.a) * Math.min(1, Math.max(0, t)) };
|
||||
}
|
||||
if (mode === 'light') {
|
||||
if (lights === 'none') return null;
|
||||
return lights === 'on' ? colors.light_on : colors.light_off;
|
||||
}
|
||||
if (mode === 'temp') {
|
||||
if (temp == null) return null;
|
||||
const lo = Math.min(tempMin, tempMax);
|
||||
const hi = Math.max(tempMin, tempMax);
|
||||
if (temp < lo) return colors.temp_cold;
|
||||
if (temp > hi) return colors.temp_hot;
|
||||
return colors.temp_ok;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Room fill color for the selected mode, or null for "no fill".
|
||||
* - lqi: red→green gradient by the room's average zigbee signal (null LQI → no fill)
|
||||
|
||||
+4
-3
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { html, svg, nothing, type TemplateResult } from 'lit';
|
||||
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
|
||||
import { spaceDisplayOf, roomFillColor } from './logic';
|
||||
import { spaceDisplayOf, roomFillStyle, fillColorsOf } from './logic';
|
||||
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
|
||||
import { t, type Lang } from './i18n';
|
||||
import type { ServerConfig } from './types';
|
||||
@@ -71,18 +71,19 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
const parts = [`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`];
|
||||
// fill rendered exactly as configured on the full card (snapshot of current states)
|
||||
const fillC = r.area
|
||||
? roomFillColor(
|
||||
? roomFillStyle(
|
||||
disp.fill,
|
||||
disp.fill === 'lqi' ? areaLqi(o.hass, devs, r.area) : null,
|
||||
disp.fill === 'light' ? areaLights(o.hass, devs, r.area) : 'none',
|
||||
disp.fill === 'temp' ? areaTemp(o.hass, devs, r.area) : null,
|
||||
disp.tempMin,
|
||||
disp.tempMax,
|
||||
fillColorsOf(o.cfg?.settings),
|
||||
)
|
||||
: null;
|
||||
if (fillC) {
|
||||
cls += ' filled';
|
||||
parts.push(`--room-fill:${fillC}`, `--room-fill-op:${(0.3 * disp.opacity).toFixed(3)}`);
|
||||
parts.push(`--room-fill:${fillC.c}`, `--room-fill-op:${fillC.a.toFixed(3)}`);
|
||||
} else {
|
||||
parts.push('--room-fill:transparent', '--room-fill-op:0');
|
||||
}
|
||||
|
||||
@@ -874,6 +874,11 @@ export const cardStyles = css`
|
||||
.rrow .ract:hover { color: var(--hp-txt); }
|
||||
.rrow .ract.del:hover { color: #ff7a5c; }
|
||||
|
||||
.gsrow .gsl {
|
||||
min-width: 150px;
|
||||
font-size: 12.5px;
|
||||
color: var(--hp-muted);
|
||||
}
|
||||
.dialogwrap {
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
|
||||
+33
-1
@@ -4,7 +4,7 @@ import {
|
||||
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
|
||||
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount,
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle,
|
||||
} from '../test-build/logic.js';
|
||||
import {
|
||||
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
||||
@@ -444,3 +444,35 @@ test('snapToWall: the angle is normalized to [-90, 90) so opposite edge directio
|
||||
const v = snapToWall([10.3, 5], a, 1); // vertical wall
|
||||
assert.ok(v.angle >= -90 && v.angle < 90);
|
||||
});
|
||||
|
||||
test('fillColorsOf: defaults, merge, malformed entries dropped', () => {
|
||||
const d = fillColorsOf({});
|
||||
assert.equal(d.light_on.c, '#ffd45c');
|
||||
const o = fillColorsOf({ fill_colors: { light_on: { c: '#ff0000', a: 0.5 }, temp_hot: { c: 'javascript:x', a: 9 } } });
|
||||
assert.equal(o.light_on.c, '#ff0000');
|
||||
assert.equal(o.light_on.a, 0.5);
|
||||
assert.equal(o.temp_hot.c, '#ffd45c'); // malformed hex → default
|
||||
assert.equal(o.temp_hot.a, 1); // clamped
|
||||
});
|
||||
|
||||
test('lerpColor: endpoints and midpoint', () => {
|
||||
assert.equal(lerpColor('#000000', '#ffffff', 0), '#000000');
|
||||
assert.equal(lerpColor('#000000', '#ffffff', 1), '#ffffff');
|
||||
assert.equal(lerpColor('#000000', '#ffffff', 0.5), '#808080');
|
||||
assert.equal(lerpColor('#000000', '#ffffff', -5), '#000000'); // clamped
|
||||
});
|
||||
|
||||
test('roomFillStyle: palette-driven fills, lqi gradient between custom endpoints', () => {
|
||||
const colors = fillColorsOf({ fill_colors: { lqi_low: { c: '#000000', a: 0.1 }, lqi_high: { c: '#ffffff', a: 0.3 } } });
|
||||
assert.equal(roomFillStyle('lqi', 110, 'none', null, 20, 25, colors).c, '#808080'); // mid of 40..180
|
||||
assert.equal(roomFillStyle('lqi', null, 'none', null, 20, 25, colors), null);
|
||||
assert.deepEqual(roomFillStyle('light', null, 'on', null, 20, 25, colors), colors.light_on);
|
||||
assert.deepEqual(roomFillStyle('temp', 18, 'none', 18, 20, 25, colors), colors.temp_cold);
|
||||
assert.equal(roomFillStyle('none', 100, 'on', 22, 20, 25, colors), null);
|
||||
});
|
||||
|
||||
test('spaceDisplayOf: show_lqi tri-state (null = follow the card option)', () => {
|
||||
assert.equal(spaceDisplayOf({}).showLqi, null);
|
||||
assert.equal(spaceDisplayOf({ settings: { show_lqi: false } }).showLqi, false);
|
||||
assert.equal(spaceDisplayOf({ settings: { show_lqi: true } }).showLqi, true);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user