/** * House Plan Card — интерактивный план дома (дача, Кирилловское) как нативная Lovelace-карточка. * Работает от объекта `hass` (без токена). Раскладка иконок хранится на сервере * через WS-команды интеграции `houseplan` (fallback — localStorage). */ import { LitElement, html, svg, css, nothing, TemplateResult, PropertyValues } from 'lit'; import { ROOMS, FLOOR_VB, FLOOR_TITLES, AREA_NAMES, Room } from './data/house'; import { FLOOR_BG, FLOOR_BG_RECT } from './data/backgrounds'; import { EXCLUDED_DOMAINS, GROUP_TITLES, iconFor, DOMAIN_PRIORITY } from './rules'; import './editor'; const CARD_VERSION = '1.1.0'; const LS_KEY = 'houseplan_card_layout_v1'; interface DevItem { id: string; name: string; model: string; area: string; floor: string; icon: string; entities: string[]; primary?: string; temp?: number | null; members?: { id: string; name: string; primary?: string }[]; link?: string | null; linkPrimary?: string; } interface CardConfig { type: string; title?: string; default_floor?: string; icon_size?: number; show_temperature?: boolean; live_states?: boolean; rooms?: Room[]; } const fireEvent = (node: EventTarget, type: string, detail?: unknown) => { const ev = new Event(type, { bubbles: true, composed: true }) as any; ev.detail = detail ?? {}; node.dispatchEvent(ev); }; const navigate = (path: string) => { history.pushState(null, '', path); fireEvent(window, 'location-changed', { replace: false }); }; const debounce = void>(fn: T, ms: number) => { let t: number | undefined; return (...a: Parameters) => { clearTimeout(t); t = window.setTimeout(() => fn(...a), ms); }; }; class HouseplanCard extends LitElement { public hass?: any; private _config?: CardConfig; private _floor = 'f1'; private _edit = false; private _layout: Record = {}; private _serverStorage = false; private _layoutLoaded = false; private _devices: DevItem[] = []; private _regSignature = ''; private _defPos: Record = {}; private _menuDev: DevItem | null = null; private _menuXY = { x: 0, y: 0 }; private _tip: { x: number; y: number; title: string; meta: string } | null = null; private _selId: string | null = null; private _toast = ''; private _toastTimer?: number; // drag state private _drag: { id: string; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null; static properties = { hass: { attribute: false }, _config: { state: true }, _floor: { state: true }, _edit: { state: true }, _layout: { state: true }, _devices: { state: true }, _menuDev: { state: true }, _tip: { state: true }, _selId: { state: true }, _toast: { state: true }, }; public static getConfigElement() { return document.createElement('houseplan-card-editor'); } public static getStubConfig(): Partial { return { type: 'custom:houseplan-card', title: 'План дома · Кирилловское' }; } public setConfig(config: CardConfig): void { this._config = { icon_size: 2.5, show_temperature: true, live_states: true, ...config }; if (config.default_floor) this._floor = config.default_floor; } public getCardSize(): number { return 12; } private get _rooms(): Room[] { return this._config?.rooms?.length ? this._config.rooms : ROOMS; } private get _areaSet(): Set { return new Set(this._rooms.filter((r) => r.area).map((r) => r.area)); } private _areaRoom(area: string): Room | undefined { return this._rooms.find((r) => r.area === area); } protected willUpdate(changed: PropertyValues): void { if (changed.has('hass') && this.hass) { if (!this._layoutLoaded) { this._layoutLoaded = true; this._loadLayout(); } this._maybeRebuildDevices(); } } // ---------- раскладка: сервер (интеграция houseplan) или localStorage ---------- private async _loadLayout(): Promise { try { const resp = await this.hass.callWS({ type: 'houseplan/layout/get' }); this._layout = (resp && resp.layout) || {}; this._serverStorage = true; } catch (e) { this._serverStorage = false; try { this._layout = JSON.parse(localStorage.getItem(LS_KEY) || '{}') || {}; } catch { this._layout = {}; } this._showToast('Интеграция houseplan не найдена — позиции сохраняются локально'); } this.requestUpdate(); } private _persistLayout = debounce(() => { if (this._serverStorage) { this.hass .callWS({ type: 'houseplan/layout/set', layout: this._layout }) .catch((e: any) => this._showToast('Не удалось сохранить на сервере: ' + (e?.message || e))); } else { localStorage.setItem(LS_KEY, JSON.stringify(this._layout)); } }, 600); // ---------- построение списка устройств из реестров hass ---------- private _maybeRebuildDevices(): void { const h = this.hass; if (!h?.devices || !h?.entities || !h?.areas) return; const sig = Object.keys(h.devices).length + ':' + Object.keys(h.entities).length + ':' + Object.keys(h.areas).length; if (sig === this._regSignature && this._devices.length) return; this._regSignature = sig; this._devices = this._buildDevices(); this._defPos = this._defaultPositions(); } private _entitiesByDevice(): Record { const map: Record = {}; for (const [eid, ent] of Object.entries(this.hass.entities)) { if (ent?.device_id) (map[ent.device_id] = map[ent.device_id] || []).push(eid); } return map; } private _domainOfDevice(dev: any, entIds: string[]): string { if (dev.identifiers?.[0]?.[0]) return dev.identifiers[0][0]; for (const eid of entIds) { const p = this.hass.entities[eid]?.platform; if (p) return p; } return ''; } private _primaryEntity(entIds: string[], icon: string): string | undefined { const ents = entIds .map((eid) => ({ eid, reg: this.hass.entities[eid], st: this.hass.states[eid] })) .filter((e) => e.reg && !e.reg.hidden); const usable = ents.filter((e) => !e.reg.entity_category); // без diagnostic/config const pool = usable.length ? usable : ents; // датчик температуры — сразу температурная сущность if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') { const t = pool.find((e) => this._isTempEntity(e.eid)); if (t) return t.eid; } for (const dom of DOMAIN_PRIORITY) { const found = pool.find((e) => e.eid.split('.')[0] === dom); if (found) return found.eid; } return pool[0]?.eid; } private _isTempEntity(eid: string): boolean { const st = this.hass.states[eid]; if (!st) return /_temperature$/.test(eid); const a = st.attributes || {}; return ( a.device_class === 'temperature' || /°C|°F/.test(a.unit_of_measurement || '') || /_temperature$/.test(eid) ); } private _tempFor(entIds: string[]): number | null { for (const eid of entIds) { if (!this._isTempEntity(eid)) continue; const st = this.hass.states[eid]; if (!st) continue; const v = parseFloat(st.state); if (!isNaN(v)) return Math.round(v * 10) / 10; } return null; } /** Курирование + группировка — перенос buildDevices() из прототипа. */ private _buildDevices(): DevItem[] { const h = this.hass; const areaSet = this._areaSet; const entsBy = this._entitiesByDevice(); const groupByArea: Record = {}; for (const dev of Object.values(h.devices)) { if (dev.model === 'Group' && dev.area_id) groupByArea[dev.area_id] = dev; } const seen: Record = {}; const out: DevItem[] = []; for (const dev of Object.values(h.devices)) { const area = dev.area_id; if (!area || !areaSet.has(area)) continue; if (dev.entry_type === 'service') continue; const entIds = entsBy[dev.id] || []; const dom = this._domainOfDevice(dev, entIds); if (EXCLUDED_DOMAINS.has(dom)) continue; if (dev.model === 'Group') continue; if (/scene/i.test(dev.model || '')) continue; if (/bridge/i.test((dev.model || '') + (dev.name || ''))) continue; if (dom === 'myheat' && dev.via_device_id) continue; const name = (dev.name_by_user || dev.name || 'без имени').trim(); const key = name + '|' + area; if (seen[key]) continue; seen[key] = 1; const icon = iconFor(name, dev.model); const room = this._areaRoom(area)!; const item: DevItem = { id: dev.id, name, model: dev.model || '', area, floor: room.floor, icon, entities: entIds, }; item.primary = this._primaryEntity(entIds, icon); if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = this._tempFor(entIds); out.push(item); } // группировка ламп: ≥2 ламп в комнате → одна групповая иконка const lamps: Record = {}; const rest: DevItem[] = []; for (const d of out) { if (d.icon === 'mdi:lightbulb') (lamps[d.area] = lamps[d.area] || []).push(d); else rest.push(d); } for (const area of Object.keys(lamps)) { const ms = lamps[area]; if (ms.length < 2) { rest.push(...ms); continue; } const grpDev = groupByArea[area]; const item: DevItem = { id: 'grp_' + area, name: GROUP_TITLES[area] || 'Лампы', model: 'группа · ' + ms.length + ' шт', area, floor: this._areaRoom(area)!.floor, icon: 'mdi:lightbulb-group', entities: ms.flatMap((m) => m.entities), link: grpDev?.id || null, members: ms.map((m) => ({ id: m.id, name: m.name, primary: m.primary })), }; if (grpDev) { const grpEnts = this._entitiesByDevice()[grpDev.id] || []; item.linkPrimary = this._primaryEntity(grpEnts, 'mdi:lightbulb'); } rest.push(item); } return rest; } // ---------- позиции ---------- private _defaultPositions(): Record { const map: Record = {}; for (const r of this._rooms) { const ds = this._devices.filter((d) => d.area === r.area && d.floor === r.floor && r.area); if (!ds.length) continue; const pad = 20; const iw = r.w - pad * 2; const ih = r.h - pad * 2; const cols = Math.max(1, Math.round(Math.sqrt((ds.length * iw) / Math.max(ih, 1)))); const rows = Math.ceil(ds.length / cols); const cw = iw / cols; const ch = ih / Math.max(rows, 1); ds.forEach((d, i) => { const c = i % cols; const rw = Math.floor(i / cols); map[d.id] = { x: r.x + pad + cw * (c + 0.5), y: r.y + pad + ch * (rw + 0.5) }; }); } return map; } private _pos(d: DevItem): { x: number; y: number } { const vb = FLOOR_VB[this._floor]; return this._layout[d.id] || this._defPos[d.id] || { x: vb[0] + vb[2] / 2, y: vb[1] + vb[3] / 2 }; } // ---------- живые состояния ---------- private _stateClass(d: DevItem): string { if (!this._config?.live_states) return ''; const st = (eid?: string) => (eid ? this.hass.states[eid] : undefined); const list = d.members ? (d.members.map((m) => st(m.primary)).filter(Boolean) as any[]) : ([st(d.primary)].filter(Boolean) as any[]); if (!list.length) return ''; if (list.every((s) => s.state === 'unavailable')) return 'unavail'; const p = d.members ? list.find((s) => s.state === 'on') || list[0] : list[0]; const dom = p.entity_id.split('.')[0]; if (['light', 'switch', 'fan', 'humidifier'].includes(dom)) return p.state === 'on' ? 'on' : ''; if (dom === 'cover' || dom === 'valve') return ['open', 'opening'].includes(p.state) ? 'open' : ''; if (dom === 'lock') return ['unlocked', 'open'].includes(p.state) ? 'open' : ''; if (dom === 'binary_sensor') { const dc = p.attributes?.device_class; if (['door', 'window', 'garage_door', 'opening', 'gas', 'smoke', 'moisture', 'problem'].includes(dc)) return p.state === 'on' ? 'open' : ''; } if (dom === 'media_player') return ['playing', 'on'].includes(p.state) ? 'on' : ''; if (dom === 'vacuum') return ['cleaning', 'returning'].includes(p.state) ? 'on' : ''; return ''; } private _liveTemp(d: DevItem): number | null { if (!this._config?.show_temperature) return null; if (d.icon !== 'mdi:thermometer' && d.icon !== 'mdi:air-filter') return null; return this._tempFor(d.entities); } // ---------- взаимодействие ---------- private _openMoreInfo(entityId?: string): void { if (!entityId) { this._showToast('У устройства нет подходящей сущности'); return; } fireEvent(this, 'hass-more-info', { entityId }); } private _clickDevice(ev: MouseEvent, d: DevItem): void { ev.stopPropagation(); if (this._drag?.moved) return; if (this._edit) { this._selId = d.id; return; } if (d.members) { this._menuDev = d; this._menuXY = { x: ev.clientX, y: ev.clientY }; this._tip = null; return; } this._openMoreInfo(d.primary); } private _clickRoom(r: Room): void { if (this._edit) return; navigate('/config/areas/area/' + r.area); } // drag private _pointerDown(ev: PointerEvent, d: DevItem): void { if (!this._edit) return; ev.preventDefault(); const p = this._pos(d); this._drag = { id: d.id, sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false }; (ev.target as HTMLElement).setPointerCapture(ev.pointerId); this._tip = null; } private _pointerMove(ev: PointerEvent, d: DevItem): void { if (!this._drag || this._drag.id !== d.id) return; const stage = this.renderRoot.querySelector('.stage') as HTMLElement; if (!stage) return; const vb = FLOOR_VB[this._floor]; const rect = stage.getBoundingClientRect(); const dx = ((ev.clientX - this._drag.sx) / rect.width) * vb[2]; const dy = ((ev.clientY - this._drag.sy) / rect.height) * vb[3]; if (Math.abs(ev.clientX - this._drag.sx) + Math.abs(ev.clientY - this._drag.sy) > 3) this._drag.moved = true; let nx = Math.round(this._drag.ox + dx); let ny = Math.round(this._drag.oy + dy); nx = Math.max(vb[0] + 6, Math.min(vb[0] + vb[2] - 6, nx)); ny = Math.max(vb[1] + 6, Math.min(vb[1] + vb[3] - 6, ny)); this._layout = { ...this._layout, [d.id]: { x: nx, y: ny } }; } private _pointerUp(_ev: PointerEvent, d: DevItem): void { if (!this._drag || this._drag.id !== d.id) return; const moved = this._drag.moved; this._drag = moved ? this._drag : null; // click-обработчик проверит moved if (moved) { this._persistLayout(); this._selId = d.id; window.setTimeout(() => (this._drag = null), 0); } } private _applyXY(axis: 'x' | 'y', val: string): void { if (!this._selId) return; const n = parseInt(val, 10); if (isNaN(n)) return; const d = this._devices.find((x) => x.id === this._selId); if (!d) return; const vb = FLOOR_VB[this._floor]; const p = { ...this._pos(d) }; p[axis] = axis === 'x' ? Math.max(vb[0] + 6, Math.min(vb[0] + vb[2] - 6, n)) : Math.max(vb[1] + 6, Math.min(vb[1] + vb[3] - 6, n)); this._layout = { ...this._layout, [d.id]: p }; this._persistLayout(); } private _resetLayout(): void { if (!confirm('Сбросить позиции всех иконок к авто-раскладке?')) return; this._layout = {}; this._persistLayout(); } private _showToast(msg: string): void { this._toast = msg; clearTimeout(this._toastTimer); this._toastTimer = window.setTimeout(() => { this._toast = ''; }, 3500); } private _showTip(ev: MouseEvent, title: string, meta: string): void { if (this._drag) return; this._tip = { x: ev.clientX, y: ev.clientY, title, meta }; } // ---------- рендер ---------- protected render(): TemplateResult | typeof nothing { if (!this._config || !this.hass) return nothing; const vb = FLOOR_VB[this._floor]; const bg = FLOOR_BG[this._floor]; const bgRect = FLOOR_BG_RECT[this._floor]; const rooms = this._rooms.filter((r) => r.floor === this._floor && r.area); const devs = this._devices.filter((d) => d.floor === this._floor); // размер иконки в % от ширины видимой области плана (legacy px-значения > 8 игнорируются) const cfgSize = this._config.icon_size ?? 2.5; const iconPct = cfgSize > 8 ? 2.5 : cfgSize; return html`
${this._config.title || 'План дома'}
${Object.keys(FLOOR_VB).map( (f) => html``, )}
${devs.length} устр.
${bg && bgRect ? svg`` : nothing} ${rooms.map( (r) => svg` this._clickRoom(r)} @mousemove=${(e: MouseEvent) => this._showTip(e, r.name, 'комната — открыть зону')} @mouseleave=${() => (this._tip = null)} > ${!bg ? svg`${r.name}` : nothing}`, )}
${devs.map((d) => this._renderDevice(d, vb))}
${this._edit ? this._renderEditbar() : nothing} ${this._menuDev ? this._renderMenu() : nothing} ${this._tip ? html`
${this._tip.title}${this._tip.meta ? html`${this._tip.meta}` : nothing}
` : nothing} ${this._toast ? html`
${this._toast}
` : nothing}
`; } private _renderDevice(d: DevItem, vb: number[]): TemplateResult { const p = this._pos(d); const left = ((p.x - vb[0]) / vb[2]) * 100; const top = ((p.y - vb[1]) / vb[3]) * 100; const cls = this._stateClass(d); const temp = this._liveTemp(d); return html`
this._clickDevice(e, d)} @mousemove=${(e: MouseEvent) => this._showTip(e, d.name, d.model + (temp != null ? ' · ' + temp + '°' : ''))} @mouseleave=${() => (this._tip = null)} @pointerdown=${(e: PointerEvent) => this._pointerDown(e, d)} @pointermove=${(e: PointerEvent) => this._pointerMove(e, d)} @pointerup=${(e: PointerEvent) => this._pointerUp(e, d)} > ${temp != null ? html`${temp}°` : nothing}
`; } private _renderEditbar(): TemplateResult { const sel = this._selId ? this._devices.find((d) => d.id === this._selId) : null; const p = sel ? this._pos(sel) : null; return html`
${sel ? sel.name : 'Режим правки — тащите иконки мышью'} ${sel && p ? html` this._applyXY('x', (e.target as HTMLInputElement).value)} /> this._applyXY('y', (e.target as HTMLInputElement).value)} />` : nothing} ${this._serverStorage ? 'сохранение: сервер (для всех)' : 'сохранение: этот браузер'}
`; } private _renderMenu(): TemplateResult { const d = this._menuDev!; return html``; } static styles = css` :host { --hp-bg: var(--card-background-color, #16212e); --hp-line: var(--divider-color, #2b3d4f); --hp-txt: var(--primary-text-color, #e6edf3); --hp-muted: var(--secondary-text-color, #8aa0b3); --hp-accent: var(--primary-color, #3ea6ff); --hp-on: #ffd45c; --hp-open: #ff9f43; } ha-card { overflow: hidden; } .head { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--hp-line); flex-wrap: wrap; } .title { font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 6px; white-space: nowrap; } .title ha-icon { color: var(--hp-accent); --mdc-icon-size: 18px; } .tabs { display: flex; gap: 4px; background: rgba(127, 127, 127, 0.12); padding: 3px; border-radius: 10px; } .tab { border: 0; background: transparent; color: var(--hp-muted); padding: 6px 13px; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; transition: 0.15s; font-family: inherit; } .tab:hover { color: var(--hp-txt); } .tab.active { background: var(--hp-accent); color: var(--text-primary-color, #fff); } .count { font-size: 12px; color: var(--hp-muted); } .spacer { flex: 1; } .btn { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--hp-line); background: transparent; color: var(--hp-txt); padding: 6px 10px; border-radius: 8px; cursor: pointer; transition: 0.15s; } .btn ha-icon { --mdc-icon-size: 17px; } .btn:hover { border-color: var(--hp-accent); } .btn.on { background: var(--hp-accent); color: var(--text-primary-color, #fff); border-color: var(--hp-accent); } .btn.ghost { border: none; } .stage { position: relative; width: 100%; container-type: inline-size; /* cqw = % ширины плана для размеров иконок */ } .stage svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; } .room { transition: 0.12s; cursor: pointer; } .room.overlay { fill: transparent; stroke: transparent; stroke-width: 2; } .room.overlay:hover { fill: rgba(62, 166, 255, 0.18); stroke: var(--hp-accent); } .room.yard { fill: rgba(75, 140, 90, 0.14); stroke: #4b8c5a; stroke-width: 2; } .room.yard:hover { fill: rgba(75, 140, 90, 0.24); stroke: #6fbf86; } .rlabel { fill: var(--hp-muted); font-size: 15px; font-weight: 600; pointer-events: none; text-anchor: middle; } .stage.edit .room { pointer-events: none; } .devlayer { position: absolute; inset: 0; pointer-events: none; } .dev { position: absolute; width: var(--icon-size, 2.5cqw); height: var(--icon-size, 2.5cqw); margin: calc(var(--icon-size, 2.5cqw) / -2) 0 0 calc(var(--icon-size, 2.5cqw) / -2); border-radius: 22%; background: var(--hp-bg); border: 1px solid var(--hp-line); display: flex; align-items: center; justify-content: center; color: var(--hp-txt); cursor: pointer; pointer-events: auto; transition: transform 0.08s, background 0.15s, border-color 0.15s, opacity 0.2s; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45); z-index: 2; } .dev ha-icon { --mdc-icon-size: calc(var(--icon-size, 2.5cqw) * 0.62); } .dev:hover { background: var(--hp-accent); color: var(--text-primary-color, #fff); transform: scale(1.35); z-index: 5; } .dev.on { background: var(--hp-on); border-color: var(--hp-on); color: #503c00; box-shadow: 0 0 8px rgba(255, 212, 92, 0.7); } .dev.open { background: var(--hp-open); border-color: var(--hp-open); color: #4a2800; } .dev.unavail { opacity: 0.35; } .dev.sel { border-color: #ffc14d; box-shadow: 0 0 0 3px rgba(255, 193, 77, 0.35); } .stage.edit .dev { cursor: grab; } .dev .tval { position: absolute; left: 100%; top: 50%; transform: translateY(-50%); margin-left: calc(var(--icon-size, 2.5cqw) * 0.1); background: rgba(4, 18, 31, 0.9); border: 1px solid var(--hp-accent); border-radius: calc(var(--icon-size, 2.5cqw) * 0.18); padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14); font-size: calc(var(--icon-size, 2.5cqw) * 0.45); font-weight: 700; line-height: calc(var(--icon-size, 2.5cqw) * 0.68); color: #dff1ff; white-space: nowrap; pointer-events: none; } .editbar { display: flex; align-items: center; gap: 10px; padding: 9px 14px; border-top: 1px solid var(--hp-line); font-size: 13px; flex-wrap: wrap; } .editbar .warn { color: #ffc14d; } .editbar .sname { font-weight: 600; } .editbar input { width: 74px; background: transparent; border: 1px solid var(--hp-line); color: var(--hp-txt); border-radius: 6px; padding: 5px 7px; font-size: 13px; } .editbar label, .editbar .hint { color: var(--hp-muted); font-size: 12px; } .menuwrap { position: fixed; inset: 0; z-index: 80; } .menu { position: fixed; background: var(--hp-bg); border: 1px solid var(--hp-accent); border-radius: 10px; box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45); min-width: 210px; max-width: 300px; overflow: hidden; transform: translate(0, 8px); } .menu .hd { padding: 8px 12px; font-weight: 600; font-size: 12.5px; border-bottom: 1px solid var(--hp-line); display: flex; align-items: center; gap: 6px; } .menu .hd ha-icon, .menu .it.all ha-icon { color: var(--hp-accent); --mdc-icon-size: 16px; } .menu .it { padding: 8px 12px; font-size: 12.5px; cursor: pointer; display: flex; align-items: center; gap: 8px; } .menu .it ha-icon { --mdc-icon-size: 16px; color: var(--hp-muted); } .menu .it:hover { background: rgba(127, 127, 127, 0.15); } .menu .it.all { color: var(--hp-accent); font-weight: 600; } .tip { position: fixed; pointer-events: none; background: var(--hp-bg); border: 1px solid var(--hp-accent); color: var(--hp-txt); padding: 6px 10px; border-radius: 8px; font-size: 12.5px; box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45); z-index: 99; max-width: 260px; } .tip .m { color: var(--hp-muted); font-size: 11px; display: block; } .toast { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%); background: var(--hp-bg); border: 1px solid var(--hp-accent); color: var(--hp-txt); padding: 9px 16px; border-radius: 10px; font-size: 13px; box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45); z-index: 120; max-width: 90vw; } `; } if (!customElements.get('houseplan-card')) { customElements.define('houseplan-card', HouseplanCard); } (wi