mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 22:29:05 +00:00
feat(general): «Общие настройки» по референсу — серия 2 из четырёх (#600)
§5 SPEC.md референса. Семь карточек #598 остались, менялось содержимое: - Display — две полные строки с иконкой, подписью и тумблером справа; пояснение радара по §5.2 стало подписью строки (текст прежний, ключ переехал в ленивый словарь как gs.radar_show_live_hint, «?» у строки больше нет); - Zigbee links — заголовок и «?» функции (topology.help) рисует карточка, а блок hp-zigbee-topology-settings получил режим embedded: строка-тумблер, callout «сохраните настройку», подзаголовки ZHA / Zigbee2MQTT, поле тем и строки действий контролами набора. Заголовок больше не удваивается (дефект 1). Лист набора вносится в теневой корень компонента; токены --hpf-* наследуются с хоста. Прежний render без embedded не тронут — его держит контракт #459; - Room fill colors — три подзаголовка (Lights / Temperature / Zigbee signal, Q6) и плитки цвета по три в ряд: название на свотче, hex, число прозрачности; свотч — trigger прежнего hp-color-opacity (Q5); - Light-source glow — две плитки и поле радиуса с единицей внутри рамки; - Plan — плашка цвета стен, сегмент фона, при Static — плашка фона с Reset и подсказкой (тема / свой цвет); - Sun — gs.sun_missing остаётся callout'ом (К5); север: поле градусов с «°», ссылка Clear, «N» и компас 44 px справа — индикатор набора, но тянуть его по-прежнему можно (smoke_sun гоняет настоящий drag; старый 120-px SVG и его CSS в plan.styles.ts удалены); лучи — строка-тумблер с подписью; источник лучей — сегмент (sun-settings-view.ts переписан с <select> на segmented, логика та же); - Data — подзаголовки с «?» и строки действий набора. К10 (Q1): Save только при изменениях и без ошибок (радиус > 0, север 0–359), статус в футере, вопрос при закрытии с изменениями; снимок при открытии — editors/general-form-state.ts поверх общего editors/dialog-baseline.ts (на него же переведён space-form-state.ts). _settingsDialog не получил новых ключей. Удалено мёртвое: _renderColorRow (рантайм и карточка), .sunrow/.suncol, ключи gs.wall_group, gs.bg_group, gs.bg_theme, gs.radar_show_live.help(.aria) в четырёх локалях. Девять новых ключей — в i18n/settings. Смоки переписаны осознанно, с пометкой #600: smoke_general_settings (инвентарь по классам набора, подзаголовки .hpf-sub h4, пояснения под «?» и подписью, ни одного .rhint), smoke_sun (сегмент источника лучей вместо select, поле #gs-north, radius drag 18 px), smoke_backup_transfer (подзаголовок и строка действий), smoke_settings_dialog_cards (callout вместо .rhint). Новый фокусный свидетель demo/smoke_general_settings_form.mjs: семь карточек и отсутствие старой разметки, один заголовок у Zigbee, десять плиток, компас 44, цели 44, запись плиток/плашки/радиуса/севера/сегментов в свои ключи, Save/dirty/вопрос при закрытии. Мутанты: general-save-enabled-without-changes, general-tile-opacity-writes-a-neighbour-key, general-north-clear-writes-zero, topology-embedded-draws-its-own-heading; якоря state-callout-hidden-under-help и dialog-card-loses-its-heading переставлены на новую разметку. Контрактные тесты: houseplan-source.mjs приводит `host.` к `this.` и в телах вынесенных диалогов; color-picker — три явных showOpacity=true (плитки, плашка стены, цвет комнаты); счётчик производных .aria 35 → 34. Бюджет: стартовый граф 290 711 — потолок ОПУЩЕН 292 500 → 292 200 (ушёл 120-px компас из eager-листа и мёртвые ключи); ленивый редакторский 237 929 — потолок 236 300 → 238 600 с разбором в bundle-budget.mjs. Гейты: typecheck, build, bundle:sync, npm test 2792 pass, mutation-gate --check, семь смоков «Общих», smoke_color_picker_consumers без правок. Issue: #600 User-Visible: no
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* #600 К10: снимок состояния диалога на момент открытия.
|
||||
*
|
||||
* «Сохранить» активен только при изменениях, и изменение считается сравнением
|
||||
* нормализованного черновика с этим снимком. Ничего не персистится: снимки
|
||||
* живут в памяти рантайма, привязаны к хосту и различаются по виду диалога, а
|
||||
* состояния самих диалогов не получают новых ключей (ТЗ: не в скоупе).
|
||||
*
|
||||
* Без снимка (диалог открыт путём, который его не сделал) ответ `true`:
|
||||
* прежнее поведение — Save доступен — безопаснее заблокированной кнопки.
|
||||
*/
|
||||
export type DialogKind = 'space' | 'settings' | 'room' | 'marker';
|
||||
|
||||
const baselines = new WeakMap<object, Map<DialogKind, string>>();
|
||||
|
||||
export function rememberDialogBaseline(host: object, kind: DialogKind, key: string): void {
|
||||
const map = baselines.get(host) ?? new Map<DialogKind, string>();
|
||||
map.set(kind, key);
|
||||
baselines.set(host, map);
|
||||
}
|
||||
|
||||
export function forgetDialogBaseline(host: object, kind: DialogKind): void {
|
||||
baselines.get(host)?.delete(kind);
|
||||
}
|
||||
|
||||
export function dialogDirty(host: object, kind: DialogKind, key: string): boolean {
|
||||
const baseline = baselines.get(host)?.get(kind);
|
||||
if (baseline === undefined) return true;
|
||||
return baseline !== key;
|
||||
}
|
||||
|
||||
/** Устойчивый отпечаток объекта: ключи по алфавиту, без транзиентных. */
|
||||
export function stableKey(value: Record<string, unknown>, transient: ReadonlySet<string> = new Set()): string {
|
||||
const entries = Object.entries(value)
|
||||
.filter(([k]) => !transient.has(k))
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
return JSON.stringify(entries);
|
||||
}
|
||||
@@ -281,6 +281,53 @@ export function colorField({
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export interface ColorTileOptions {
|
||||
label: string;
|
||||
/** Текущий цвет `#rrggbb` — фон плитки и печать hex. */
|
||||
hex: string;
|
||||
opacity: number;
|
||||
opacityLabel: string;
|
||||
/** Готовый `hp-color-opacity` (Q5): свотч открывает существующую панель. */
|
||||
picker: TemplateResult;
|
||||
onOpacity: (opacity: number) => void;
|
||||
}
|
||||
|
||||
/** Светлый ли цвет — для контраста подписи поверх свотча плитки. */
|
||||
export function isLightHex(hex: string): boolean {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!m) return true;
|
||||
const n = parseInt(m[1], 16);
|
||||
const r = (n >> 16) & 255; const g = (n >> 8) & 255; const b = n & 255;
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;
|
||||
}
|
||||
|
||||
/**
|
||||
* Плитка цвета (§3.1 «Плитки цвета»): название поверх свотча на всю ширину,
|
||||
* ниже hex и число прозрачности. Свотч — trigger существующего пикера.
|
||||
*/
|
||||
export function colorTile({ label, hex, opacity, opacityLabel, picker, onOpacity }: ColorTileOptions): TemplateResult {
|
||||
const pct = Math.round(Math.min(1, Math.max(0, opacity)) * 100);
|
||||
return html`<div class="hpf-colortile">
|
||||
<div class="hpf-colortile-swatch" style=${`background:${hex};color:${isLightHex(hex) ? '#1f2a30' : '#fff'}`}>
|
||||
${picker}<span>${label}</span>
|
||||
</div>
|
||||
<div class="hpf-colortile-meta">
|
||||
<code class="hpf-hex">${hex}</code>
|
||||
<span class="hpf-unit" title=${opacityLabel}><input type="number" inputmode="numeric" min="0" max="100" step="1"
|
||||
.value=${String(pct)} aria-label=${opacityLabel}
|
||||
@input=${(e: Event) => {
|
||||
const n = Number((e.target as HTMLInputElement).value);
|
||||
if (Number.isFinite(n)) onOpacity(Math.min(1, Math.max(0, n / 100)));
|
||||
}} /><span>%</span></span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Ряд плиток цвета: три колонки, на узком экране две. */
|
||||
export function colorTiles(tiles: readonly TemplateResult[]): TemplateResult {
|
||||
return html`<div class="hpf-colortiles">${tiles}</div>`;
|
||||
}
|
||||
|
||||
export interface ColorRowOptions {
|
||||
label: string;
|
||||
help?: TemplateResult | typeof nothing;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* #600 К10 для «Общих настроек»: снимок при открытии, dirty, валидация.
|
||||
* Состояние `_settingsDialog` не получает новых ключей — снимок живёт здесь.
|
||||
*/
|
||||
import type { HouseplanEditorHostPort } from '../houseplan-editor-runtime';
|
||||
import { dialogDirty, forgetDialogBaseline, rememberDialogBaseline, stableKey } from './dialog-baseline';
|
||||
|
||||
export type GeneralSettingsDraft = NonNullable<HouseplanEditorHostPort['_settingsDialog']>;
|
||||
|
||||
const TRANSIENT: ReadonlySet<string> = new Set(['busy']);
|
||||
|
||||
export function generalDraftKey(d: GeneralSettingsDraft): string {
|
||||
return stableKey(d as unknown as Record<string, unknown>, TRANSIENT);
|
||||
}
|
||||
|
||||
export function rememberGeneralBaseline(host: object, d: GeneralSettingsDraft): void {
|
||||
rememberDialogBaseline(host, 'settings', generalDraftKey(d));
|
||||
}
|
||||
|
||||
export function forgetGeneralBaseline(host: object): void {
|
||||
forgetDialogBaseline(host, 'settings');
|
||||
}
|
||||
|
||||
export function generalDirty(host: object, d: GeneralSettingsDraft): boolean {
|
||||
return dialogDirty(host, 'settings', generalDraftKey(d));
|
||||
}
|
||||
|
||||
export interface GeneralProblem {
|
||||
field: string;
|
||||
message: 'gs.error_glow_radius' | 'gs.error_north';
|
||||
}
|
||||
|
||||
/** Ошибки черновика в порядке полей формы. */
|
||||
export function generalProblems(d: GeneralSettingsDraft): GeneralProblem[] {
|
||||
const problems: GeneralProblem[] = [];
|
||||
if (!(Number.isFinite(d.glowRadius) && d.glowRadius > 0)) problems.push({ field: 'gs-glow-radius', message: 'gs.error_glow_radius' });
|
||||
if (d.northDeg !== null && !(Number.isInteger(d.northDeg) && d.northDeg >= 0 && d.northDeg <= 359)) {
|
||||
problems.push({ field: 'gs-north', message: 'gs.error_north' });
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
@@ -1,209 +1,270 @@
|
||||
/**
|
||||
* Диалог «Общие настройки».
|
||||
* Диалог «Общие настройки» по референсу `docs/design/600-settings-dialogs/`
|
||||
* (#600, серия 2; §5 SPEC.md).
|
||||
*
|
||||
* #592 вынес его сюда побайтово; #598 собрал девять подписей-заголовков в семь
|
||||
* карточек общего набора. Состояние по-прежнему живёт на хосте — модуль только
|
||||
* рисует, и каждый контрол пишет в тот же ключ черновика, что и раньше (#598 К1).
|
||||
*
|
||||
* Классы `.srcrow`, `.dispsection`, `.gsrow`, `.colorrow`, `.namein`, `.areasel`,
|
||||
* `.tempin` сохранены намеренно: за них держатся одиннадцать смоков, и потеря
|
||||
* любого превратила бы ревью в чтение переписанных свидетелей (#598 К6).
|
||||
* Семь карточек #598 остались на месте — менялось содержимое, не состав:
|
||||
* строки-тумблеры с иконкой и подписью, плитки цвета по три в ряд, компактный
|
||||
* компас, сегмент источника лучей, футер со статусом. Каждый контрол пишет в
|
||||
* тот же ключ `_settingsDialog`, что и раньше (К1); сообщение о состоянии
|
||||
* `gs.sun_missing` остаётся callout'ом (К5).
|
||||
*/
|
||||
import { html, nothing, type TemplateResult } from 'lit';
|
||||
|
||||
import { ensureFormKitStyles, formCard, segmented } from './form-kit';
|
||||
import { langOf } from '../i18n';
|
||||
import {
|
||||
callout, colorField, colorRow, colorTile, colorTiles, ensureFormKitStyles, field, footerStatus,
|
||||
formCard, segmented, subsection, textLink, toggleRow, unitInput,
|
||||
} from './form-kit';
|
||||
import { forgetGeneralBaseline, generalDirty, generalProblems, type GeneralSettingsDraft } from './general-form-state';
|
||||
import { langOf, type I18nKey } from '../i18n';
|
||||
import { settingsT, type SettingsI18nKey } from '../i18n/settings';
|
||||
import { supportT } from '../i18n/support';
|
||||
import { DEFAULT_FILL_COLORS } from '../logic';
|
||||
import { hasTopologyTranslation, topologyT } from '../i18n/topology';
|
||||
import { DEFAULT_FILL_COLORS, type FillColors } from '../logic';
|
||||
import { strictNumber } from '../space-dialog';
|
||||
import { sunStateOf } from '../sun';
|
||||
import { renderSunRayOriginSelect } from '../sun-settings-view';
|
||||
import { renderSunRayOriginSegment } from '../sun-settings-view';
|
||||
import { zigbeeTopologySettingsOf } from '../zigbee-topology-settings';
|
||||
import type { ZigbeeTopologySettings } from '../zigbee-topology-settings';
|
||||
import type { HouseplanEditorRuntime } from '../houseplan-editor-runtime';
|
||||
|
||||
export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): TemplateResult {
|
||||
ensureFormKitStyles(this.host);
|
||||
return html`<hp-dialog .hass=${this.host.hass} data-kind="settings" form-shell .title=${this.host._t('gs.title')} icon="mdi:cog-outline" wide
|
||||
@hp-close=${() => (this.host._settingsDialog = null)}>
|
||||
<div class="body hpf-form">
|
||||
${formCard({
|
||||
title: this.host._t('gs.card_display'),
|
||||
body: html`
|
||||
<label class="srcrow">
|
||||
${this._boolInput(this.host._settingsDialog!.showRoomTooltip, (v) =>
|
||||
(this.host._settingsDialog = { ...this.host._settingsDialog!, showRoomTooltip: v }))}
|
||||
<span>${supportT(
|
||||
langOf(this.host.hass, this.host._config?.language), 'gs.show_room_tooltip',
|
||||
)}</span>
|
||||
</label>
|
||||
<label class="srcrow">
|
||||
${this._boolInput(this.host._settingsDialog!.radarShowLive, (v) =>
|
||||
(this.host._settingsDialog = { ...this.host._settingsDialog!, radarShowLive: v }))}
|
||||
<span>${this.host._t('gs.radar_show_live')}</span>
|
||||
${this._help('gs.radar_show_live.help')}
|
||||
</label>`,
|
||||
const host = this.host;
|
||||
const d = host._settingsDialog!;
|
||||
const t = host._t.bind(host);
|
||||
const lang = langOf(host.hass, host._config?.language);
|
||||
const st = (key: SettingsI18nKey, vars?: Record<string, string | number>) => settingsT(lang, key, vars);
|
||||
const set = (patch: Partial<GeneralSettingsDraft>) => { host._settingsDialog = { ...host._settingsDialog!, ...patch }; };
|
||||
const problems = generalProblems(d);
|
||||
const problemFor = (fieldId: string) => problems.find((p) => p.field === fieldId);
|
||||
const dirty = generalDirty(host, d);
|
||||
const canSave = dirty && problems.length === 0 && !d.busy;
|
||||
const close = () => { forgetGeneralBaseline(host); host._settingsDialog = null; };
|
||||
const requestClose = async (event: Event) => {
|
||||
if (!dirty || d.busy) { close(); return; }
|
||||
const dialog = event.currentTarget as { rejectClose?: () => void } | null;
|
||||
const discard = await host._confirmDanger({
|
||||
key: 'discard-settings-dialog', kind: 'warning',
|
||||
title: st('dialog.discard_title'), message: st('dialog.discard_message'),
|
||||
confirmLabel: st('dialog.discard_confirm'), cancelLabel: st('dialog.discard_keep'),
|
||||
});
|
||||
if (discard) close(); else dialog?.rejectClose?.();
|
||||
};
|
||||
const reviewFirst = () => {
|
||||
const first = problems[0];
|
||||
if (!first) return;
|
||||
const node = (host.renderRoot as ParentNode).querySelector<HTMLElement>(`#${first.field}`);
|
||||
node?.scrollIntoView({ block: 'center' });
|
||||
node?.focus();
|
||||
};
|
||||
/** Плитка цвета палитры: свотч — trigger пикера с той же атомарной записью. */
|
||||
const tile = (key: keyof FillColors, labelKey: I18nKey) => {
|
||||
const v = d.colors[key];
|
||||
const label = t(labelKey);
|
||||
return colorTile({
|
||||
label, hex: v.c, opacity: v.a, opacityLabel: st('gs.opacity_of', { name: label }),
|
||||
onOpacity: (a) => this._setFillColor(key, { c: v.c, a }),
|
||||
picker: html`<hp-color-opacity .label=${label} hide-label
|
||||
.opacityLabel=${t('space.opacity')}
|
||||
.pickerLabels=${host._colorPickerLabels}
|
||||
.color=${v.c} .opacity=${v.a} .showOpacity=${true}
|
||||
@hp-color-opacity-change=${(e: CustomEvent<{ color: string; opacity: number }>) => {
|
||||
this._setFillColor(key, { c: e.detail.color, a: e.detail.opacity });
|
||||
}}></hp-color-opacity>`,
|
||||
});
|
||||
};
|
||||
const glowProblem = problemFor('gs-glow-radius');
|
||||
const northProblem = problemFor('gs-north');
|
||||
const zigbeeHelp = hasTopologyTranslation(lang, 'help') && hasTopologyTranslation(lang, 'help_aria')
|
||||
? html`<hp-help data-help-key="topology.help" .text=${topologyT(lang, 'help')} .ariaLabel=${topologyT(lang, 'help_aria')}></hp-help>`
|
||||
: nothing;
|
||||
|
||||
return html`<hp-dialog .hass=${host.hass} data-kind="settings" form-shell wide
|
||||
.title=${t('gs.title')} icon="mdi:cog-outline" @hp-close=${requestClose}>
|
||||
<div class="body hpf-form">
|
||||
${formCard({
|
||||
id: 'display',
|
||||
title: t('gs.card_display'),
|
||||
body: html`
|
||||
${toggleRow({
|
||||
id: 'gs-room-tooltip', icon: 'mdi:tooltip-text-outline',
|
||||
title: supportT(lang, 'gs.show_room_tooltip'), caption: st('gs.show_room_tooltip_hint'),
|
||||
checked: d.showRoomTooltip, onChange: (v) => set({ showRoomTooltip: v }),
|
||||
})}
|
||||
${formCard({
|
||||
title: this.host._t('gs.card_zigbee'),
|
||||
body: html`<hp-zigbee-topology-settings .hass=${this.host.hass} .value=${this.host._settingsDialog!.zigbeeTopology} .savedEnabled=${zigbeeTopologySettingsOf(this.host._settings).enabled} .devices=${this.host._devices} .registry=${this.host._haRegistry} @hp-topology-settings-change=${(event: CustomEvent<ZigbeeTopologySettings>) => (this.host._settingsDialog = { ...this.host._settingsDialog!, zigbeeTopology: event.detail })}></hp-zigbee-topology-settings>`,
|
||||
${toggleRow({
|
||||
id: 'gs-radar-live', icon: 'mdi:radar',
|
||||
title: t('gs.radar_show_live'), caption: st('gs.radar_show_live_hint'),
|
||||
checked: d.radarShowLive, onChange: (v) => set({ radarShowLive: v }),
|
||||
})}`,
|
||||
})}
|
||||
${formCard({
|
||||
id: 'zigbee',
|
||||
title: t('gs.card_zigbee'),
|
||||
help: zigbeeHelp,
|
||||
body: html`<hp-zigbee-topology-settings embedded .hass=${host.hass} .value=${d.zigbeeTopology}
|
||||
.savedEnabled=${zigbeeTopologySettingsOf(host._settings).enabled} .devices=${host._devices} .registry=${host._haRegistry}
|
||||
@hp-topology-settings-change=${(event: CustomEvent<ZigbeeTopologySettings>) => set({ zigbeeTopology: event.detail })}></hp-zigbee-topology-settings>`,
|
||||
})}
|
||||
${formCard({
|
||||
id: 'fills',
|
||||
title: t('gs.card_fills'),
|
||||
help: this._help('gs.card_fills.help'),
|
||||
body: html`
|
||||
${subsection({ title: t('gs.light_group') })}
|
||||
${colorTiles([tile('light_on', 'gs.light_on'), tile('light_off', 'gs.light_off'), tile('light_none', 'gs.light_none')])}
|
||||
${subsection({ title: t('gs.temp_group') })}
|
||||
${colorTiles([tile('temp_cold', 'gs.temp_cold'), tile('temp_ok', 'gs.temp_ok'), tile('temp_hot', 'gs.temp_hot')])}
|
||||
${subsection({ title: t('gs.lqi_group') })}
|
||||
${colorTiles([tile('lqi_low', 'gs.lqi_low'), tile('lqi_high', 'gs.lqi_high')])}`,
|
||||
})}
|
||||
${formCard({
|
||||
id: 'glow',
|
||||
title: t('gs.glow_group'),
|
||||
body: html`
|
||||
${colorTiles([tile('glow_base', 'gs.glow_base'), tile('glow_light', 'gs.glow_light')])}
|
||||
${field({
|
||||
label: t('gs.glow_radius'), htmlFor: 'gs-glow-radius', help: this._help('gs.glow_radius.help'),
|
||||
error: glowProblem ? st(glowProblem.message) : undefined,
|
||||
control: unitInput({
|
||||
id: 'gs-glow-radius', value: String(d.glowRadius), min: 0.5, step: 0.5, invalid: !!glowProblem,
|
||||
unit: host._imperial ? t('gs.unit_ft') : t('gs.unit_m'),
|
||||
onInput: (raw) => {
|
||||
const v = strictNumber(raw);
|
||||
if (v != null && v > 0) set({ glowRadius: v });
|
||||
},
|
||||
}),
|
||||
})}`,
|
||||
})}
|
||||
${formCard({
|
||||
id: 'plan',
|
||||
title: t('gs.card_plan'),
|
||||
body: html`
|
||||
${colorRow({
|
||||
label: t('gs.wall_fill'),
|
||||
picker: colorField({
|
||||
hex: d.colors.wall_fill.c, opacity: d.colors.wall_fill.a, opacityLabel: t('space.opacity'),
|
||||
onOpacity: (a) => this._setFillColor('wall_fill', { c: d.colors.wall_fill.c, a }),
|
||||
picker: html`<hp-color-opacity .label=${t('gs.wall_fill')} hide-label
|
||||
.opacityLabel=${t('space.opacity')} .pickerLabels=${host._colorPickerLabels}
|
||||
.color=${d.colors.wall_fill.c} .opacity=${d.colors.wall_fill.a} .showOpacity=${true}
|
||||
@hp-color-opacity-change=${(e: CustomEvent<{ color: string; opacity: number }>) => {
|
||||
this._setFillColor('wall_fill', { c: e.detail.color, a: e.detail.opacity });
|
||||
}}></hp-color-opacity>`,
|
||||
}),
|
||||
})}
|
||||
${formCard({
|
||||
title: this.host._t('gs.card_fills'),
|
||||
help: this._help('gs.card_fills.help'),
|
||||
body: html`
|
||||
<label class="dispsection">${this.host._t('gs.light_group')}</label>
|
||||
${this._renderColorRow('light_on', 'gs.light_on')}
|
||||
${this._renderColorRow('light_off', 'gs.light_off')}
|
||||
${this._renderColorRow('light_none', 'gs.light_none')}
|
||||
<label class="dispsection">${this.host._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.host._t('gs.lqi_group')}</label>
|
||||
${this._renderColorRow('lqi_low', 'gs.lqi_low')}
|
||||
${this._renderColorRow('lqi_high', 'gs.lqi_high')}`,
|
||||
${field({
|
||||
label: t('gs.bg_mode'), help: this._help('gs.bg_mode.help'),
|
||||
control: segmented({
|
||||
name: 'gs-bg-mode',
|
||||
value: d.bgMode === 'daynight' ? 'daynight' : 'static',
|
||||
ariaLabel: t('gs.bg_mode'),
|
||||
options: [
|
||||
{ value: 'static', label: t('gs.bg_static') },
|
||||
{ value: 'daynight', label: t('gs.bg_daynight') },
|
||||
],
|
||||
onChange: (value) => set({ bgMode: value === 'daynight' ? 'daynight' : 'static' }),
|
||||
}),
|
||||
})}
|
||||
${formCard({
|
||||
title: this.host._t('gs.glow_group'),
|
||||
body: html`
|
||||
${this._renderColorRow('glow_base', 'gs.glow_base')}
|
||||
${this._renderColorRow('glow_light', 'gs.glow_light')}
|
||||
<div class="colorrow gsrow">
|
||||
<span class="gsl help-inline-label"><label for="gs-glow-radius">${this.host._t('gs.glow_radius')}</label>
|
||||
${this._help('gs.glow_radius.help')}</span>
|
||||
<input id="gs-glow-radius" type="number" class="tempin" min="0.5" step="0.5"
|
||||
.value=${String(this.host._settingsDialog!.glowRadius)}
|
||||
@input=${(e: Event) => {
|
||||
const v = strictNumber((e.target as HTMLInputElement).value);
|
||||
if (v != null && v > 0)
|
||||
this.host._settingsDialog = { ...this.host._settingsDialog!, glowRadius: v };
|
||||
}} />
|
||||
<span class="opl">${this.host._imperial ? this.host._t('gs.unit_ft') : this.host._t('gs.unit_m')}</span>
|
||||
</div>`,
|
||||
${d.bgMode === 'static'
|
||||
? field({
|
||||
label: t('gs.bg_color'),
|
||||
hint: st(d.bgColor ? 'gs.bg_custom_hint' : 'gs.bg_theme_hint'),
|
||||
control: colorField({
|
||||
hex: d.bgColor || host._stageBgHex(),
|
||||
resetLabel: d.bgColor ? t('gs.bg_default') : undefined,
|
||||
onReset: () => set({ bgColor: null }),
|
||||
picker: html`<hp-color-opacity .label=${t('gs.bg_color')} hide-label
|
||||
.pickerLabels=${host._colorPickerLabels}
|
||||
.color=${d.bgColor || host._stageBgHex()} .opacity=${1} .showOpacity=${false}
|
||||
@hp-color-opacity-change=${(e: CustomEvent<{ color: string }>) => set({ bgColor: e.detail.color })}></hp-color-opacity>`,
|
||||
}),
|
||||
})
|
||||
: nothing}`,
|
||||
})}
|
||||
${formCard({
|
||||
id: 'sun',
|
||||
title: t('gs.sun_group'),
|
||||
body: html`
|
||||
${!sunStateOf(host.hass)
|
||||
? callout({ kind: 'warning', role: 'status', text: t('gs.sun_missing') })
|
||||
: nothing}
|
||||
<div class="hpf-north">
|
||||
<span class="hpf-labelrow"><label for="gs-north">${t('gs.north')}</label>${this._help('gs.north.help')}</span>
|
||||
<div class="hpf-inline hpf-wrap">
|
||||
${unitInput({
|
||||
id: 'gs-north', value: d.northDeg === null ? '' : String(d.northDeg), unit: '°',
|
||||
min: 0, max: 359, step: 1, placeholder: t('gs.north_ph'), invalid: !!northProblem,
|
||||
onInput: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
const n = trimmed === '' ? null : Math.round(Number(trimmed));
|
||||
set({ northDeg: n !== null && Number.isFinite(n) ? n : null });
|
||||
},
|
||||
})}
|
||||
${d.northDeg !== null ? textLink(t('gs.north_clear'), () => set({ northDeg: null })) : nothing}
|
||||
</div>
|
||||
<span class="hpf-north-n" aria-hidden="true">${t('gs.north_letter')}</span>
|
||||
${host._renderCompass()}
|
||||
</div>
|
||||
${northProblem ? html`<p class="hpf-error" role="alert">${st(northProblem.message)}</p>` : nothing}
|
||||
${toggleRow({
|
||||
id: 'gs-sun-rays', icon: 'mdi:weather-sunny',
|
||||
title: t('gs.sun_rays'), caption: st('gs.sun_rays_hint'),
|
||||
checked: d.sunRays, onChange: (v) => set({ sunRays: v }),
|
||||
})}
|
||||
${formCard({
|
||||
title: this.host._t('gs.card_plan'),
|
||||
body: html`
|
||||
<label class="dispsection">${this.host._t('gs.wall_group')}</label>
|
||||
${this._renderColorRow('wall_fill', 'gs.wall_fill')}
|
||||
<label class="dispsection">${this.host._t('gs.bg_group')}</label>
|
||||
<div class="colorrow gsrow">
|
||||
<span class="gsl help-inline-label"><label for="gs-bg-mode">${this.host._t('gs.bg_mode')}</label>
|
||||
${this._help('gs.bg_mode.help')}</span>
|
||||
${segmented({
|
||||
name: 'gs-bg-mode',
|
||||
value: this.host._settingsDialog!.bgMode === 'daynight' ? 'daynight' : 'static',
|
||||
ariaLabel: this.host._t('gs.bg_mode'),
|
||||
options: [
|
||||
{ value: 'static', label: this.host._t('gs.bg_static') },
|
||||
{ value: 'daynight', label: this.host._t('gs.bg_daynight') },
|
||||
],
|
||||
onChange: (value) =>
|
||||
(this.host._settingsDialog = { ...this.host._settingsDialog!, bgMode: value === 'daynight' ? 'daynight' : 'static' }),
|
||||
})}
|
||||
</div>
|
||||
${this.host._settingsDialog!.bgMode === 'static'
|
||||
? html`<div class="colorrow gsrow">
|
||||
<hp-color-opacity .label=${this.host._t('gs.bg_color')}
|
||||
.pickerLabels=${this.host._colorPickerLabels}
|
||||
.color=${this.host._settingsDialog!.bgColor || this.host._stageBgHex()}
|
||||
.opacity=${1} .showOpacity=${false}
|
||||
@hp-color-opacity-change=${(e: CustomEvent<{ color: string }>) => {
|
||||
this.host._settingsDialog = { ...this.host._settingsDialog!, bgColor: e.detail.color };
|
||||
}}></hp-color-opacity>
|
||||
${this.host._settingsDialog!.bgColor
|
||||
? html`<button class="btn ghost" @click=${() =>
|
||||
(this.host._settingsDialog = { ...this.host._settingsDialog!, bgColor: null })}>${this.host._t('gs.bg_default')}</button>`
|
||||
: html`<span class="opl">${this.host._t('gs.bg_theme')}</span>`}
|
||||
</div>`
|
||||
: nothing}`,
|
||||
})}
|
||||
${formCard({
|
||||
title: this.host._t('gs.sun_group'),
|
||||
body: html`
|
||||
${!sunStateOf(this.host.hass)
|
||||
? html`<div class="rhint">${this.host._t('gs.sun_missing')}</div>`
|
||||
: nothing}
|
||||
<div class="sunrow">
|
||||
${this.host._renderCompass()}
|
||||
<div class="suncol">
|
||||
<div class="helpfieldlabel compact">
|
||||
<label for="gs-north">${this.host._t('gs.north')}</label>
|
||||
${this._help('gs.north.help')}
|
||||
</div>
|
||||
<div class="colorrow">
|
||||
<input id="gs-north" class="namein tempin" type="number" min="0" max="359" step="1"
|
||||
placeholder=${this.host._t('gs.north_ph')}
|
||||
.value=${this.host._settingsDialog!.northDeg === null ? '' : String(this.host._settingsDialog!.northDeg)}
|
||||
@input=${(e: Event) => {
|
||||
const raw = (e.target as HTMLInputElement).value.trim();
|
||||
const n = raw === '' ? null : Math.round(Number(raw));
|
||||
this.host._settingsDialog = {
|
||||
...this.host._settingsDialog!,
|
||||
northDeg: n !== null && Number.isFinite(n) ? Math.min(359, Math.max(0, n)) : null,
|
||||
};
|
||||
}} />
|
||||
${this.host._settingsDialog!.northDeg !== null
|
||||
? html`<button class="btn ghost" @click=${() =>
|
||||
(this.host._settingsDialog = { ...this.host._settingsDialog!, northDeg: null })}>${this.host._t('gs.north_clear')}</button>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="srcrow">
|
||||
${this._boolInput(this.host._settingsDialog!.sunRays, (v) =>
|
||||
(this.host._settingsDialog = { ...this.host._settingsDialog!, sunRays: v }))}
|
||||
<span>${this.host._t('gs.sun_rays')}</span>
|
||||
</label>
|
||||
${renderSunRayOriginSelect(this.host._settingsDialog!.sunRayOrigin, (key) => this.host._t(key), (sunRayOrigin) => (this.host._settingsDialog = { ...this.host._settingsDialog!, sunRayOrigin }))}`,
|
||||
})}
|
||||
${formCard({
|
||||
title: this.host._t('gs.card_data'),
|
||||
body: html`
|
||||
${this.host._canEdit ? html`
|
||||
<label class="dispsection">${this.host._t('gs.backup_group')}${this._help('gs.backup_group.help')}</label>
|
||||
<div class="backupactions">
|
||||
<button class="btn ghost" @click=${() => this._openBackupExport()}>
|
||||
<ha-icon icon="mdi:download"></ha-icon>${this.host._t('backup.export_open')}
|
||||
</button>
|
||||
<span class="backupupload">
|
||||
<button class="btn ghost" type="button" @click=${(e: Event) =>
|
||||
((e.currentTarget as HTMLElement).nextElementSibling as HTMLInputElement | null)?.click()}>
|
||||
<ha-icon icon="mdi:upload"></ha-icon>${this.host._t('backup.import_open')}
|
||||
</button>
|
||||
<input type="file" accept="application/json,.json" @change=${(event: Event) => this._pickBackupImport(event)} />
|
||||
</span>
|
||||
${this.host._canOptimizeUndo && this.host._undoKind === 'import' ? html`
|
||||
<button class="btn ghost" @click=${() => this._undoPlanOptimization()}
|
||||
?disabled=${this.host._optimizeUndoBusy}>
|
||||
<ha-icon icon="mdi:undo-variant"></ha-icon>${this.host._t('backup.undo_import')}
|
||||
</button>` : nothing}
|
||||
</div>` : nothing}
|
||||
<label class="dispsection">${this.host._t('gs.grid_group')}${this._help('gs.grid_group.help')}</label>
|
||||
<div class="colorrow gsrow">
|
||||
<button class="btn ghost alignall" @click=${() => this._openAlignDialog()}>
|
||||
<ha-icon icon="mdi:broom"></ha-icon>${this.host._t('gs.align_all')}
|
||||
${renderSunRayOriginSegment(d.sunRayOrigin, (key) => t(key), (sunRayOrigin) => set({ sunRayOrigin }))}`,
|
||||
})}
|
||||
${formCard({
|
||||
id: 'data',
|
||||
title: t('gs.card_data'),
|
||||
body: html`
|
||||
${host._canEdit ? html`
|
||||
${subsection({ title: t('gs.backup_group'), help: this._help('gs.backup_group.help') })}
|
||||
<div class="hpf-actions">
|
||||
<button class="btn ghost" @click=${() => this._openBackupExport()}>
|
||||
<ha-icon icon="mdi:download"></ha-icon>${t('backup.export_open')}
|
||||
</button>
|
||||
<span class="backupupload">
|
||||
<button class="btn ghost" type="button" @click=${(e: Event) =>
|
||||
((e.currentTarget as HTMLElement).nextElementSibling as HTMLInputElement | null)?.click()}>
|
||||
<ha-icon icon="mdi:upload"></ha-icon>${t('backup.import_open')}
|
||||
</button>
|
||||
</div>
|
||||
${this.host._canOptimizeUndo && this.host._undoKind !== 'import' ? html`<div class="colorrow gsrow">
|
||||
<button class="btn ghost alignall" @click=${() => this._undoPlanOptimization()}
|
||||
?disabled=${this.host._optimizeUndoBusy}>
|
||||
<ha-icon icon="mdi:undo-variant"></ha-icon>${this.host._t('gs.optimize_undo')}
|
||||
</button>
|
||||
</div>` : nothing}`,
|
||||
})}
|
||||
</div>
|
||||
<div class="row" slot="footer">
|
||||
<button class="btn ghost" @click=${() =>
|
||||
(this.host._settingsDialog = { ...this.host._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: this.host._imperial ? 9.8 : 3, bgColor: null, northDeg: null, bgMode: 'daynight', sunRays: false, sunRayOrigin: 'inner', showRoomTooltip: true, radarShowLive: true, zigbeeTopology: { enabled: false, z2mBaseTopics: [] } })}>
|
||||
${this.host._t('gs.reset')}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn ghost" data-hp="dialog-cancel" @click=${() => (this.host._settingsDialog = null)}>${this.host._t('btn.cancel')}</button>
|
||||
<button class="btn on" data-hp="dialog-confirm" @click=${() => this._saveSettingsDialog()} ?disabled=${this.host._settingsDialog!.busy}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>${this.host._settingsDialog!.busy ? '…' : this.host._t('btn.save')}
|
||||
</button>
|
||||
</div>
|
||||
</hp-dialog>`;
|
||||
}
|
||||
<input type="file" accept="application/json,.json" @change=${(event: Event) => this._pickBackupImport(event)} />
|
||||
</span>
|
||||
${host._canOptimizeUndo && host._undoKind === 'import' ? html`
|
||||
<button class="btn ghost" @click=${() => this._undoPlanOptimization()} ?disabled=${host._optimizeUndoBusy}>
|
||||
<ha-icon icon="mdi:undo-variant"></ha-icon>${t('backup.undo_import')}
|
||||
</button>` : nothing}
|
||||
</div>` : nothing}
|
||||
${subsection({ title: t('gs.grid_group'), help: this._help('gs.grid_group.help') })}
|
||||
<div class="hpf-actions">
|
||||
<button class="btn ghost alignall" @click=${() => this._openAlignDialog()}>
|
||||
<ha-icon icon="mdi:broom"></ha-icon>${t('gs.align_all')}
|
||||
</button>
|
||||
${host._canOptimizeUndo && host._undoKind !== 'import' ? html`
|
||||
<button class="btn ghost alignall" @click=${() => this._undoPlanOptimization()} ?disabled=${host._optimizeUndoBusy}>
|
||||
<ha-icon icon="mdi:undo-variant"></ha-icon>${t('gs.optimize_undo')}
|
||||
</button>` : nothing}
|
||||
</div>`,
|
||||
})}
|
||||
</div>
|
||||
<div class="row dialog-action-footer hpf-footer" slot="footer">
|
||||
<div class="dialog-action-group">
|
||||
<button class="btn ghost" @click=${() => set({
|
||||
colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: host._imperial ? 9.8 : 3,
|
||||
bgColor: null, northDeg: null, bgMode: 'daynight', sunRays: false, sunRayOrigin: 'inner',
|
||||
showRoomTooltip: true, radarShowLive: true, zigbeeTopology: { enabled: false, z2mBaseTopics: [] },
|
||||
})}>${t('gs.reset')}</button>
|
||||
</div>
|
||||
${footerStatus(problems.length
|
||||
? { action: textLink(st('dialog.review_fields', { n: String(problems.length) }), reviewFirst) }
|
||||
: { text: dirty ? st('dialog.unsaved') : '' })}
|
||||
<div class="dialog-action-group dialog-action-commit">
|
||||
<button class="btn ghost" data-hp="dialog-cancel" @click=${requestClose}>${t('btn.cancel')}</button>
|
||||
<button class="btn on" data-hp="dialog-confirm" @click=${() => this._saveSettingsDialog()} ?disabled=${!canSave}
|
||||
title=${problems[0] ? st(problems[0].message) : ''}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '…' : t('btn.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</hp-dialog>`;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* граф, а эта логика нужна только открытому диалогу (К8).
|
||||
*/
|
||||
import type { SpaceDialogState } from '../space-dialog';
|
||||
import { dialogDirty, forgetDialogBaseline, rememberDialogBaseline, stableKey } from './dialog-baseline';
|
||||
|
||||
/**
|
||||
* Ключи, которые не являются настройками: транзиентные поля пикера файлов,
|
||||
@@ -22,33 +23,24 @@ const SPACE_DIALOG_TRANSIENT_KEYS: ReadonlySet<string> = new Set([
|
||||
|
||||
/** Нормализованный отпечаток черновика: только настройки, ключи по алфавиту. */
|
||||
export function spaceDialogDraftKey(d: SpaceDialogState): string {
|
||||
const entries = Object.entries(d)
|
||||
.filter(([key]) => !SPACE_DIALOG_TRANSIENT_KEYS.has(key))
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map(([key, value]) => [key, key === 'planFile' && value ? (value as { name: string }).name : value]);
|
||||
return JSON.stringify(entries);
|
||||
return stableKey(
|
||||
{ ...d, planFile: d.planFile ? d.planFile.name : null },
|
||||
SPACE_DIALOG_TRANSIENT_KEYS,
|
||||
);
|
||||
}
|
||||
|
||||
const spaceBaselines = new WeakMap<object, string>();
|
||||
|
||||
/** Запомнить состояние на момент открытия: вызывается тем, кто открыл диалог. */
|
||||
export function rememberSpaceDialogBaseline(host: object, d: SpaceDialogState): void {
|
||||
spaceBaselines.set(host, spaceDialogDraftKey(d));
|
||||
rememberDialogBaseline(host, 'space', spaceDialogDraftKey(d));
|
||||
}
|
||||
|
||||
export function forgetSpaceDialogBaseline(host: object): void {
|
||||
spaceBaselines.delete(host);
|
||||
forgetDialogBaseline(host, 'space');
|
||||
}
|
||||
|
||||
/**
|
||||
* Есть ли несохранённые изменения. Без снимка (диалог открыт путём, который
|
||||
* его не сделал) ответ `true`: старое поведение — Save доступен — безопаснее
|
||||
* заблокированной кнопки.
|
||||
*/
|
||||
/** Есть ли несохранённые изменения (см. `dialog-baseline.ts` о поведении без снимка). */
|
||||
export function spaceDialogDirty(host: object, d: SpaceDialogState): boolean {
|
||||
const baseline = spaceBaselines.get(host);
|
||||
if (baseline === undefined) return true;
|
||||
return baseline !== spaceDialogDraftKey(d);
|
||||
return dialogDirty(host, 'space', spaceDialogDraftKey(d));
|
||||
}
|
||||
|
||||
export interface SpaceDialogProblem {
|
||||
|
||||
+15
-17
@@ -10651,11 +10651,20 @@ export class HouseplanCard extends LitElement {
|
||||
this._settingsDialog = { ...this._settingsDialog!, northDeg: a };
|
||||
}
|
||||
|
||||
/** The compass dial: drag the «N» arrow around the ring (docs/SUN.md). */
|
||||
/**
|
||||
* The compass dial: drag around the ring to set north (docs/SUN.md).
|
||||
*
|
||||
* #600 §3.1: the dial is the 44 px indicator of the reference — «N» sits on
|
||||
* the label line outside it, the needle rotates to the effective angle and
|
||||
* the degrees live in the number field next to it. The drag input stayed:
|
||||
* it is an existing way to set north and `smoke_sun` drives it for real.
|
||||
* Classes `.compass` / `.unset` are the stable hooks of that smoke.
|
||||
*/
|
||||
private _renderCompass(): TemplateResult {
|
||||
const d = this._settingsDialog!;
|
||||
const deg = d.northDeg;
|
||||
return html`<svg class="compass ${deg === null ? 'unset' : ''}" viewBox="-60 -60 120 120"
|
||||
return html`<span class="hpf-compass compass ${deg === null ? 'hpf-unset unset' : ''}" role="img"
|
||||
aria-label=${this._t('gs.north')}
|
||||
@pointerdown=${(e: PointerEvent) => {
|
||||
(e.currentTarget as Element).setPointerCapture(e.pointerId);
|
||||
this._compassDrag = true;
|
||||
@@ -10664,18 +10673,10 @@ export class HouseplanCard extends LitElement {
|
||||
@pointermove=${(e: PointerEvent) => { if (this._compassDrag) this._compassPoint(e); }}
|
||||
@pointerup=${() => (this._compassDrag = false)}
|
||||
@pointercancel=${() => (this._compassDrag = false)}>
|
||||
<circle class="cring" r="50"></circle>
|
||||
${[0, 45, 90, 135, 180, 225, 270, 315].map(
|
||||
(t) => svg`<line class="ctick ${t % 90 ? 'minor' : ''}" x1="0" y1="-50" x2="0" y2="${t % 90 ? -46 : -43}"
|
||||
transform="rotate(${t})"></line>`,
|
||||
)}
|
||||
<g class="cneedle" transform="rotate(${deg ?? 0})">
|
||||
<line x1="0" y1="34" x2="0" y2="-28"></line>
|
||||
<path d="M -7 -24 L 0 -42 L 7 -24 Z"></path>
|
||||
<text x="0" y="-12" text-anchor="middle">${this._t('gs.north_letter')}</text>
|
||||
</g>
|
||||
<text class="cdeg" x="0" y="26" text-anchor="middle">${deg === null ? '—' : deg + '°'}</text>
|
||||
</svg>`;
|
||||
<svg viewBox="0 0 24 24" style=${`transform: rotate(${deg ?? 0}deg)`} aria-hidden="true">
|
||||
<path d="M12 3v18M12 3l-4 7h8z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -10920,9 +10921,6 @@ export class HouseplanCard extends LitElement {
|
||||
return this._editorRuntimeOrThrow()._rangeInput(min, max, step, value, onInput, disabled, ariaLabel);
|
||||
}
|
||||
|
||||
private _renderColorRow(key: keyof FillColors, labelKey: string): TemplateResult {
|
||||
return this._editorRuntimeOrThrow()._renderColorRow(key, labelKey);
|
||||
}
|
||||
|
||||
/** Glow radius: stored in cm (config.settings.glow_radius_cm), default 3 m. */
|
||||
private get _glowRadiusCm(): number {
|
||||
|
||||
@@ -86,7 +86,6 @@ import {
|
||||
rayRimEdges, rimStops, rimPeakAlpha, RIM_COLOR, type SunRayOrigin,
|
||||
} from './sun';
|
||||
import { dayCycleStageVars, renderDayCycleEnvironment } from './day-cycle-render';
|
||||
import { renderSunRayOriginSelect } from './sun-settings-view';
|
||||
import {
|
||||
furnitureDefaultCm,
|
||||
furnitureGraphic, furnitureCorners,
|
||||
@@ -140,6 +139,7 @@ import {
|
||||
touchSpaceDisplay, type SpaceDialogState,
|
||||
} from './space-dialog';
|
||||
import { rememberSpaceDialogBaseline } from './editors/space-form-state';
|
||||
import { rememberGeneralBaseline } from './editors/general-form-state';
|
||||
import { commitPlanOptimization } from './plan-optimize-write';
|
||||
import { roomTemperatureControls } from './room-temperature-controls';
|
||||
import { openSpaceCopyDialog, renderSpaceCopyDialog, saveSpaceCopy } from './space-copy-runtime';
|
||||
@@ -8840,6 +8840,8 @@ public _openSettingsDialog = (): void => {
|
||||
radarShowLive: this.host._settings.radar?.show_live !== false,
|
||||
showRoomTooltip: showRoomTooltipOf(this.host._settings), zigbeeTopology: zigbeeTopologySettingsOf(this.host._settings), busy: false,
|
||||
};
|
||||
// #600 К10: снимок на момент открытия — от него считается «есть изменения».
|
||||
rememberGeneralBaseline(this.host, this.host._settingsDialog);
|
||||
};
|
||||
|
||||
public _openSupportDialog = (): void => {
|
||||
@@ -10084,20 +10086,6 @@ public _rangeInput(
|
||||
?disabled=${disabled} aria-label=${ariaLabel || nothing} @input=${h} />`;
|
||||
}
|
||||
|
||||
public _renderColorRow(key: keyof FillColors, labelKey: string): TemplateResult {
|
||||
const d = this.host._settingsDialog!;
|
||||
const v = d.colors[key];
|
||||
return html`<div class="colorrow gsrow">
|
||||
<hp-color-opacity .label=${this.host._t(labelKey as any)}
|
||||
.opacityLabel=${this.host._t('space.opacity')}
|
||||
.pickerLabels=${this.host._colorPickerLabels}
|
||||
.color=${v.c} .opacity=${v.a} .showOpacity=${true}
|
||||
@hp-color-opacity-change=${(e: CustomEvent<{ color: string; opacity: number }>) => {
|
||||
this._setFillColor(key, { c: e.detail.color, a: e.detail.opacity });
|
||||
}}></hp-color-opacity>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
public _renderAlignDialog(): TemplateResult {
|
||||
const d = this.host._alignDialog!;
|
||||
const r = d.report;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { LitElement, css, html, nothing, type PropertyValues, type TemplateResul
|
||||
import { langOf } from './i18n';
|
||||
import { hasTopologyTranslation, topologyT, type TopologyI18nKey } from './i18n/topology';
|
||||
import './hp-help';
|
||||
import { ensureFormKitStyles } from './editors/form-kit';
|
||||
import {
|
||||
normalizeZ2mBaseTopic, type ZigbeeTopologySettings,
|
||||
} from './zigbee-topology-settings';
|
||||
@@ -18,6 +19,10 @@ export class HpZigbeeTopologySettings extends LitElement {
|
||||
static properties = {
|
||||
hass: { attribute: false },
|
||||
value: { attribute: false },
|
||||
// #600 §5.1: внутри карточки «Zigbee links» заголовок и «?» рисует карточка,
|
||||
// а блок раскладывается контролами набора: строка-тумблер, callout,
|
||||
// подзаголовки ZHA / Zigbee2MQTT, поле тем и строки действий.
|
||||
embedded: { type: Boolean, reflect: true },
|
||||
savedEnabled: { type: Boolean, attribute: 'saved-enabled' },
|
||||
devices: { attribute: false },
|
||||
registry: { attribute: false },
|
||||
@@ -63,6 +68,16 @@ export class HpZigbeeTopologySettings extends LitElement {
|
||||
}
|
||||
button:disabled, textarea:disabled { opacity: .5; cursor: default; }
|
||||
.warning { color: var(--warning-color, #d89300); font-size: 12px; line-height: 1.45; }
|
||||
/* #600 embedded: примитивы набора внутри карточки; правила набора, которые
|
||||
привязаны к форме диалога, здесь недоступны — они повторены точечно. */
|
||||
:host([embedded]) { display: grid; gap: 12px; }
|
||||
:host([embedded]) textarea.hpf-input { min-height: 72px; border-radius: 7px; padding: 8px 10px;
|
||||
border-color: var(--hpf-line, var(--divider-color, #666)); background: var(--hpf-surface, var(--card-background-color, #202126)); }
|
||||
:host([embedded]) textarea[aria-invalid="true"] { border-color: var(--hpf-danger, var(--error-color, #db543d)); }
|
||||
:host([embedded]) .hpf-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; }
|
||||
:host([embedded]) button { min-height: 44px; border-radius: 8px; padding: 9px 14px; font-weight: 400;
|
||||
border-color: var(--hpf-line, var(--divider-color, #666)); }
|
||||
:host([embedded]) .hpf-hint { margin: 0; }
|
||||
`;
|
||||
|
||||
disconnectedCallback(): void {
|
||||
@@ -89,6 +104,8 @@ export class HpZigbeeTopologySettings extends LitElement {
|
||||
}
|
||||
|
||||
private get _admin(): boolean { return this.hass?.user?.is_admin === true; }
|
||||
public embedded = false;
|
||||
|
||||
private _t(key: TopologyI18nKey, vars?: Record<string, string | number>): string {
|
||||
return topologyT(langOf(this.hass), key, vars);
|
||||
}
|
||||
@@ -179,6 +196,7 @@ export class HpZigbeeTopologySettings extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (this.embedded) return this._renderEmbedded();
|
||||
const admin = this._admin;
|
||||
const enabled = this.value.enabled;
|
||||
const mayLoad = admin && this.savedEnabled;
|
||||
@@ -228,6 +246,59 @@ export class HpZigbeeTopologySettings extends LitElement {
|
||||
</div>` : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Раскладка внутри карточки общего набора (#600, §5.1 SPEC.md). Лист набора
|
||||
* вносится в свой теневой корень: селекторы примитивов не привязаны к форме,
|
||||
* а токены `--hpf-*` наследуются с хоста через границу тени. Заголовок и «?»
|
||||
* функции здесь не рисуются — их даёт карточка (`topology.title` /
|
||||
* `topology.help`), иначе заголовок удваивался (дефект 1 из #600).
|
||||
*/
|
||||
private _renderEmbedded(): TemplateResult {
|
||||
ensureFormKitStyles(this);
|
||||
const admin = this._admin;
|
||||
const enabled = this.value.enabled;
|
||||
const mayLoad = admin && this.savedEnabled;
|
||||
const topics = this._topics();
|
||||
return html`
|
||||
<label class="hpf-toggle">
|
||||
<span class="hpf-toggle-icon" aria-hidden="true"><ha-icon icon="mdi:zigbee"></ha-icon></span>
|
||||
<span class="hpf-toggle-title"><span>${this._t('toggle')}</span></span>
|
||||
<span class="hpf-toggle-caption">${this._t('hint')}</span>
|
||||
<input type="checkbox" .checked=${enabled} ?disabled=${!admin} aria-label=${this._t('toggle')}
|
||||
@change=${(event: Event) => this._emit({ ...this.value,
|
||||
enabled: (event.target as HTMLInputElement).checked })} />
|
||||
</label>
|
||||
${!admin ? html`<p class="hpf-hint">${this._t('admin_only')}</p>` : nothing}
|
||||
${enabled ? html`
|
||||
${!this.savedEnabled ? html`<div class="hpf-callout"><ha-icon icon="mdi:information-outline"></ha-icon><p>${this._t('save_first')}</p></div>` : nothing}
|
||||
<div class="hpf-sub"><h4>${this._t('zha')}</h4></div>
|
||||
<p class="hpf-hint">${this._t('zha_hint')}</p>
|
||||
<div class="hpf-actions">
|
||||
<button class="btn ghost" ?disabled=${!mayLoad || this._busy('zha')} @click=${this._readZha}>
|
||||
<ha-icon icon="mdi:access-point-network"></ha-icon>${this._t('zha_read')}
|
||||
</button>
|
||||
<span class="hpf-hint">${this._status('zha')}</span>
|
||||
</div>
|
||||
<div class="hpf-sub"><h4>${this._t('z2m')}</h4></div>
|
||||
<div class="hpf-field">
|
||||
<span class="hpf-label hpf-labelrow"><label for="z2m-topics">${this._t('z2m_topics')}</label></span>
|
||||
<textarea id="z2m-topics" class="hpf-input" rows="2" spellcheck="false" ?disabled=${!admin}
|
||||
aria-invalid=${this._invalidTopic ? 'true' : nothing}
|
||||
.value=${this._topicText} @input=${(event: Event) =>
|
||||
this._editTopics((event.target as HTMLTextAreaElement).value)}></textarea>
|
||||
${this._invalidTopic ? html`<p class="hpf-error" role="alert">${this._t('error_invalid_topic')}</p>` : nothing}
|
||||
</div>
|
||||
<div class="hpf-callout hpf-warning"><ha-icon icon="mdi:alert-outline"></ha-icon><p>${this._t('z2m_warning')}</p></div>
|
||||
${topics.map((topic) => html`<div class="hpf-actions">
|
||||
<button class="btn ghost" ?disabled=${!mayLoad || this._invalidTopic || this._busy(`z2m:${topic}`)}
|
||||
@click=${() => this._refreshZ2m(topic)}>
|
||||
<ha-icon icon="mdi:refresh"></ha-icon>${this._t('z2m_update')} · ${topic}
|
||||
</button>
|
||||
<span class="hpf-hint">${this._status(`z2m:${topic}`)}</span>
|
||||
</div>`)}` : nothing}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get('hp-zigbee-topology-settings')) {
|
||||
|
||||
+3
-8
@@ -455,14 +455,14 @@
|
||||
"pdf.scale": "Maßstab 1:{n}",
|
||||
"pdf.north": "Nord",
|
||||
"gs.title": "Allgemeine Einstellungen",
|
||||
"gs.light_group": "Füllung: Licht",
|
||||
"gs.light_group": "Beleuchtung",
|
||||
"gs.light_on": "Lichter an",
|
||||
"gs.light_off": "Alle Lichter aus",
|
||||
"gs.temp_group": "Füllung: Temperatur",
|
||||
"gs.temp_group": "Temperatur",
|
||||
"gs.temp_cold": "Kalt",
|
||||
"gs.temp_ok": "Angenehm",
|
||||
"gs.temp_hot": "Heiß",
|
||||
"gs.lqi_group": "Füllung: Zigbee-Signal",
|
||||
"gs.lqi_group": "Zigbee-Signal",
|
||||
"gs.lqi_low": "Schwaches Signal",
|
||||
"gs.lqi_high": "Starkes Signal",
|
||||
"gs.reset": "Auf Standard zurücksetzen",
|
||||
@@ -693,7 +693,6 @@
|
||||
"gs.glow_group": "Glow von Lichtquellen",
|
||||
"gs.glow_base": "Grunddunkelheit des Hauses",
|
||||
"gs.glow_light": "Standardlichtfarbe / Intensität",
|
||||
"gs.wall_group": "Wände",
|
||||
"gs.wall_fill": "Wandfüllung",
|
||||
"gs.glow_radius": "Glow-Radius",
|
||||
"gs.glow_radius.help": "Legt den allgemeinen Radius des Lichtscheins in Metern oder Fuß fest; ein gerätespezifischer Radius überschreibt diesen Wert.",
|
||||
@@ -983,10 +982,8 @@
|
||||
"vac.trail_never": "Nie",
|
||||
"vac.trail_cleaning": "Beim Reinigen",
|
||||
"vac.trail_always": "Immer",
|
||||
"gs.bg_group": "Bühnenhintergrund",
|
||||
"gs.bg_color": "Hintergrund um den Plan",
|
||||
"gs.bg_default": "Standardthema",
|
||||
"gs.bg_theme": "Standardthema",
|
||||
"gs.bg_mode": "Planhintergrund",
|
||||
"gs.bg_mode.help": "„Folgt der Sonne“ verwendet sun.sun und ersatzweise die lokale Browserzeit; „Statische Farbe“ zeigt immer die ausgewählte Farbe.",
|
||||
"gs.bg_mode.help.aria": "Hilfe: allgemeiner Planhintergrund",
|
||||
@@ -1380,11 +1377,9 @@
|
||||
"gs.card_fills": "Füllfarben der Räume",
|
||||
"gs.card_plan": "Plan",
|
||||
"gs.card_data": "Daten",
|
||||
"gs.radar_show_live.help.aria": "Über die Live-Radaranwesenheit",
|
||||
"gs.card_fills.help.aria": "Über die Füllfarben der Räume",
|
||||
"gs.backup_group.help.aria": "Über Sicherung und Übertragung",
|
||||
"gs.grid_group.help.aria": "Über die Planwartung",
|
||||
"gs.radar_show_live.help": "Blendet alle Live-Radarziele ein oder aus. Eine Beobachtungsaufzeichnung wird dadurch weder gestartet noch beendet.",
|
||||
"gs.backup_group.help": "Laden Sie ein tragbares JSON-Backup herunter oder sehen Sie eine Vorschau und importieren Sie ein Backup, das mit House Plan erstellt wurde.",
|
||||
"gs.grid_group.help": "Aktualisiert Datenmodelle, richtet Planelemente am Raster aus und fasst redundante Wandfragmente zusammen. Ein exakter Bericht wird angezeigt, bevor etwas gespeichert wird.",
|
||||
"gs.card_fills.help": "Füllfarben gelten für jeden Bereich; jede Farbe hat ihre eigene Deckkraft. Der Füllmodus wird in den Einstellungen des jeweiligen Bereichs festgelegt.",
|
||||
|
||||
+3
-8
@@ -455,14 +455,14 @@
|
||||
"pdf.scale": "Scale 1:{n}",
|
||||
"pdf.north": "North",
|
||||
"gs.title": "General settings",
|
||||
"gs.light_group": "Fill: lights",
|
||||
"gs.light_group": "Lights",
|
||||
"gs.light_on": "Lights on",
|
||||
"gs.light_off": "All lights off",
|
||||
"gs.temp_group": "Fill: temperature",
|
||||
"gs.temp_group": "Temperature",
|
||||
"gs.temp_cold": "Cold",
|
||||
"gs.temp_ok": "Comfortable",
|
||||
"gs.temp_hot": "Hot",
|
||||
"gs.lqi_group": "Fill: zigbee signal",
|
||||
"gs.lqi_group": "Zigbee signal",
|
||||
"gs.lqi_low": "Weak signal",
|
||||
"gs.lqi_high": "Strong signal",
|
||||
"gs.reset": "Reset to defaults",
|
||||
@@ -693,7 +693,6 @@
|
||||
"gs.glow_group": "Light-source glow",
|
||||
"gs.glow_base": "House darkness",
|
||||
"gs.glow_light": "Default light color / intensity",
|
||||
"gs.wall_group": "Walls",
|
||||
"gs.wall_fill": "Wall fill",
|
||||
"gs.glow_radius": "Glow radius",
|
||||
"gs.glow_radius.help": "Sets the default glow radius in metres or feet; a device-specific radius overrides it.",
|
||||
@@ -983,10 +982,8 @@
|
||||
"vac.trail_never": "Never",
|
||||
"vac.trail_cleaning": "While cleaning",
|
||||
"vac.trail_always": "Always",
|
||||
"gs.bg_group": "Stage background",
|
||||
"gs.bg_color": "Background around the plan",
|
||||
"gs.bg_default": "Theme default",
|
||||
"gs.bg_theme": "theme default",
|
||||
"gs.bg_mode": "Plan background",
|
||||
"gs.bg_mode.help": "“Follows the Sun” uses sun.sun, falling back to the browser's local clock; Static always shows the selected colour.",
|
||||
"gs.bg_mode.help.aria": "Help: general plan background",
|
||||
@@ -1380,11 +1377,9 @@
|
||||
"gs.card_fills": "Room fill colors",
|
||||
"gs.card_plan": "Plan",
|
||||
"gs.card_data": "Data",
|
||||
"gs.radar_show_live.help.aria": "About live radar presence",
|
||||
"gs.card_fills.help.aria": "About room fill colors",
|
||||
"gs.backup_group.help.aria": "About backup and transfer",
|
||||
"gs.grid_group.help.aria": "About plan maintenance",
|
||||
"gs.radar_show_live.help": "Hides or shows all live radar targets. This does not start or stop observation recording.",
|
||||
"gs.backup_group.help": "Download a portable JSON backup or preview and import a backup made by House Plan.",
|
||||
"gs.grid_group.help": "Updates data models, aligns plan elements to the grid and merges redundant wall fragments. An exact report is shown before anything is stored.",
|
||||
"gs.card_fills.help": "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.",
|
||||
|
||||
+3
-8
@@ -455,14 +455,14 @@
|
||||
"pdf.scale": "Échelle 1:{n}",
|
||||
"pdf.north": "Nord",
|
||||
"gs.title": "Paramètres généraux",
|
||||
"gs.light_group": "Remplissage : lumières",
|
||||
"gs.light_group": "Éclairage",
|
||||
"gs.light_on": "Lumières allumées",
|
||||
"gs.light_off": "Toutes les lumières éteintes",
|
||||
"gs.temp_group": "Remplissage : température",
|
||||
"gs.temp_group": "Température",
|
||||
"gs.temp_cold": "Froid",
|
||||
"gs.temp_ok": "Confortable",
|
||||
"gs.temp_hot": "Chaud",
|
||||
"gs.lqi_group": "Remplissage : signal Zigbee",
|
||||
"gs.lqi_group": "Signal Zigbee",
|
||||
"gs.lqi_low": "Signal faible",
|
||||
"gs.lqi_high": "Signal fort",
|
||||
"gs.reset": "Réinitialiser par défaut",
|
||||
@@ -693,7 +693,6 @@
|
||||
"gs.glow_group": "Halo des sources lumineuses",
|
||||
"gs.glow_base": "Obscurité de la maison",
|
||||
"gs.glow_light": "Couleur / intensité de lumière par défaut",
|
||||
"gs.wall_group": "Murs",
|
||||
"gs.wall_fill": "Remplissage des murs",
|
||||
"gs.glow_radius": "Rayon du halo",
|
||||
"gs.glow_radius.help": "Définit le rayon général du halo en mètres ou en pieds ; le rayon propre à un appareil remplace cette valeur.",
|
||||
@@ -983,10 +982,8 @@
|
||||
"vac.trail_never": "Jamais",
|
||||
"vac.trail_cleaning": "Pendant le nettoyage",
|
||||
"vac.trail_always": "Toujours",
|
||||
"gs.bg_group": "Arrière-plan de la scène",
|
||||
"gs.bg_color": "Arrière-plan autour du plan",
|
||||
"gs.bg_default": "Valeur par défaut du thème",
|
||||
"gs.bg_theme": "valeur par défaut du thème",
|
||||
"gs.bg_mode": "Arrière-plan du plan",
|
||||
"gs.bg_mode.help": "« Suit le soleil » utilise sun.sun ou, à défaut, l’heure locale du navigateur ; « Couleur statique » affiche toujours la couleur choisie.",
|
||||
"gs.bg_mode.help.aria": "Aide : arrière-plan général du plan",
|
||||
@@ -1380,11 +1377,9 @@
|
||||
"gs.card_fills": "Couleurs de remplissage des pièces",
|
||||
"gs.card_plan": "Plan",
|
||||
"gs.card_data": "Données",
|
||||
"gs.radar_show_live.help.aria": "À propos de la présence radar en direct",
|
||||
"gs.card_fills.help.aria": "À propos des couleurs de remplissage",
|
||||
"gs.backup_group.help.aria": "À propos de la sauvegarde et du transfert",
|
||||
"gs.grid_group.help.aria": "À propos de la maintenance du plan",
|
||||
"gs.radar_show_live.help": "Masque ou affiche toutes les cibles radar en direct. Cela ne démarre ni n’arrête l’enregistrement des observations.",
|
||||
"gs.backup_group.help": "Téléchargez une sauvegarde JSON portable ou prévisualisez et importez une sauvegarde créée par House Plan.",
|
||||
"gs.grid_group.help": "Met à jour les modèles de données, aligne les éléments du plan sur la grille et fusionne les fragments de murs redondants. Un rapport précis est affiché avant tout enregistrement.",
|
||||
"gs.card_fills.help": "Les couleurs de remplissage s’appliquent à tous les espaces ; chaque couleur possède sa propre opacité. Le mode de remplissage utilisé par un espace se règle dans la boîte de dialogue de cet espace.",
|
||||
|
||||
+3
-8
@@ -455,14 +455,14 @@
|
||||
"pdf.scale": "Масштаб 1:{n}",
|
||||
"pdf.north": "Север",
|
||||
"gs.title": "Общие настройки",
|
||||
"gs.light_group": "Заливка: освещение",
|
||||
"gs.light_group": "Освещение",
|
||||
"gs.light_on": "Свет включён",
|
||||
"gs.light_off": "Весь свет выключен",
|
||||
"gs.temp_group": "Заливка: температура",
|
||||
"gs.temp_group": "Температура",
|
||||
"gs.temp_cold": "Холодно",
|
||||
"gs.temp_ok": "Комфорт",
|
||||
"gs.temp_hot": "Жарко",
|
||||
"gs.lqi_group": "Заливка: зигби-сигнал",
|
||||
"gs.lqi_group": "Сигнал Zigbee",
|
||||
"gs.lqi_low": "Слабый сигнал",
|
||||
"gs.lqi_high": "Сильный сигнал",
|
||||
"gs.reset": "Сбросить к умолчаниям",
|
||||
@@ -693,7 +693,6 @@
|
||||
"gs.glow_group": "Свечение источников света",
|
||||
"gs.glow_base": "Темнота дома",
|
||||
"gs.glow_light": "Цвет света по умолчанию / интенсивность",
|
||||
"gs.wall_group": "Стены",
|
||||
"gs.wall_fill": "Цвет заливки стен",
|
||||
"gs.glow_radius": "Радиус свечения",
|
||||
"gs.glow_radius.help": "Задаёт общий радиус светового пятна в метрах или футах; персональный радиус устройства заменяет это значение.",
|
||||
@@ -983,10 +982,8 @@
|
||||
"vac.trail_never": "Не показывать никогда",
|
||||
"vac.trail_cleaning": "Во время уборки",
|
||||
"vac.trail_always": "Показывать всегда",
|
||||
"gs.bg_group": "Фон сцены",
|
||||
"gs.bg_color": "Цвет фона вокруг плана",
|
||||
"gs.bg_default": "Как в теме",
|
||||
"gs.bg_theme": "по умолчанию — из темы",
|
||||
"gs.bg_mode": "Фон плана",
|
||||
"gs.bg_mode.help": "«Следует за Солнцем» использует sun.sun, а при недоступности — локальные часы браузера; статичный режим всегда показывает выбранный цвет.",
|
||||
"gs.bg_mode.help.aria": "Подсказка: общий фон плана",
|
||||
@@ -1380,11 +1377,9 @@
|
||||
"gs.card_fills": "Цвета заливки комнат",
|
||||
"gs.card_plan": "План",
|
||||
"gs.card_data": "Данные",
|
||||
"gs.radar_show_live.help.aria": "О живом присутствии радаров",
|
||||
"gs.card_fills.help.aria": "О цветах заливки комнат",
|
||||
"gs.backup_group.help.aria": "О резервной копии и переносе",
|
||||
"gs.grid_group.help.aria": "Об обслуживании плана",
|
||||
"gs.radar_show_live.help": "Скрывает или показывает все живые цели радаров. Это не запускает и не останавливает запись наблюдений.",
|
||||
"gs.backup_group.help": "Скачайте переносимую JSON-копию или проверьте и импортируйте копию, созданную House Plan.",
|
||||
"gs.grid_group.help": "Обновляет модели данных, выравнивает элементы по сетке и объединяет лишние фрагменты стен. Перед записью будет показан точный отчёт.",
|
||||
"gs.card_fills.help": "Цвета заливок действуют на все пространства; у каждого цвета своя прозрачность. Какой режим заливки использует пространство — задаётся в его диалоге.",
|
||||
|
||||
@@ -49,5 +49,13 @@
|
||||
"dialog.discard_title": "Änderungen verwerfen?",
|
||||
"dialog.discard_message": "Ihre Änderungen wurden nicht gespeichert.",
|
||||
"dialog.discard_confirm": "Änderungen verwerfen",
|
||||
"dialog.discard_keep": "Weiter bearbeiten"
|
||||
"dialog.discard_keep": "Weiter bearbeiten",
|
||||
"gs.show_room_tooltip_hint": "Zeigen Sie auf einen Raum im Plan, um seine Übersicht zu sehen.",
|
||||
"gs.radar_show_live_hint": "Blendet alle Live-Radarziele ein oder aus. Die Beobachtungsaufzeichnung wird dadurch nicht gestartet oder gestoppt.",
|
||||
"gs.bg_custom_hint": "Eigene Hintergrundfarbe.",
|
||||
"gs.bg_theme_hint": "Hintergrund des Themes wird verwendet.",
|
||||
"gs.sun_rays_hint": "Sonnenstrahlen durch Fensteröffnungen zeichnen.",
|
||||
"gs.error_glow_radius": "Geben Sie einen Radius größer als null ein.",
|
||||
"gs.error_north": "Geben Sie eine ganze Zahl zwischen 0° und 359° ein.",
|
||||
"gs.opacity_of": "Deckkraft: {name}"
|
||||
}
|
||||
|
||||
@@ -49,5 +49,13 @@
|
||||
"dialog.discard_title": "Discard your changes?",
|
||||
"dialog.discard_message": "Your changes haven’t been saved.",
|
||||
"dialog.discard_confirm": "Discard changes",
|
||||
"dialog.discard_keep": "Keep editing"
|
||||
"dialog.discard_keep": "Keep editing",
|
||||
"gs.show_room_tooltip_hint": "Hover a room on the plan to see its summary.",
|
||||
"gs.radar_show_live_hint": "Hides or shows all live radar targets. This does not start or stop observation recording.",
|
||||
"gs.bg_custom_hint": "Custom background color.",
|
||||
"gs.bg_theme_hint": "Using the theme background.",
|
||||
"gs.sun_rays_hint": "Draw sun rays through window openings.",
|
||||
"gs.error_glow_radius": "Enter a radius greater than zero.",
|
||||
"gs.error_north": "Enter a whole number between 0° and 359°.",
|
||||
"gs.opacity_of": "{name} opacity"
|
||||
}
|
||||
|
||||
@@ -49,5 +49,13 @@
|
||||
"dialog.discard_title": "Abandonner vos modifications ?",
|
||||
"dialog.discard_message": "Vos modifications n’ont pas été enregistrées.",
|
||||
"dialog.discard_confirm": "Abandonner",
|
||||
"dialog.discard_keep": "Continuer la modification"
|
||||
"dialog.discard_keep": "Continuer la modification",
|
||||
"gs.show_room_tooltip_hint": "Survolez une pièce sur le plan pour voir son résumé.",
|
||||
"gs.radar_show_live_hint": "Affiche ou masque toutes les cibles radar en direct. Cela ne démarre ni n’arrête l’enregistrement d’observation.",
|
||||
"gs.bg_custom_hint": "Couleur de fond personnalisée.",
|
||||
"gs.bg_theme_hint": "Fond du thème utilisé.",
|
||||
"gs.sun_rays_hint": "Tracer les rayons du soleil à travers les ouvertures des fenêtres.",
|
||||
"gs.error_glow_radius": "Saisissez un rayon supérieur à zéro.",
|
||||
"gs.error_north": "Saisissez un nombre entier entre 0° et 359°.",
|
||||
"gs.opacity_of": "Opacité : {name}"
|
||||
}
|
||||
|
||||
@@ -49,5 +49,13 @@
|
||||
"dialog.discard_title": "Отменить изменения?",
|
||||
"dialog.discard_message": "Изменения не сохранены.",
|
||||
"dialog.discard_confirm": "Отменить изменения",
|
||||
"dialog.discard_keep": "Продолжить редактирование"
|
||||
"dialog.discard_keep": "Продолжить редактирование",
|
||||
"gs.show_room_tooltip_hint": "Наведите на комнату на плане, чтобы увидеть сводку.",
|
||||
"gs.radar_show_live_hint": "Показывает или скрывает все живые цели радаров. Запись наблюдения не запускается и не останавливается.",
|
||||
"gs.bg_custom_hint": "Свой цвет фона.",
|
||||
"gs.bg_theme_hint": "Используется фон темы.",
|
||||
"gs.sun_rays_hint": "Рисовать солнечные лучи через оконные проёмы.",
|
||||
"gs.error_glow_radius": "Введите радиус больше нуля.",
|
||||
"gs.error_north": "Введите целое число от 0° до 359°.",
|
||||
"gs.opacity_of": "Прозрачность: {name}"
|
||||
}
|
||||
|
||||
@@ -67,20 +67,6 @@ export const dialogsStyles = css`
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* the compass dial in the general settings (docs/SUN.md) */
|
||||
.sunrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-5);
|
||||
margin: var(--sp-3) 0;
|
||||
}
|
||||
.suncol {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
}
|
||||
.recoveryoverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -205,6 +205,9 @@ function extrasCss(options: FormKitCssOptions): string {
|
||||
.${p}-unit > span { padding: 0 11px 0 3px; white-space: nowrap; color: var(${tokens.muted}); font-size: .875rem; }
|
||||
.${p}-unit:focus-within { outline: 2px solid var(${tokens.accent}); outline-offset: 2px; }
|
||||
.${p}-unit.${p}-unit-wide, .${p}-unit.${p}-unit-wide > input { width: 100%; min-width: 0; }
|
||||
.${p}-field > .${p}-unit:not(.${p}-unit-wide) { justify-self: start; }
|
||||
.${p}-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; }
|
||||
.${p}-actions > input[type="file"] { display: none; }
|
||||
.${p}-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -413,7 +416,7 @@ function extrasCss(options: FormKitCssOptions): string {
|
||||
.${p}-north > .${p}-labelrow { grid-area: 1 / 1; min-height: 28px; }
|
||||
.${p}-north > .${p}-select, .${p}-north > .${p}-unit, .${p}-north > .${p}-input { grid-area: 2 / 1; }
|
||||
.${p}-north > .${p}-north-n { grid-area: 1 / 2; text-align: center; font-size: .875rem; line-height: 28px; color: var(${tokens.muted}); }
|
||||
.${p}-compass { grid-area: 2 / 2; display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; border: 1px solid var(${tokens.line}); border-radius: 50%; background: var(${tokens.canvas}); color: var(${tokens.accent}); }
|
||||
.${p}-compass { grid-area: 2 / 2; box-sizing: border-box; display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; border: 1px solid var(${tokens.line}); border-radius: 50%; background: var(${tokens.canvas}); color: var(${tokens.accent}); }
|
||||
.${p}-compass svg { width: 24px; height: 24px; transform-origin: 50% 50%; transform-box: view-box; }
|
||||
.${p}-compass.${p}-unset { color: var(${tokens.muted}); }
|
||||
.${p}-tint { padding: 14px; border: 1px solid var(${tokens.tintLine}); border-radius: 9px; background: var(${tokens.tint}); }
|
||||
|
||||
@@ -128,10 +128,9 @@ export const planStyles = css`
|
||||
.sunlayer {
|
||||
pointer-events: none;
|
||||
}
|
||||
/* #600: компас — индикатор 44 px набора (класс hpf-compass), геометрия и
|
||||
цвета живут в листе набора; здесь только поведение перетаскивания. */
|
||||
.compass {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
flex: none;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
@@ -139,39 +138,6 @@ export const planStyles = css`
|
||||
.compass:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.compass .cring {
|
||||
fill: rgba(255, 255, 255, 0.04);
|
||||
stroke: var(--divider-color, #444);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.compass .ctick {
|
||||
stroke: var(--secondary-text-color, #9aa4ad);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.compass .ctick.minor {
|
||||
stroke-width: 1;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.compass .cneedle line {
|
||||
stroke: var(--primary-color, #3ea6ff);
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.compass .cneedle path {
|
||||
fill: var(--primary-color, #3ea6ff);
|
||||
}
|
||||
.compass .cneedle text {
|
||||
fill: var(--text-primary-color, #fff);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.compass .cdeg {
|
||||
fill: var(--secondary-text-color, #9aa4ad);
|
||||
font-size: 13px;
|
||||
}
|
||||
.compass.unset .cneedle {
|
||||
opacity: 0.35;
|
||||
}
|
||||
/* HP-1552: first-open boot veil — the plan hides until the stage height settles */
|
||||
.stage.hpboot .zoomwrap,
|
||||
.stage.hpboot .zoombadge {
|
||||
|
||||
+20
-12
@@ -1,20 +1,28 @@
|
||||
import { html, type TemplateResult } from 'lit';
|
||||
import type { TemplateResult } from 'lit';
|
||||
import { field, segmented } from './editors/form-kit';
|
||||
import type { I18nKey } from './i18n';
|
||||
import type { SunRayOrigin } from './sun';
|
||||
|
||||
export function renderSunRayOriginSelect(
|
||||
/**
|
||||
* Источник солнечных лучей — сегмент из двух вариантов (#600 §5.1 вместо
|
||||
* `<select>`). Значение и ключ прежние: `sunRayOrigin` ∈ inner | outer.
|
||||
*/
|
||||
export function renderSunRayOriginSegment(
|
||||
value: SunRayOrigin,
|
||||
translate: (key: I18nKey) => string,
|
||||
changed: (value: SunRayOrigin) => void,
|
||||
): TemplateResult {
|
||||
return html`<div class="colorrow gsrow">
|
||||
<span class="gsl"><label for="gs-sun-ray-origin">${translate('gs.sun_ray_origin')}</label></span>
|
||||
<select id="gs-sun-ray-origin" class="areasel"
|
||||
@change=${(event: Event) => changed(
|
||||
(event.target as HTMLSelectElement).value === 'outer' ? 'outer' : 'inner',
|
||||
)}>
|
||||
<option value="inner" ?selected=${value === 'inner'}>${translate('gs.sun_ray_origin.inner')}</option>
|
||||
<option value="outer" ?selected=${value === 'outer'}>${translate('gs.sun_ray_origin.outer')}</option>
|
||||
</select>
|
||||
</div>`;
|
||||
return field({
|
||||
label: translate('gs.sun_ray_origin'),
|
||||
control: segmented<SunRayOrigin>({
|
||||
name: 'gs-sun-ray-origin',
|
||||
value,
|
||||
ariaLabel: translate('gs.sun_ray_origin'),
|
||||
options: [
|
||||
{ value: 'inner', label: translate('gs.sun_ray_origin.inner') },
|
||||
{ value: 'outer', label: translate('gs.sun_ray_origin.outer') },
|
||||
],
|
||||
onChange: (next) => changed(next === 'outer' ? 'outer' : 'inner'),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user