mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
feat v1.15.0: temperature room fill (blue/green/yellow) with editable comfort bounds
- fill_mode 'temp': below comfort → #4fc3f7, inside → #66d17a, above → #ffd45c; bounds default 20-25°C, editable in the space dialog (visible only for this mode), swapped bounds tolerated; rooms without a temperature reading unfilled - areaTemp(): average of the area devices' temperatures - tests: roomFillColor temp bands + bounds, spaceDisplayOf defaults, areaTemp, backend schema (fill_mode temp + temp_min/temp_max); smoke_temp_fill.mjs - TESTING.md updated in the same commit (policy)
This commit is contained in:
@@ -309,3 +309,15 @@ export function areaLights(hass: any, devices: { area: string; entities: string[
|
||||
}
|
||||
return seen ? 'off' : 'none';
|
||||
}
|
||||
|
||||
/** Average temperature across the area's devices (null when nothing reports one). */
|
||||
export function areaTemp(hass: any, devices: { area: string; entities: string[] }[], area: string): number | null {
|
||||
const vals: number[] = [];
|
||||
for (const d of devices) {
|
||||
if (d.area !== area) continue;
|
||||
const t = tempFor(hass, d.entities);
|
||||
if (t != null) vals.push(t);
|
||||
}
|
||||
if (!vals.length) return null;
|
||||
return Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 10) / 10;
|
||||
}
|
||||
|
||||
+27
-5
@@ -14,9 +14,10 @@ import {
|
||||
import {
|
||||
lqiColor, snapToGrid, segKey as segKeyOf, samePoint, pointInPolygon, markerIdForBinding,
|
||||
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
||||
spaceDisplayOf, roomFillColor, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, type SpaceDisplay,
|
||||
spaceDisplayOf, roomFillColor, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
||||
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
||||
} from './logic';
|
||||
import { buildDevices, lqiFor, tempFor, areaLights } from './devices';
|
||||
import { buildDevices, lqiFor, tempFor, areaLights, areaTemp } from './devices';
|
||||
import type {
|
||||
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
|
||||
} from './types';
|
||||
@@ -24,7 +25,7 @@ import './editor';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.14.0';
|
||||
const CARD_VERSION = '1.15.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';
|
||||
@@ -127,7 +128,9 @@ class HouseplanCard extends LitElement {
|
||||
showNames: boolean;
|
||||
roomColor: string;
|
||||
roomOpacity: number; // 0..1
|
||||
fillMode: 'none' | 'lqi' | 'light';
|
||||
fillMode: 'none' | 'lqi' | 'light' | 'temp';
|
||||
tempMin: number;
|
||||
tempMax: number;
|
||||
busy: boolean;
|
||||
} | null = null;
|
||||
private _keyHandler = (e: KeyboardEvent) => this._onKey(e);
|
||||
@@ -1458,6 +1461,7 @@ class HouseplanCard extends LitElement {
|
||||
source: sp.plan_url ? 'file' : 'draw', orientation: 'landscape',
|
||||
showBorders: disp.showBorders, showNames: disp.showNames,
|
||||
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
|
||||
tempMin: disp.tempMin, tempMax: disp.tempMax,
|
||||
busy: false,
|
||||
};
|
||||
} else {
|
||||
@@ -1466,6 +1470,7 @@ class HouseplanCard extends LitElement {
|
||||
source: 'file', orientation: 'landscape',
|
||||
showBorders: false, showNames: false,
|
||||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
|
||||
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
||||
busy: false,
|
||||
};
|
||||
}
|
||||
@@ -1547,6 +1552,8 @@ class HouseplanCard extends LitElement {
|
||||
room_color: d.roomColor,
|
||||
room_opacity: d.roomOpacity,
|
||||
fill_mode: d.fillMode,
|
||||
temp_min: Math.min(d.tempMin, d.tempMax),
|
||||
temp_max: Math.max(d.tempMin, d.tempMax),
|
||||
};
|
||||
await this._saveConfigNow();
|
||||
this._spaceDialog = null;
|
||||
@@ -1635,6 +1642,7 @@ class HouseplanCard extends LitElement {
|
||||
source: 'file', orientation: 'landscape',
|
||||
showBorders: false, showNames: false,
|
||||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
|
||||
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
||||
busy: false,
|
||||
};
|
||||
}
|
||||
@@ -1921,6 +1929,9 @@ class HouseplanCard extends LitElement {
|
||||
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,
|
||||
)
|
||||
: null;
|
||||
if (fillC) st.push(`--room-fill:${fillC}`, `--room-fill-op:${(0.3 * disp.opacity).toFixed(3)}`);
|
||||
@@ -2362,10 +2373,21 @@ class HouseplanCard extends LitElement {
|
||||
<label>${this._t('space.fill_label')}</label>
|
||||
<select class="areasel"
|
||||
@change=${(e: Event) => (this._spaceDialog = { ...d, fillMode: (e.target as HTMLSelectElement).value as any })}>
|
||||
${[['none', 'fill.none'], ['lqi', 'fill.lqi'], ['light', 'fill.light']].map(
|
||||
${[['none', 'fill.none'], ['lqi', 'fill.lqi'], ['light', 'fill.light'], ['temp', 'fill.temp']].map(
|
||||
([v, k]) => html`<option value=${v} ?selected=${d.fillMode === v}>${this._t(k as any)}</option>`,
|
||||
)}
|
||||
</select>
|
||||
${d.fillMode === 'temp'
|
||||
? html`<div class="colorrow">
|
||||
<span class="opl">${this._t('space.temp_min')}</span>
|
||||
<input class="namein tempin" type="number" step="0.5" .value=${String(d.tempMin)}
|
||||
@input=${(e: Event) => (this._spaceDialog = { ...d, tempMin: Number((e.target as HTMLInputElement).value) })} />
|
||||
<span class="opl">${this._t('space.temp_max')}</span>
|
||||
<input class="namein tempin" type="number" step="0.5" .value=${String(d.tempMax)}
|
||||
@input=${(e: Event) => (this._spaceDialog = { ...d, tempMax: Number((e.target as HTMLInputElement).value) })} />
|
||||
<span class="opv">°C</span>
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="row">
|
||||
${d.mode === 'edit'
|
||||
|
||||
+4
-1
@@ -166,5 +166,8 @@
|
||||
"space.orientation": "Canvas",
|
||||
"orient.landscape": "Landscape",
|
||||
"orient.portrait": "Portrait",
|
||||
"orient.square": "Square"
|
||||
"orient.square": "Square",
|
||||
"fill.temp": "Temperature (blue = cold, green = comfy, yellow = hot)",
|
||||
"space.temp_min": "Comfort from",
|
||||
"space.temp_max": "to"
|
||||
}
|
||||
|
||||
+4
-1
@@ -166,5 +166,8 @@
|
||||
"space.orientation": "Холст",
|
||||
"orient.landscape": "Альбомный",
|
||||
"orient.portrait": "Портретный",
|
||||
"orient.square": "Квадрат"
|
||||
"orient.square": "Квадрат",
|
||||
"fill.temp": "По температуре (голубая = холодно, зелёная = комфорт, жёлтая = жарко)",
|
||||
"space.temp_min": "Комфорт от",
|
||||
"space.temp_max": "до"
|
||||
}
|
||||
|
||||
+21
-2
@@ -183,7 +183,7 @@ export function subst(s: string, vars?: Record<string, string | number>): string
|
||||
|
||||
// ---------------- room fills & colors ----------------
|
||||
|
||||
export type RoomFillMode = 'none' | 'lqi' | 'light';
|
||||
export type RoomFillMode = 'none' | 'lqi' | 'light' | 'temp';
|
||||
|
||||
/** Per-space display settings with their defaults resolved. */
|
||||
export interface SpaceDisplay {
|
||||
@@ -192,10 +192,14 @@ export interface SpaceDisplay {
|
||||
color: string; // hex like #3ea6ff
|
||||
opacity: number; // 0..1 — applied to borders, names and fills
|
||||
fill: RoomFillMode;
|
||||
tempMin: number; // comfort range lower bound, °C
|
||||
tempMax: number; // comfort range upper bound, °C
|
||||
}
|
||||
|
||||
export const DEFAULT_ROOM_COLOR = '#3ea6ff';
|
||||
export const DEFAULT_ROOM_OPACITY = 0.55;
|
||||
export const DEFAULT_TEMP_MIN = 20;
|
||||
export const DEFAULT_TEMP_MAX = 25;
|
||||
|
||||
/** Resolve a space's display settings; spaces without a plan default to visible markup. */
|
||||
export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
|
||||
@@ -206,7 +210,9 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
|
||||
showNames: s.show_names ?? noPlan,
|
||||
color: typeof s.room_color === 'string' && /^#[0-9a-f]{6}$/i.test(s.room_color) ? s.room_color : DEFAULT_ROOM_COLOR,
|
||||
opacity: typeof s.room_opacity === 'number' ? Math.min(1, Math.max(0, s.room_opacity)) : DEFAULT_ROOM_OPACITY,
|
||||
fill: s.fill_mode === 'lqi' || s.fill_mode === 'light' ? s.fill_mode : 'none',
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -221,11 +227,24 @@ export function roomFillColor(
|
||||
mode: RoomFillMode,
|
||||
lqi: number | null,
|
||||
lights: 'on' | 'off' | 'none',
|
||||
temp?: number | null,
|
||||
tempMin: number = DEFAULT_TEMP_MIN,
|
||||
tempMax: number = DEFAULT_TEMP_MAX,
|
||||
): string | null {
|
||||
if (mode === 'lqi') return lqi == null ? null : lqiColor(lqi);
|
||||
if (mode === 'light') {
|
||||
if (lights === 'none') return null;
|
||||
return lights === 'on' ? '#ffd45c' : '#9aa0a6';
|
||||
}
|
||||
if (mode === 'temp') {
|
||||
// blue below the comfort range, green inside, yellow above; no reading → no fill.
|
||||
// Bounds are swapped automatically if entered in the wrong order.
|
||||
if (temp == null) return null;
|
||||
const lo = Math.min(tempMin, tempMax);
|
||||
const hi = Math.max(tempMin, tempMax);
|
||||
if (temp < lo) return '#4fc3f7';
|
||||
if (temp > hi) return '#ffd45c';
|
||||
return '#66d17a';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -440,6 +440,7 @@ export const cardStyles = css`
|
||||
cursor: pointer;
|
||||
}
|
||||
.colorrow input[type='range'] { flex: 1; }
|
||||
.colorrow .tempin { width: 70px; flex: none; }
|
||||
.colorrow .opl { color: var(--hp-muted); font-size: 12px; }
|
||||
.colorrow .opv { font-size: 12px; min-width: 34px; text-align: right; }
|
||||
.planrow {
|
||||
|
||||
Reference in New Issue
Block a user