feat v1.41.0: kiosk mode for wall tablets and TVs

- kiosk: true — header hidden, editors hard-blocked, full-height stage;
  full View interactivity preserved (live states, glow, taps, locks)
- swipe between spaces at 1:1 (swipeTarget pure helper, wrap + dots
  indicator), pan wins while zoomed, double tap resets zoom
- cycle: N auto-carousel with 60 s pause after any touch (TV/burn-in)
- per-SCREEN size multipliers (icons x0.5-3, room-card font) in
  localStorage via a 3 s long-press popover; clampScale helper
- GUI editor fields, README wall-tablet recipe, +2 unit tests (111),
  smoke_kiosk.mjs (12 checks); TESTING/CHANGELOG same-commit
This commit is contained in:
Matysh
2026-07-23 21:12:58 +03:00
parent 82eed100b2
commit 1d6ca968e8
18 changed files with 529 additions and 71 deletions
+17
View File
@@ -8,6 +8,23 @@
---
## Wall tablet / TV (kiosk mode)
Add the card to a dedicated dashboard with a **panel view** and set `kiosk: true`
(or tick "Wall device (kiosk) mode" in the card editor):
```yaml
type: custom:houseplan-card
kiosk: true
cycle: 0 # seconds between auto space switches, 0 = off (nice for TVs)
```
No header, no editors — just the live plan. Swipe to change floors (at 1:1),
pinch to zoom, double-tap to reset. Long-press an empty spot for 3 seconds to
tune icon and text sizes for THIS screen (saved per device). To hide Home
Assistant's own header use the companion app's kiosk settings or the
[kiosk-mode](https://github.com/NemesisRE/kiosk-mode) plugin.
## What it is and why
House Plan shows your smart home the way it actually looks — on a floor plan. Instead of long lists of entities, you see rooms and devices in their real places: where the leak is, what the temperature is in the kids' room, whether the light is on in the hallway, whether the gate is open.
+1 -1
View File
@@ -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.40.2"
VERSION = "1.41.0"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.40.2"
"version": "1.41.0"
}
+73
View File
@@ -0,0 +1,73 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c0 = window.__card;
// создать киоск-экземпляр
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
c.hass = c0.hass;
document.body.appendChild(c);
c.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
await new Promise((r) => setTimeout(r, 400));
c.hass = { ...c0.hass };
await c.updateComplete;
const sr = () => c.shadowRoot || c.renderRoot;
// 1) шапка скрыта
const hdr = sr().querySelector('.hdr');
out.headerHidden = !hdr || getComputedStyle(hdr).display === 'none';
// 2) редакторы заблокированы
c._setMode('plan'); await c.updateComplete;
out.editorsBlocked = c._mode === 'view';
// 3) свайп при 1:1 переключает пространство (и точки показываются)
const s0 = c._space;
const stage = sr().querySelector('.stage');
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 1, clientX: 600, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 1, clientX: 450, clientY: 305 }));
await c.updateComplete;
out.swipeSwitches = c._space !== s0;
out.dotsShown = !!sr().querySelector('.kioskdots');
// 4) при зуме свайп не переключает
c._zoom = 2; const s1 = c._space;
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 2, clientX: 600, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 2, clientX: 450, clientY: 305 }));
await c.updateComplete;
out.noSwipeZoomed = c._space === s1;
// 5) двойной тап сбрасывает зум
c._lastTap = Date.now() - 100;
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 3, clientX: 500, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 3, clientX: 501, clientY: 300 }));
await c.updateComplete;
out.dblTapResets = c._zoom === 1;
// 6) локальный множитель иконок применяется
c._saveKioskScale({ icon: 2 }); await c.updateComplete;
const dl = sr().querySelector('.devlayer');
const size1 = dl.getAttribute('style');
c._saveKioskScale({ icon: 1 }); await c.updateComplete;
const size2 = sr().querySelector('.devlayer').getAttribute('style');
const v1 = parseFloat(size1.match(/--icon-size:([\d.]+)/)[1]);
const v2 = parseFloat(size2.match(/--icon-size:([\d.]+)/)[1]);
out.iconScaleWorks = Math.abs(v1 / v2 - 2) < 0.01;
out.persisted = JSON.parse(localStorage.getItem('houseplan_card_kiosk_v1')).icon === 1;
// 7) попап настроек экрана открывается (прямой вызов; долгое нажатие проверено таймером)
c._kioskDialog = true; await c.updateComplete;
out.dialogRenders = !!sr().querySelector('.dialog input[type="range"]');
c._kioskDialog = false;
// 8) карусель: тик двигает пространство, пауза после касания работает
const cs = c._space;
c._config = { ...c._config, cycle: 1 };
c._cyclePausedUntil = 0;
c._cycleTick(); await c.updateComplete;
out.cycleAdvances = c._space !== cs;
c._cyclePausedUntil = Date.now() + 60000;
const cs2 = c._space;
c._cycleTick(); await c.updateComplete;
out.cyclePaused = c._space === cs2;
// 9) обычная карточка (не киоск): шапка на месте, свайпа нет
const sr0 = c0.shadowRoot || c0.renderRoot;
out.normalHeader = !!sr0.querySelector('.hdr') && getComputedStyle(sr0.querySelector('.hdr')).display !== 'none';
c.remove();
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
File diff suppressed because one or more lines are too long
+61 -19
View File
File diff suppressed because one or more lines are too long
+14
View File
@@ -1,5 +1,19 @@
# Changelog
## v1.41.0 — 2026-07-24 (kiosk mode for wall devices)
- New card option **`kiosk: true`** (also in the GUI editor): the full View
experience — live states, glow, lamp taps, info cards, locks — with no
header and no editors at all, sized for a wall tablet or TV.
- **Swipe** left/right switches spaces at 1:1 zoom (wrap-around, dots
indicator); while zoomed the gesture pans as usual; **double tap** resets
zoom. **`cycle: N`** auto-advances spaces every N seconds (pauses for a
minute after any touch) — for TVs and against OLED burn-in.
- **Per-screen sizes**: long-press an empty spot (3 s) to open a popover with
icon and room-card-font multipliers, stored in this device's localStorage —
every tablet/TV tunes itself once.
- Recipe for a full-screen wall dashboard (panel view + kiosk-mode/companion
settings) added to the README.
## v1.40.2 — 2026-07-24
- Default icon rules: smart speakers (Yandex/Alice stations, «колонка»,
generic speakers) now get **mdi:speaker**; mdi:soundbar stays for actual
+7
View File
@@ -140,6 +140,13 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
(explicit ripple color still wins); off/white lights unchanged [auto]
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
- [ ] Kiosk mode (v1.41.0): kiosk: true hides the whole header, blocks every
editor (admins incl.), full-height stage; swipe left/right switches
spaces at 1:1 (dots indicator, wraps), never while zoomed; double tap
resets zoom; long-press (3 s) on empty plan opens the per-screen size
popover (icon ×0.53, room-card font; localStorage per device);
cycle: N auto-advances spaces with a 60 s pause after any touch;
manual: walk the real wall tablet [auto+manual]
- [ ] Room link icon (v1.40.1): clicking empty room space in View does
nothing (default cursor); an open-in-new icon after the room name (rooms
with an HA area, View only) navigates to the area; no icon in editors or
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.40.2",
"version": "1.41.0",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+4
View File
@@ -67,6 +67,8 @@ class HouseplanCardEditor extends LitElement {
{ name: 'show_temperature', selector: { boolean: {} } },
{ name: 'live_states', selector: { boolean: {} } },
{ name: 'show_signal', selector: { boolean: {} } },
{ name: 'kiosk', selector: { boolean: {} } },
{ name: 'cycle', selector: { number: { min: 0, max: 3600, step: 5, mode: 'box' } } },
];
}
@@ -82,6 +84,8 @@ class HouseplanCardEditor extends LitElement {
show_temperature: t(L, 'editor.show_temperature'),
live_states: t(L, 'editor.live_states'),
show_signal: t(L, 'editor.show_signal'),
kiosk: t(L, 'editor.kiosk'),
cycle: t(L, 'editor.cycle'),
};
return html`<ha-form
.hass=${this.hass}
+141 -7
View File
@@ -14,7 +14,7 @@ import {
import {
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide,
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale,
snapToWall, openingAmount,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
@@ -32,11 +32,12 @@ import './space-card';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.40.2';
const CARD_VERSION = '1.41.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';
const LS_NAV = 'houseplan_card_nav_v1'; // last space + editor mode (owner: restore where you were)
const LS_KIOSK = 'houseplan_card_kiosk_v1'; // per-SCREEN size multipliers (each wall tablet differs)
const NORM_W = 1000; // width of the render space for normalized configs
const GRID_N = 240; // grid points across the plan width (half the previous step; old nodes are a subset of the new ones, positions are preserved)
@@ -102,6 +103,28 @@ class HouseplanCard extends LitElement {
}
/** Legacy alias: markup machinery is active exactly in plan mode. */
private get _kiosk(): boolean {
return !!this._config?.kiosk;
}
private _showKioskDots(): void {
this._kioskDots = true;
clearTimeout(this._kioskDotsTimer);
this._kioskDotsTimer = window.setTimeout(() => (this._kioskDots = false), 2500);
}
/** Kiosk auto-carousel: advance to the next space every `cycle` seconds. */
private _cycleTick(): void {
if (!this._kiosk || !(Number(this._config?.cycle) > 0)) return;
if (Date.now() >= this._cyclePausedUntil && this._model.length > 1 && this._zoom <= 1.001) {
const ids = this._model.map((m) => m.id);
const i = ids.indexOf(this._space);
this._space = ids[(i + 1) % ids.length];
this._restoreZoom();
this._showKioskDots();
}
}
/** Any edit mode is active (plan / devices / decor). */
private get _editing(): boolean {
return this._mode === 'plan' || this._mode === 'devices' || this._mode === 'decor';
@@ -207,6 +230,16 @@ class HouseplanCard extends LitElement {
private _keyHandler = (e: KeyboardEvent) => this._onKey(e);
private _hashApplied = false;
private _navApplied = false; // the saved space was restored (or the user navigated)
// ---- kiosk (wall device) mode ----
private _kioskScale: { icon: number; font: number } = { icon: 1, font: 1 };
private _kioskDialog = false;
private _kioskDots = false;
private _kioskDotsTimer?: number;
private _kioskHoldTimer?: number;
private _cycleTimer?: number;
private _cyclePausedUntil = 0;
private _swipeStart: { x: number; y: number; id: number } | null = null;
private _lastTap = 0;
/** Deep-link: read `#space=<id>` from the URL (used by embedded houseplan-space-card). */
private _hashSpace(): string {
const m = /(?:^|[#&])space=([^&]+)/.exec(window.location.hash || '');
@@ -251,6 +284,8 @@ class HouseplanCard extends LitElement {
_decorDraft: { state: true },
_decorSel: { state: true },
_decorTextDialog: { state: true },
_kioskDialog: { state: true },
_kioskDots: { state: true },
_areaSel: { state: true },
_nameSel: { state: true },
_roomDialog: { state: true },
@@ -267,11 +302,18 @@ class HouseplanCard extends LitElement {
public connectedCallback(): void {
super.connectedCallback();
window.addEventListener('keydown', this._keyHandler);
if (this._config?.kiosk && Number(this._config?.cycle) > 0) {
clearInterval(this._cycleTimer);
this._cycleTimer = window.setInterval(() => this._cycleTick(), Number(this._config.cycle) * 1000);
}
window.addEventListener('hashchange', this._onHashChange);
}
public disconnectedCallback(): void {
window.removeEventListener('keydown', this._keyHandler);
clearInterval(this._cycleTimer);
clearTimeout(this._kioskDotsTimer);
clearTimeout(this._kioskHoldTimer);
window.removeEventListener('hashchange', this._onHashChange);
clearTimeout(this._holdTimer);
this._roViewport?.disconnect();
@@ -379,6 +421,12 @@ class HouseplanCard extends LitElement {
} catch {
this._zoomBySpace = {};
}
try {
const ks = JSON.parse(localStorage.getItem(LS_KIOSK) || 'null');
this._kioskScale = { icon: clampScale(ks?.icon), font: clampScale(ks?.font) };
} catch {
/* defaults */
}
// instant render from cache (stale-while-revalidate): show the plan and icons
// right away, without waiting for the server response — fresh data will load in the background.
try {
@@ -394,8 +442,9 @@ class HouseplanCard extends LitElement {
else if (nav?.space && this._model.find((sp) => sp.id === nav.space)) { this._space = nav.space; this._navApplied = true; }
else if (config.default_floor) this._space = config.default_floor;
else if (!this._model.find((sp) => sp.id === this._space)) this._space = this._model[0]?.id || this._space;
// reopenning the tab lands you in the same editor you left (admins only)
if (nav?.mode && nav.mode !== 'view' && this._canEdit) this._mode = nav.mode;
// reopenning the tab lands you in the same editor you left (admins only);
// kiosk screens always stay in View
if (nav?.mode && nav.mode !== 'view' && this._canEdit && !config.kiosk) this._mode = nav.mode;
}
} catch {
/* ignore */
@@ -1011,6 +1060,23 @@ class HouseplanCard extends LitElement {
}
private _stagePointerDown(ev: PointerEvent): void {
if (this._kiosk) {
this._cyclePausedUntil = Date.now() + 60000;
if (this._pointers.size === 0) {
this._swipeStart = { x: ev.clientX, y: ev.clientY, id: ev.pointerId };
// long-press on EMPTY stage opens the per-screen size popover
if (!(ev.target as HTMLElement).closest?.('.dev, .roomlabel, .oplock')) {
clearTimeout(this._kioskHoldTimer);
this._kioskHoldTimer = window.setTimeout(() => {
this._kioskDialog = true;
this._swipeStart = null;
}, 3000);
}
} else {
this._swipeStart = null; // second finger = pinch, not a swipe
clearTimeout(this._kioskHoldTimer);
}
}
// do not interfere with icon dragging and markup drawing
if (this._drag || this._markup) return;
if (this._mode === 'devices' && (ev.target as HTMLElement).closest('.dev')) return;
@@ -1077,6 +1143,31 @@ class HouseplanCard extends LitElement {
}
private _stagePointerUp(ev: PointerEvent): void {
if (this._kiosk) {
clearTimeout(this._kioskHoldTimer);
const ss = this._swipeStart;
this._swipeStart = null;
if (ss && ss.id === ev.pointerId) {
const dx = ev.clientX - ss.x;
const dy = ev.clientY - ss.y;
// double tap (no movement) resets the zoom to 1:1
if (Math.abs(dx) + Math.abs(dy) < 8) {
const now = Date.now();
if (now - this._lastTap < 350) this._resetZoom();
this._lastTap = now;
}
const target = swipeTarget(dx, dy, this._zoom, this._model.map((m) => m.id), this._space);
if (target) {
this._space = target;
this._selId = null;
this._restoreZoom();
this._saveNav();
this._suppressClick = true;
setTimeout(() => (this._suppressClick = false), 0);
this._showKioskDots();
}
}
}
if (this._decorDraft?.pid === ev.pointerId) {
this._decorCommitDraft();
return;
@@ -1218,6 +1309,7 @@ class HouseplanCard extends LitElement {
}
private _setMode(mode: 'view' | 'plan' | 'devices' | 'decor'): void {
if (this._kiosk && mode !== 'view') return; // wall devices never edit
if (this._mode === mode) return;
if ((mode === 'plan' || mode === 'decor') && !this._norm) {
this._showToast(this._t('toast.markup_needs_server'));
@@ -3045,6 +3137,42 @@ class HouseplanCard extends LitElement {
</div>`;
}
private _saveKioskScale(patch: Partial<{ icon: number; font: number }>): void {
this._kioskScale = { ...this._kioskScale, ...patch };
try {
localStorage.setItem(LS_KIOSK, JSON.stringify(this._kioskScale));
} catch {
/* ignore */
}
this.requestUpdate();
}
/** Per-SCREEN size settings (wall tablets differ) — stored locally. */
private _renderKioskDialog(): TemplateResult {
const k = this._kioskScale;
const row = (key: 'icon' | 'font', label: string) => html`<label>${label}</label>
<div class="colorrow">
<input type="range" min="50" max="300" step="5" .value=${String(Math.round(k[key] * 100))}
@input=${(e: Event) => this._saveKioskScale({ [key]: Number((e.target as HTMLInputElement).value) / 100 })} />
<span class="opv">${Math.round(k[key] * 100)}%</span>
</div>`;
return html`<div class="menuwrap dialogwrap" @click=${() => (this._kioskDialog = false)}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:tablet"></ha-icon>${this._t('kiosk.title')}</div>
<div class="body">
<div class="rhint">${this._t('kiosk.hint')}</div>
${row('icon', this._t('kiosk.icon_scale'))}
${row('font', this._t('kiosk.font_scale'))}
</div>
<div class="row">
<button class="btn ghost" @click=${() => this._saveKioskScale({ icon: 1, font: 1 })}>${this._t('gs.reset')}</button>
<span class="spacer"></span>
<button class="btn on" @click=${() => (this._kioskDialog = false)}>${this._t('btn.close')}</button>
</div>
</div>
</div>`;
}
// ================= render =================
protected render(): TemplateResult | typeof nothing {
@@ -3081,7 +3209,7 @@ class HouseplanCard extends LitElement {
return html`
<ha-card>
<div class="hdr">
<div class="hdr ${this._kiosk ? 'kioskhide' : ''}">
<div class="head">
<div class="title">
<ha-icon icon="mdi:home-city"></ha-icon>
@@ -3149,7 +3277,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:calc(100dvh - 118px)"
style="height:${this._kiosk ? '100dvh' : 'calc(100dvh - 118px)'}"
@click=${(e: MouseEvent) => this._markupClick(e)}
@wheel=${(e: WheelEvent) => this._onWheel(e)}
@pointerdown=${(e: PointerEvent) => this._stagePointerDown(e)}
@@ -3249,7 +3377,7 @@ class HouseplanCard extends LitElement {
${this._markup ? this._renderMarkupLayer(vb) : nothing}
${this._renderOpenings(disp)}
</svg>
<div class="devlayer" style="--icon-size:${((iconPct * vb[2]) / view.w).toFixed(3)}cqw">
<div class="devlayer" style="--icon-size:${((iconPct * vb[2] * (this._kiosk ? this._kioskScale.icon : 1)) / view.w).toFixed(3)}cqw;--rl-font:${this._kiosk ? this._kioskScale.font : 1}">
${devs.map((d) => this._renderDevice(d, view, showLqi))}
${this._renderOpeningLocks(view)}
${disp.showNames || this._markup
@@ -3288,6 +3416,12 @@ class HouseplanCard extends LitElement {
: nothing}
</div>`
: nothing}
${this._kiosk && this._kioskDots && this._model.length > 1
? html`<div class="kioskdots">
${this._model.map((m) => html`<span class="kdot ${m.id === this._space ? 'on' : ''}"></span>`)}
</div>`
: nothing}
${this._kioskDialog ? this._renderKioskDialog() : nothing}
${this._toast ? html`<div class="toast">${this._toast}</div>` : nothing}
</ha-card>
`;
+7 -1
View File
@@ -301,5 +301,11 @@
"marker.show_entities": "Show entities",
"marker.show_entities_tip": "Adds not only devices to the list, but all their entities too",
"marker.pick_ph": "Choose a device…",
"room.open_area": "Open the HA area"
"room.open_area": "Open the HA area",
"kiosk.title": "This screen's sizes",
"kiosk.hint": "Stored on this device only — every wall tablet or TV can have its own comfortable sizes.",
"kiosk.icon_scale": "Device icon size",
"kiosk.font_scale": "Room card text size",
"editor.kiosk": "Wall device (kiosk) mode",
"editor.cycle": "Auto-switch spaces every N seconds (kiosk, 0 = off)"
}
+7 -1
View File
@@ -301,5 +301,11 @@
"marker.show_entities": "Отображать сущности",
"marker.show_entities_tip": "Добавляет в список не только устройства, но и все их сущности",
"marker.pick_ph": "Выберите устройство…",
"room.open_area": "Открыть зону в HA"
"room.open_area": "Открыть зону в HA",
"kiosk.title": "Размеры на этом экране",
"kiosk.hint": "Хранится только на этом устройстве — у каждого настенного планшета или ТВ свои удобные размеры.",
"kiosk.icon_scale": "Размер значков устройств",
"kiosk.font_scale": "Размер текста карточек комнат",
"editor.kiosk": "Режим настенного устройства (киоск)",
"editor.cycle": "Автосмена пространств каждые N секунд (киоск, 0 = выкл)"
}
+24
View File
@@ -965,6 +965,30 @@ export function outlineWithout(poly: number[][], cuts: number[][], eps = 1e-6):
return cutSegments(edges, cuts, eps);
}
// ---------------- kiosk gestures ----------------
/**
* Kiosk swipe: which neighbouring space a horizontal gesture selects.
* Only fires at 1:1 zoom (owner's decision when zoomed the gesture pans),
* needs a mostly-horizontal move of at least minPx. Wraps around.
*/
export function swipeTarget(
dx: number, dy: number, zoom: number, spaceIds: string[], current: string, minPx = 60,
): string | null {
if (zoom > 1.001 || spaceIds.length < 2) return null;
if (Math.abs(dx) < minPx || Math.abs(dx) < Math.abs(dy) * 1.5) return null;
const i = spaceIds.indexOf(current);
if (i < 0) return null;
const n = spaceIds.length;
return dx < 0 ? spaceIds[(i + 1) % n] : spaceIds[(i - 1 + n) % n];
}
/** Clamp a per-screen size multiplier (icons / room-card font). */
export function clampScale(v: unknown, def = 1): number {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? Math.min(3, Math.max(0.5, n)) : def;
}
// ---------------- alignment guides ----------------
export interface AlignGuide {
+20 -1
View File
@@ -364,7 +364,7 @@ export const cardStyles = css`
pointer-events: none; /* draggable only in plan mode (rule below) */
position: absolute;
transform: translate(-50%, -50%);
font-size: calc(var(--icon-size, 2.5cqw) * 0.5 * var(--rl-scale, 1));
font-size: calc(var(--icon-size, 2.5cqw) * 0.5 * var(--rl-scale, 1) * var(--rl-font, 1));
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
@@ -505,6 +505,25 @@ export const cardStyles = css`
color: #4bd28f;
border-color: #4bd28f;
}
.hdr.kioskhide { display: none; }
.kioskdots {
position: absolute;
left: 50%;
bottom: 14px;
transform: translateX(-50%);
display: flex;
gap: 8px;
z-index: 5;
pointer-events: none;
}
.kdot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--hp-muted);
opacity: 0.55;
}
.kdot.on { background: var(--hp-accent); opacity: 1; }
.measurelabel {
position: absolute;
transform: translate(12px, -150%);
+5 -1
View File
@@ -108,5 +108,9 @@ export interface CardConfig {
live_states?: boolean;
show_signal?: boolean;
language?: string; // 'en' | 'ru' | '' (auto — HA profile)
tap_action?: string; // 'info' (default) | 'more-info' | 'toggle'
tap_action?: string; // legacy, ignored since v1.38.1
/** Wall-device (kiosk) mode: no header, no editors, swipe between spaces. */
kiosk?: boolean;
/** Kiosk auto-carousel: seconds between space switches, 0/undefined = off. */
cycle?: number;
}
+24
View File
@@ -9,6 +9,7 @@ import {
sharedBoundary, openZoneOf, distToSegment,
outlineWithout,
alignGuides, segmentAngle, is45,
swipeTarget, clampScale,
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
} from '../test-build/logic.js';
@@ -763,3 +764,26 @@ test('segmentAngle & is45', () => {
assert.ok(is45(44.8) && is45(45.3));
assert.ok(!is45(30) && !is45(52));
});
test('swipeTarget: kiosk swipe rules', () => {
const ids = ['f1', 'f2', 'garden'];
// влево → следующее, вправо → предыдущее, по кругу
assert.equal(swipeTarget(-100, 5, 1, ids, 'f1'), 'f2');
assert.equal(swipeTarget(100, 5, 1, ids, 'f1'), 'garden');
assert.equal(swipeTarget(-100, 0, 1, ids, 'garden'), 'f1');
// при зуме — не свайп
assert.equal(swipeTarget(-100, 0, 2, ids, 'f1'), null);
// короткий или диагональный жест — не свайп
assert.equal(swipeTarget(-30, 0, 1, ids, 'f1'), null);
assert.equal(swipeTarget(-80, 70, 1, ids, 'f1'), null);
// одно пространство — некуда
assert.equal(swipeTarget(-100, 0, 1, ['f1'], 'f1'), null);
});
test('clampScale', () => {
assert.equal(clampScale(2), 2);
assert.equal(clampScale(9), 3);
assert.equal(clampScale(0.1), 0.5);
assert.equal(clampScale('x'), 1);
assert.equal(clampScale(undefined, 1.5), 1.5);
});