Files
houseplan-card/src/houseplan-onboarding-runtime.ts
T
2026-09-05 16:04:54 +03:00

914 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { html, nothing, type TemplateResult } from 'lit';
import { classifyPlanFile, encodePlanFile, renderBackdropGuard } from './backdrop-pick';
import { hasTranslation, langOf, t, type I18nKey } from './i18n';
import './hp-help';
import {
DEFAULT_CUSTOM_FILL,
DEFAULT_ROOM_COLOR,
DEFAULT_ROOM_OPACITY,
DEFAULT_TEMP_MAX,
DEFAULT_TEMP_MIN,
SPACE_FILL_UI_MODES,
customFillOf,
spaceDisplayOf,
stageBgOf,
} from './logic';
import { bgModeOf, northDegOf } from './sun';
import {
createEmptySpaceConfig,
initialSpaceDisplayDraft,
switchSpacePlanSource,
touchSpaceDisplay,
} from './space-dialog';
import { collectSpaceMarkerDependencies } from './space-deletion';
import {
gridCellFieldToCm,
gridCellFieldValue,
newSpaceCellCm,
} from './grid-scale';
import { zeroWallStyleOf } from './zero-walls';
import type { HouseplanEditorHostPort } from './houseplan-editor-runtime';
const BUILD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
const CELL_CM_MIN = 0.1;
const CELL_CM_MAX = 1000;
const strictNumber = (value: string): number | null => {
if (!value.trim()) return null;
const parsed = Number(value.replace(',', '.'));
return Number.isFinite(parsed) ? parsed : null;
};
/**
* Space creation/import is part of empty-install onboarding, not an editor.
* It therefore has its own lazy boundary: normal View downloads neither this
* module nor the editor, while a new empty installation can create its first
* space without ever requesting the editor chunk.
*/
export const ONBOARDING_RUNTIME_FINGERPRINT = BUILD_FINGERPRINT;
export class HouseplanOnboardingRuntime {
public constructor(public readonly host: HouseplanEditorHostPort) {}
private _help(key: Extract<I18nKey, `${string}.help`>): TemplateResult | typeof nothing {
const ariaKey = `${key}.aria` as I18nKey;
const lang = langOf(this.host.hass, this.host._config?.language);
if (!hasTranslation(lang, key) || !hasTranslation(lang, ariaKey)) return nothing;
return html`<hp-help data-help-key=${key}
.text=${t(lang, key)} .ariaLabel=${t(lang, ariaKey)}></hp-help>`;
}
public _openSpaceDialog(mode: 'edit' | 'create', spaceId?: string): void {
if (!this.host._serverStorage || !this.host._serverCfg) {
this.host._showToast(this.host._t('toast.integration_missing'));
return;
}
if (mode === 'edit') {
const sp = this.host._serverCfg.spaces.find((space) => space.id === spaceId);
if (!sp) return;
const disp = spaceDisplayOf(sp);
const storedCustom = sp.settings?.custom_fill && typeof sp.settings.custom_fill === 'object'
? customFillOf(sp.settings.custom_fill) : null;
const dialogCustom = disp.fill === 'none'
? { ...(storedCustom || DEFAULT_CUSTOM_FILL), a: 0 }
: storedCustom;
this.host._spaceDialog = {
mode, spaceId, title: sp.title, planUrl: sp.plan_url || null, planFile: null,
source: sp.plan_url ? 'file' : 'draw',
showBorders: disp.showBorders, showNames: disp.showNames,
zeroWallStyle: zeroWallStyleOf(sp),
displayTouched: true,
hideDecor: disp.hideDecor, hideOpenings: disp.hideOpenings,
roomColor: disp.color, roomOpacity: disp.opacity,
fillMode: disp.fill === 'none' ? 'custom' : disp.fill,
customFill: dialogCustom,
glowEnabled: disp.glow,
bgColor: disp.bgColor,
bgMode: sp.settings?.bg_mode === 'static' || sp.settings?.bg_mode === 'daynight'
? sp.settings.bg_mode : null,
northDeg: northDegOf({}, sp.settings),
sunRays: typeof sp.settings?.sun_rays === 'boolean' ? sp.settings.sun_rays : null,
tempMin: disp.tempMin, tempMax: disp.tempMax,
showLqi: disp.showLqi ?? this.host._config?.show_signal ?? true,
cardFontScale: disp.cardFontScale,
labelTemp: disp.labelTemp, labelHum: disp.labelHum,
labelLqi: disp.labelLqi, labelLight: disp.labelLight,
cellCm: Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5,
cellCmInput: gridCellFieldValue(
Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5, this.host._imperial,
),
cellCmTouched: false,
busy: false,
};
return;
}
const cellCm = newSpaceCellCm(this.host._imperial);
this.host._spaceDialog = {
mode, title: '', planUrl: null, planFile: null,
...initialSpaceDisplayDraft(),
hideDecor: false, hideOpenings: false, zeroWallStyle: 'dashed',
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'custom',
customFill: { ...DEFAULT_CUSTOM_FILL, a: 0 },
glowEnabled: true,
bgColor: null,
bgMode: 'daynight', northDeg: null, sunRays: null,
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
showLqi: this.host._config?.show_signal ?? true,
cardFontScale: 1,
labelTemp: false, labelHum: false, labelLqi: false, labelLight: false,
cellCm,
cellCmInput: gridCellFieldValue(cellCm, this.host._imperial),
cellCmTouched: false,
busy: false,
};
}
public async _pickPlanFile(ev: Event): Promise<void> {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
if (!file || !this.host._spaceDialog) return;
// #39: re-selecting the same file after a guard decision must fire again.
input.value = '';
const classified = await classifyPlanFile(file);
if (classified.kind === 'reject') {
this.host._showToast(this.host._t('toast.plan_formats'));
return;
}
if (classified.kind === 'guard') {
this.host._backdropGuard = classified.state;
return;
}
const payload = await encodePlanFile(file, classified.ext, file.name);
if (!this.host._spaceDialog) return;
this.host._spaceDialog = { ...this.host._spaceDialog, planFile: payload };
}
public _renderBackdropGuard(): TemplateResult | typeof nothing {
return renderBackdropGuard(
this.host,
(payload) => {
if (this.host._spaceDialog) {
this.host._spaceDialog = { ...this.host._spaceDialog, planFile: payload };
}
},
() => { this.host._backdropGuard = null; },
this.host.hass,
) ?? nothing;
}
public _toggleServerPlans = async (): Promise<void> => {
const dialog = this.host._spaceDialog;
if (!dialog) return;
if (dialog.pickSaved) {
this.host._spaceDialog = { ...dialog, pickSaved: false };
return;
}
this.host._spaceDialog = { ...dialog, pickSaved: true, savedBusy: true };
try {
const response: { plans?: NonNullable<typeof dialog.saved> } = await this.host.hass.callWS({
type: 'houseplan/plans/list',
});
const current = this.host._spaceDialog;
if (current) this.host._spaceDialog = {
...current, saved: response?.plans || [], savedBusy: false,
};
} catch (error: unknown) {
const current = this.host._spaceDialog;
if (current) this.host._spaceDialog = { ...current, saved: [], savedBusy: false };
this.host._showToast(this.host._t('toast.plans_list_failed', {
err: this.host._errText(error),
}));
}
};
public _useServerPlan(url: string): void {
const dialog = this.host._spaceDialog;
if (!dialog) return;
this.host._spaceDialog = {
...dialog, planUrl: url, planFile: null, pickSaved: false, savedAspect: undefined,
};
this.host._aspectJob = this._readPlanAspect(url);
}
public async _readPlanAspect(url: string): Promise<number> {
for (let i = 0; i < 40; i++) {
const src = this.host._display(url);
if (src) {
const ratio = await new Promise<number>((resolve) => {
const image = new Image();
image.onload = () => resolve(image.naturalWidth && image.naturalHeight
? image.naturalWidth / image.naturalHeight : 0);
image.onerror = () => resolve(0);
image.src = src;
});
const current = this.host._spaceDialog;
if (current && current.planUrl === url && Number.isFinite(ratio) && ratio > 0) {
this.host._spaceDialog = { ...current, savedAspect: ratio };
return ratio;
}
return 0;
}
await new Promise((resolve) => setTimeout(resolve, 150));
if (this.host._spaceDialog?.planUrl !== url) return 0;
}
return 0;
}
public async _deleteServerPlan(name: string): Promise<void> {
const dialog = this.host._spaceDialog;
const plan = dialog?.saved?.find((candidate) => candidate.name === name);
if (!dialog || !plan || plan.used_by.length || plan.url === dialog.planUrl) return;
const accepted = await this.host._confirmDanger({
key: 'delete-plan',
kind: 'destructive',
title: this.host._t('confirm.delete_plan_title'),
message: this.host._t('confirm.delete_plan_body'),
objectName: name,
confirmLabel: this.host._t('btn.delete'),
cancelLabel: this.host._t('btn.cancel'),
});
const currentDialog = this.host._spaceDialog;
const currentPlan = currentDialog?.saved?.find((candidate) => candidate.name === name);
if (!accepted || !currentDialog || !currentPlan
|| currentPlan.url !== plan.url || currentPlan.modified !== plan.modified
|| currentPlan.used_by.length || currentPlan.url === currentDialog.planUrl) return;
try {
await this.host.hass.callWS({ type: 'houseplan/plans/delete', name });
const dialog = this.host._spaceDialog;
if (dialog?.saved) this.host._spaceDialog = {
...dialog, saved: dialog.saved.filter((plan) => plan.name !== name),
};
} catch (error: unknown) {
this.host._showToast(this.host._t('toast.plan_delete_failed', {
err: this.host._errText(error),
}));
}
}
public _renderServerPlans(
dialog: NonNullable<HouseplanEditorHostPort['_spaceDialog']>,
): TemplateResult {
if (dialog.savedBusy) return html`<div class="savedplans muted">${this.host._t('space.loading')}</div>`;
const list = dialog.saved || [];
if (!list.length) return html`<div class="savedplans muted">${this.host._t('space.no_saved')}</div>`;
const kb = (size: number) => (size >= 1048576
? `${(size / 1048576).toFixed(1)} MB` : `${Math.round(size / 1024)} KB`);
return html`<div class="savedplans">
${list.map((plan) => html`
<div class="savedplan ${plan.url === dialog.planUrl ? 'cur' : ''}">
<img src=${this.host._display(plan.url)} alt="" loading="lazy" decoding="async" />
<div class="savedmeta">
<b>${plan.name}</b>
<span class="muted">${kb(plan.size)}${plan.used_by.length
? ` · ${this.host._t('space.used_by', { list: plan.used_by.join(', ') })}`
: ''}</span>
</div>
<button class="btn ghost" @click=${() => this._useServerPlan(plan.url)}
?disabled=${plan.url === dialog.planUrl}>${this.host._t('btn.use')}</button>
<button class="btn ghost danger"
title=${plan.used_by.length || plan.url === dialog.planUrl
? this.host._t('space.in_use') : this.host._t('btn.delete')}
?disabled=${plan.used_by.length > 0 || plan.url === dialog.planUrl}
@click=${() => this._deleteServerPlan(plan.name)}>
<ha-icon icon="mdi:trash-can-outline"></ha-icon>
</button>
</div>`)}
</div>`;
}
private async _saveConfigNow(): Promise<void> {
this.host._cfgEpoch++;
try {
await this.host._writeConfig();
} catch (error: unknown) {
const failure = error as { physicalGeometryRolledBack?: boolean; code?: string };
if (failure?.physicalGeometryRolledBack) await this.host._reloadRejectedPhysicalWrite();
else if (failure?.code === 'conflict') await this.host._reloadConfigOnly();
throw error;
}
}
public async _saveSpaceDialog(): Promise<void> {
const dialog = this.host._spaceDialog;
if (!dialog || dialog.busy || !dialog.title.trim()) return;
if (dialog.source === 'file' && !dialog.planFile && !dialog.planUrl) {
this.host._showToast(this.host._t('toast.plan_required'));
return;
}
const wasFirst = dialog.mode === 'create' && (this.host._serverCfg?.spaces.length || 0) === 0;
this.host._spaceDialog = { ...dialog, busy: true };
try {
const spaceId = dialog.mode === 'create'
? `s${Date.now().toString(36)}` : dialog.spaceId!;
let uploaded: { url: string; aspect: number } | null = null;
if (dialog.source === 'file' && dialog.planFile) {
const response: { url: string } = await this.host.hass.callWS({
type: 'houseplan/plan/set',
space_id: spaceId,
ext: dialog.planFile.ext,
data: dialog.planFile.b64,
});
uploaded = { url: response.url, aspect: dialog.planFile.aspect };
}
let pickedAspect: number | null = dialog.savedAspect || null;
if (!uploaded && dialog.source === 'file' && dialog.planUrl
&& !pickedAspect && this.host._aspectJob) {
pickedAspect = (await this.host._aspectJob) || null;
}
const config = this.host._serverCfg!;
let space = config.spaces.find((candidate) => candidate.id === spaceId);
if (dialog.mode === 'create') {
space = createEmptySpaceConfig(spaceId, dialog.title.trim());
config.spaces.push(space);
} else {
if (!space) throw new Error(`space ${spaceId} is gone from the config`);
space.title = dialog.title.trim();
}
if (!space) throw new Error(`space ${spaceId} is unavailable`);
if (uploaded) {
space.plan_url = uploaded.url;
space.plan_aspect = uploaded.aspect;
} else if (dialog.source === 'file' && dialog.planUrl && dialog.planUrl !== space.plan_url) {
space.plan_url = dialog.planUrl;
space.plan_aspect = pickedAspect;
}
if (dialog.source === 'draw') {
space.plan_url = null;
space.plan_aspect = null;
delete space.plan_x;
delete space.plan_y;
delete space.plan_scale;
delete space.plan_scale_x;
delete space.plan_scale_y;
delete space.plan_angle;
}
space.settings = {
...(space.settings || {}),
show_borders: dialog.showBorders,
show_names: dialog.showNames,
hide_decor: dialog.hideDecor || undefined,
hide_openings: dialog.hideOpenings || undefined,
room_color: dialog.roomColor,
room_opacity: dialog.roomOpacity,
bg_color: dialog.bgColor || undefined,
bg_mode: dialog.bgMode || undefined,
north_deg: dialog.northDeg ?? undefined,
sun_rays: dialog.sunRays ?? undefined,
fill_mode: dialog.fillMode,
custom_fill: dialog.customFill || undefined,
glow_enabled: dialog.glowEnabled,
temp_min: Number.isFinite(dialog.tempMin)
? Math.min(dialog.tempMin, dialog.tempMax) : DEFAULT_TEMP_MIN,
temp_max: Number.isFinite(dialog.tempMax)
? Math.max(dialog.tempMin, dialog.tempMax) : DEFAULT_TEMP_MAX,
show_lqi: dialog.showLqi,
card_font_scale: dialog.cardFontScale !== 1 ? dialog.cardFontScale : undefined,
label_temp: dialog.labelTemp,
label_hum: dialog.labelHum,
label_lqi: dialog.labelLqi,
label_light: dialog.labelLight,
};
space.zero_wall_style = dialog.zeroWallStyle;
space.cell_cm = Number.isFinite(dialog.cellCm) && dialog.cellCm > 0
? Math.max(CELL_CM_MIN, Math.min(CELL_CM_MAX, dialog.cellCm)) : 5;
await this._saveConfigNow();
this.host._spaceDialog = null;
if (dialog.mode === 'create') this.host._commitSpace(space.id);
this.host._regSignature = '';
this.host._maybeRebuildDevices();
if (this.host._importQueue.length) {
this._openNextImport();
} else if (wasFirst || this.host._importTotal > 0) {
const wasImport = this.host._importTotal > 0;
this.host._importTotal = 0;
this.host._commitSpace(this.host._serverCfg!.spaces[0]?.id || this.host._space);
await this.host._requestMode('plan');
this.host._tool = 'draw';
this.host._path = [];
this.host._cursorPt = null;
this.host._primeDrawWallField();
this.host._showToast(this.host._t(wasFirst && !wasImport
? 'toast.space_added_onboard' : 'import.done'));
} else {
this.host._showToast(this.host._t(dialog.mode === 'create'
? 'toast.space_added' : 'toast.space_saved'));
if (dialog.mode === 'create') {
if (this.host._mode !== 'plan') await this.host._requestMode('plan');
else {
this.host._tool = 'draw';
this.host._path = [];
this.host._cursorPt = null;
this.host._primeDrawWallField();
this.host._saveNav();
}
}
}
} catch (error: unknown) {
const failure = error as { code?: string };
if (failure?.code !== 'conflict') await this.host._reloadConfigOnly(true);
if (this.host._spaceDialog) this.host._spaceDialog = {
...this.host._spaceDialog, busy: false,
};
this.host._showToast(this.host._t('toast.error', { err: this.host._errText(error) }));
}
}
public async _deleteSpace(): Promise<void> {
const dialog = this.host._spaceDialog;
if (!dialog || dialog.mode !== 'edit') return;
const serverConfig = this.host._serverCfg;
if (!serverConfig) return;
const space = serverConfig.spaces.find((candidate) => candidate.id === dialog.spaceId);
if (!space) return;
const dependencies = collectSpaceMarkerDependencies(
serverConfig, this.host._layout || {}, dialog.spaceId || '',
);
const deletingLastSpace = serverConfig.spaces.length === 1
&& serverConfig.spaces[0]?.id === dialog.spaceId;
if (dependencies.count && !deletingLastSpace) {
this.host._spaceDialog = { ...dialog, deleteBlockers: dependencies.count };
return;
}
const spaceId = dialog.spaceId!;
const accepted = await this.host._confirmDanger({
key: 'delete-space',
kind: 'destructive',
title: this.host._t('confirm.delete_space_title'),
message: this.host._t('confirm.delete_space_body'),
objectName: space.title,
confirmLabel: this.host._t('btn.delete'),
cancelLabel: this.host._t('btn.cancel'),
});
const currentDialog = this.host._spaceDialog;
const currentConfig = this.host._serverCfg;
if (!accepted || !currentDialog || currentDialog.mode !== 'edit'
|| currentDialog.busy || currentDialog.spaceId !== spaceId || !currentConfig) return;
const currentSpace = currentConfig.spaces.find((candidate) => candidate.id === spaceId);
if (!currentSpace) return;
const currentDependencies = collectSpaceMarkerDependencies(
currentConfig, this.host._layout || {}, spaceId,
);
const currentlyDeletingLastSpace = currentConfig.spaces.length === 1
&& currentConfig.spaces[0]?.id === spaceId;
if (currentDependencies.count && !currentlyDeletingLastSpace) {
this.host._spaceDialog = {
...currentDialog, deleteBlockers: currentDependencies.count,
};
return;
}
this.host._spaceDialog = { ...currentDialog, deleteBlockers: 0, busy: true };
try {
if (this.host._saveConfigDebounced.pending()) this.host._saveConfigDebounced.flush();
if (this.host._persistLayout.pending()) this.host._persistLayout.flush();
await this.host._writeChain;
const response: { config_rev?: number; layout_rev?: number } = await this.host.hass.callWS({
type: 'houseplan/space/delete',
space_id: spaceId,
expected_config_rev: this.host._cfgRev,
expected_layout_rev: this.host._layoutRev,
});
const [configResponse, layoutResponse] = await Promise.all([
this.host._getAuthoritativeConfig(),
this.host.hass.callWS({ type: 'houseplan/layout/get' }),
]);
this.host._adoptStructuralResponses(configResponse, layoutResponse);
this.host._cfgRev = response?.config_rev ?? this.host._cfgRev;
this.host._layoutRev = response?.layout_rev ?? this.host._layoutRev;
this.host._spaceDialog = null;
if (this.host._space === spaceId) {
this.host._commitSpace(this.host._serverCfg!.spaces[0]?.id || '');
}
this.host._regSignature = '';
this.host._maybeRebuildDevices();
this.host._showToast(this.host._t('toast.space_deleted'));
} catch (error: unknown) {
const failure = error as { code?: string };
if (failure?.code === 'conflict' || failure?.code === 'space_in_use') {
await Promise.all([
this.host._reloadConfigOnly(true), this.host._reloadLayoutOnly(),
]);
}
const refreshedConfig = this.host._serverCfg;
if (this.host._spaceDialog && refreshedConfig) {
const refreshed = collectSpaceMarkerDependencies(
refreshedConfig, this.host._layout || {}, spaceId,
);
const stillLastSpace = refreshedConfig.spaces.length === 1
&& refreshedConfig.spaces[0]?.id === spaceId;
this.host._spaceDialog = {
...this.host._spaceDialog,
busy: false,
deleteBlockers: stillLastSpace ? 0 : refreshed.count,
};
}
this.host._showToast(this.host._t('toast.delete_failed', {
err: this.host._errText(error),
}));
}
}
public _startImport(): void {
const dialog = this.host._importDialog;
if (!dialog) return;
const titles = dialog.floors.filter((floor) => floor.checked).map((floor) => floor.name);
this.host._importDialog = null;
if (!titles.length) {
this._openSpaceDialog('create');
return;
}
this.host._importQueue = titles;
this.host._importTotal = titles.length;
this._openNextImport();
}
public _openNextImport(): void {
const title = this.host._importQueue.shift();
if (title === undefined) return;
const cellCm = newSpaceCellCm(this.host._imperial);
this.host._spaceDialog = {
mode: 'create', title, planUrl: null, planFile: null,
...initialSpaceDisplayDraft(),
hideDecor: false, hideOpenings: false, zeroWallStyle: 'dashed',
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'custom',
customFill: null,
glowEnabled: true,
bgColor: null,
bgMode: 'daynight', northDeg: null, sunRays: null,
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
showLqi: this.host._config?.show_signal ?? true,
cardFontScale: 1,
labelTemp: false, labelHum: false, labelLqi: false, labelLight: false,
cellCm,
cellCmInput: gridCellFieldValue(cellCm, this.host._imperial),
cellCmTouched: false,
busy: false,
};
}
public _skipImport(): void {
this.host._spaceDialog = null;
if (this.host._importQueue.length) this._openNextImport();
else if (this.host._importTotal > 0 && this.host._model.length) {
this.host._importTotal = 0;
this.host._commitSpace(this.host._serverCfg!.spaces[0]?.id || this.host._space);
void this.host._requestMode('plan').then(() => {
this.host._showToast(this.host._t('import.done'));
});
}
}
public _renderImportDialog(): TemplateResult {
const dialog = this.host._importDialog!;
const selected = dialog.floors.filter((floor) => floor.checked).length;
return html`<hp-dialog .hass=${this.host.hass} .title=${this.host._t('import.title')}
icon="mdi:home-floor-1" @hp-close=${() => (this.host._importDialog = null)}>
<div class="body">
<div class="rhint">${this.host._t('import.hint')}</div>
${dialog.floors.map((floor, index) => html`<label class="floorrow">
<input type="checkbox" .checked=${floor.checked}
@change=${(event: Event) => {
const floors = [...dialog.floors];
floors[index] = {
...floor, checked: (event.target as HTMLInputElement).checked,
};
this.host._importDialog = { floors };
}} />
<span>${floor.name}</span>
${floor.level != null ? html`<span class="floorlvl">L${floor.level}</span>` : nothing}
</label>`)}
</div>
<div class="row" slot="footer">
<button class="btn ghost" @click=${() => {
this.host._importDialog = null;
this._openSpaceDialog('create');
}}>${this.host._t('import.manual')}</button>
<span class="spacer"></span>
<button class="btn on" @click=${() => this._startImport()} ?disabled=${!selected}>
<ha-icon icon="mdi:import"></ha-icon>${this.host._t('import.start', { n: selected })}
</button>
</div>
</hp-dialog>`;
}
private _boolInput(value: boolean, change: (checked: boolean) => void): TemplateResult {
return html`<input type="checkbox" .checked=${value}
@change=${(event: Event) => change((event.target as HTMLInputElement).checked)} />`;
}
private _rangeInput(
min: number, max: number, step: number, value: number, change: (next: number) => void,
): TemplateResult {
return html`<input type="range" min=${min} max=${max} step=${step} .value=${String(value)}
@input=${(event: Event) => change(Number((event.target as HTMLInputElement).value))} />`;
}
public _renderSpaceDialog(): TemplateResult {
return this._renderSpaceDialogBody(this.host._spaceDialog!);
}
private _renderSpaceDialogBody(
dialog: NonNullable<HouseplanEditorHostPort['_spaceDialog']>,
): TemplateResult {
const progress = this.host._importTotal > 0 && dialog.mode === 'create'
? this.host._t('import.progress', {
i: this.host._importTotal - this.host._importQueue.length,
n: this.host._importTotal,
}) : '';
const close = () => {
this.host._spaceDialog = null;
this.host._importQueue = [];
this.host._importTotal = 0;
};
return html`<hp-dialog .hass=${this.host.hass}
.title=${`${dialog.mode === 'create'
? this.host._t('space.new') : this.host._t('space.header')}${progress ? ` · ${progress}` : ''}`}
icon="mdi:floor-plan" wide @hp-close=${close}>
<div class="body">
<label>${this.host._t('space.title_label')}</label>
<input class="namein" type="text" placeholder=${this.host._t('space.title_ph')}
.value=${dialog.title}
@input=${(event: Event) => (this.host._spaceDialog = {
...dialog, title: (event.target as HTMLInputElement).value,
})} />
<label>${this.host._t('space.plan_label')}</label>
<label class="srcrow">
<input type="radio" name="plansrc" .checked=${dialog.source === 'file'}
@change=${() => (this.host._spaceDialog = switchSpacePlanSource(dialog, 'file'))} />
<span>${this.host._t('space.source_file')}</span>
</label>
${dialog.source === 'file' ? html`<div class="planrow">
${dialog.planFile
? html`<span class="planname">${dialog.planFile.name}</span>`
: dialog.planUrl
? html`<img class="planprev" src=${this.host._display(dialog.planUrl)}
alt=${this.host._t('space.plan_alt')} />`
: html`<span class="planname muted">${this.host._t('space.no_plan')}</span>`}
<span class="fileupload">
<button class="btn filebtn" type="button" @click=${(event: Event) =>
((event.currentTarget as HTMLElement).nextElementSibling as HTMLInputElement | null)?.click()}>
<ha-icon icon="mdi:upload"></ha-icon>${dialog.planUrl || dialog.planFile
? this.host._t('btn.replace') : this.host._t('btn.upload')}
</button>
<input type="file" hidden
accept=".svg,.png,.jpg,.jpeg,.webp,image/svg+xml,image/png,image/jpeg,image/webp"
@change=${(event: Event) => this._pickPlanFile(event)} />
</span>
<button class="btn ghost" @click=${this._toggleServerPlans}
title=${this.host._t('space.pick_saved_hint')}>
<ha-icon icon="mdi:folder-image"></ha-icon>${this.host._t('space.pick_saved')}
</button>
</div>
${dialog.pickSaved ? this._renderServerPlans(dialog) : nothing}` : nothing}
<label class="srcrow">
<input type="radio" name="plansrc" .checked=${dialog.source === 'draw'}
@change=${() => (this.host._spaceDialog = switchSpacePlanSource(dialog, 'draw'))} />
<span>${this.host._t('space.source_draw')}</span>
</label>
<div class="helpfieldlabel">
<label for="onboarding-space-cell-cm">${this.host._t('space.scale_label')}</label>
${this._help('space.cell_cm.help')}
</div>
<div class="colorrow">
<input id="onboarding-space-cell-cm" class="namein tempin" type="number"
min=${gridCellFieldValue(CELL_CM_MIN, this.host._imperial)}
max=${gridCellFieldValue(CELL_CM_MAX, this.host._imperial)}
step="0.1"
.value=${dialog.cellCmInput ?? gridCellFieldValue(dialog.cellCm, this.host._imperial)}
@input=${(event: Event) => {
const raw = (event.target as HTMLInputElement).value;
const parsed = strictNumber(raw);
const canonical = parsed == null ? null
: gridCellFieldToCm(parsed, this.host._imperial);
this.host._spaceDialog = {
...dialog,
cellCmInput: raw,
cellCmTouched: true,
cellCm: canonical != null && canonical > 0
? Math.max(CELL_CM_MIN, Math.min(CELL_CM_MAX, canonical)) : dialog.cellCm,
};
}} />
<span class="opl">${this.host._t(this.host._imperial
? 'space.scale_unit_imperial' : 'space.scale_unit')}</span>
</div>
<label class="dispsection">${this.host._t('space.display_section')}</label>
<label class="srcrow">
${this._boolInput(dialog.showBorders, (value) => {
this.host._spaceDialog = touchSpaceDisplay(dialog, 'showBorders', value);
})}
<span>${this.host._t('space.show_borders')}</span>
</label>
<div class="helpfieldlabel">
<label for="onboarding-space-zero-wall-style">${this.host._t('space.zero_wall_style')}</label>
${this._help('space.zero_wall_style.help')}
</div>
<select id="onboarding-space-zero-wall-style" class="areasel" @change=${(event: Event) => {
const value = (event.target as HTMLSelectElement).value;
this.host._spaceDialog = {
...dialog, zeroWallStyle: value === 'solid' ? 'solid' : 'dashed',
};
}}>
<option value="dashed" ?selected=${dialog.zeroWallStyle === 'dashed'}>
${this.host._t('space.zero_wall_dashed')}
</option>
<option value="solid" ?selected=${dialog.zeroWallStyle === 'solid'}>
${this.host._t('space.zero_wall_solid')}
</option>
</select>
<label class="srcrow">
${this._boolInput(dialog.showNames, (value) => {
this.host._spaceDialog = touchSpaceDisplay(dialog, 'showNames', value);
})}
<span>${this.host._t('space.show_names')}</span>
</label>
<label class="srcrow">
${this._boolInput(dialog.showLqi, (value) => {
this.host._spaceDialog = { ...dialog, showLqi: value };
})}
<span>${this.host._t('space.show_lqi')}</span>
</label>
<label class="srcrow">
${this._boolInput(dialog.hideDecor, (value) => {
this.host._spaceDialog = { ...dialog, hideDecor: value };
})}
<span>${this.host._t('space.hide_decor')}</span>
</label>
<div class="rhint">${this.host._t('space.hide_decor_tip')}</div>
<label class="srcrow">
${this._boolInput(dialog.hideOpenings, (value) => {
this.host._spaceDialog = { ...dialog, hideOpenings: value };
})}
<span>${this.host._t('space.hide_openings')}</span>
</label>
<div class="rhint">${this.host._t('space.hide_openings_tip')}</div>
<label class="dispsection">${this.host._t('space.roomcard_section')}</label>
${([
['labelTemp', 'space.label_temp'], ['labelHum', 'space.label_hum'],
['labelLqi', 'space.label_lqi'], ['labelLight', 'space.label_light'],
] as const).map(([field, key]) => html`<label class="srcrow">
${this._boolInput(dialog[field], (value) => {
this.host._spaceDialog = { ...dialog, [field]: value };
})}
<span>${this.host._t(key)}</span>
</label>`)}
<label>${this.host._t('space.card_font')}</label>
<div class="colorrow gsrow">
${this._rangeInput(50, 300, 5, Math.round(dialog.cardFontScale * 100), (value) => {
this.host._spaceDialog = { ...dialog, cardFontScale: value / 100 };
})}
<span class="opv">${Math.round(dialog.cardFontScale * 100)}%</span>
</div>
${this.host._renderCardPreview(dialog.cardFontScale, 1, 1)}
<div class="colorrow">
<hp-color-opacity .label=${this.host._t('space.room_color')}
.opacityLabel=${this.host._t('space.opacity')}
.pickerLabels=${this.host._colorPickerLabels}
.color=${dialog.roomColor} .opacity=${dialog.roomOpacity} .showOpacity=${true}
@hp-color-opacity-change=${(event: CustomEvent<{ color: string; opacity: number }>) => {
this.host._spaceDialog = {
...dialog, roomColor: event.detail.color, roomOpacity: event.detail.opacity,
};
}}></hp-color-opacity>
</div>
<div class="helpfieldlabel">
<label for="onboarding-space-bg-mode">${this.host._t('space.bg_mode')}</label>
${this._help('space.bg_mode.help')}
</div>
<select id="onboarding-space-bg-mode" class="areasel" @change=${(event: Event) => {
const value = (event.target as HTMLSelectElement).value;
this.host._spaceDialog = {
...dialog,
bgMode: value === 'static' || value === 'daynight' ? value : null,
};
}}>
<option value="" ?selected=${dialog.bgMode === null}>${this.host._t('space.sun_inherit')}</option>
<option value="static" ?selected=${dialog.bgMode === 'static'}>${this.host._t('gs.bg_static')}</option>
<option value="daynight" ?selected=${dialog.bgMode === 'daynight'}>${this.host._t('gs.bg_daynight')}</option>
</select>
${(dialog.bgMode ?? bgModeOf(this.host._settings, {})) === 'static' ? html`
<div class="colorrow">
<hp-color-opacity .label=${this.host._t('space.bg_color')}
.pickerLabels=${this.host._colorPickerLabels}
.color=${dialog.bgColor || stageBgOf(this.host._settings, { bgColor: null })
|| this.host._stageBgHex()}
.opacity=${1} .showOpacity=${false}
@hp-color-opacity-change=${(event: CustomEvent<{ color: string }>) => {
this.host._spaceDialog = { ...dialog, bgColor: event.detail.color };
}}></hp-color-opacity>
${dialog.bgColor ? html`<button class="btn ghost" @click=${() => {
this.host._spaceDialog = { ...dialog, bgColor: null };
}}>${this.host._t('space.bg_inherit')}</button>`
: html`<span class="opl">${this.host._t('space.bg_inherited')}</span>`}
</div>` : nothing}
<div class="helpfieldlabel">
<label for="onboarding-space-north">${this.host._t('space.north')}</label>
${this._help('space.north.help')}
</div>
<div class="colorrow">
<input id="onboarding-space-north" class="namein tempin" type="number" min="0" max="359" step="1"
placeholder=${this.host._t('space.sun_inherit')}
.value=${dialog.northDeg === null ? '' : String(dialog.northDeg)}
@input=${(event: Event) => {
const raw = (event.target as HTMLInputElement).value.trim();
const value = raw === '' ? null : Math.round(Number(raw));
this.host._spaceDialog = {
...dialog,
northDeg: value !== null && Number.isFinite(value)
? Math.min(359, Math.max(0, value)) : null,
};
}} />
<span class="opl">${dialog.northDeg === null
? this.host._t('space.north_inherited', {
v: northDegOf(this.host._settings, {}) === null
? '—' : `${northDegOf(this.host._settings, {})}°`,
}) : '°'}</span>
</div>
<label>${this.host._t('space.sun_rays')}</label>
<select class="areasel" @change=${(event: Event) => {
const value = (event.target as HTMLSelectElement).value;
this.host._spaceDialog = {
...dialog, sunRays: value === '' ? null : value === '1',
};
}}>
<option value="" ?selected=${dialog.sunRays === null}>${this.host._t('space.sun_inherit')}</option>
<option value="1" ?selected=${dialog.sunRays === true}>${this.host._t('space.sun_on')}</option>
<option value="0" ?selected=${dialog.sunRays === false}>${this.host._t('space.sun_off')}</option>
</select>
<div class="helpfieldlabel">
<span>${this.host._t('space.fill_label')}</span>
${this._help('space.fill_mode.help')}
</div>
${SPACE_FILL_UI_MODES.map((value) => [value, `fill.${value}`] as const).map(
([value, key]) => html`<label class="srcrow">
<input type="radio" name="fillmode" .checked=${dialog.fillMode === value}
@change=${() => (this.host._spaceDialog = { ...dialog, fillMode: value })} />
<span>${this.host._t(key)}</span>
${value === 'temp' && dialog.fillMode === 'temp' ? html`<span class="temprange">
<input class="namein tempin" type="number" step="0.5" .value=${String(dialog.tempMin)}
@input=${(event: Event) => {
const parsed = strictNumber((event.target as HTMLInputElement).value);
if (parsed != null) this.host._spaceDialog = { ...dialog, tempMin: parsed };
}} />
–
<input class="namein tempin" type="number" step="0.5" .value=${String(dialog.tempMax)}
@input=${(event: Event) => {
const parsed = strictNumber((event.target as HTMLInputElement).value);
if (parsed != null) this.host._spaceDialog = { ...dialog, tempMax: parsed };
}} /> °C
</span>` : nothing}
</label>
${value === 'custom' && dialog.fillMode === 'custom' ? html`
<div class="colorrow gsrow">
<span class="gsl">${this.host._t('space.custom_fill')}</span>
<hp-color-opacity .label=${this.host._t('space.custom_fill')}
.opacityLabel=${this.host._t('space.opacity')}
.pickerLabels=${this.host._colorPickerLabels}
.color=${(dialog.customFill || DEFAULT_CUSTOM_FILL).c}
.opacity=${(dialog.customFill || DEFAULT_CUSTOM_FILL).a}
@hp-color-opacity-change=${(event: CustomEvent<{ color: string; opacity: number }>) => {
this.host._spaceDialog = {
...dialog, customFill: { c: event.detail.color, a: event.detail.opacity },
};
}}></hp-color-opacity>
${dialog.customFill ? html`<button class="btn ghost" type="button"
@click=${() => (this.host._spaceDialog = { ...dialog, customFill: null })}>
${this.host._t('btn.reset')}</button>` : nothing}
</div>` : nothing}`,
)}
<label class="srcrow">
${this._boolInput(dialog.glowEnabled, (checked) => {
this.host._spaceDialog = { ...dialog, glowEnabled: checked };
})}
<span>${this.host._t('space.glow_enabled')}</span>
</label>
${dialog.deleteBlockers ? html`<div class="backuperror" role="alert">
${this.host._t('space.delete_blocked', { n: String(dialog.deleteBlockers) })}
</div>` : nothing}
</div>
<div class="row dialog-action-footer" slot="footer">
${dialog.mode === 'edit' ? html`<div class="dialog-action-group dialog-action-danger">
<button class="btn danger" @click=${() => this._deleteSpace()} ?disabled=${dialog.busy}>
<ha-icon icon="mdi:delete-outline"></ha-icon>${this.host._t('btn.delete')}
</button>
</div>` : nothing}
<div class="dialog-action-group dialog-action-commit">
${this.host._importTotal > 0 && dialog.mode === 'create'
? html`<button class="btn ghost" @click=${() => this._skipImport()}>
${this.host._t('btn.skip')}</button>` : nothing}
<button class="btn ghost" @click=${close}>${this.host._t('btn.cancel')}</button>
<button class="btn on" @click=${() => this._saveSpaceDialog()}
?disabled=${!dialog.title.trim()
|| (dialog.source === 'file' && !(dialog.planFile || dialog.planUrl)) || dialog.busy}
title=${dialog.source === 'file' && !(dialog.planFile || dialog.planUrl)
? this.host._t('title.need_plan') : ''}>
<ha-icon icon="mdi:check"></ha-icon>${dialog.busy ? '…' : this.host._t('btn.save')}
</button>
</div>
</div>
</hp-dialog>`;
}
}