UNPKG

meteoalarm-card

Version:

Meteoalarm card for Home Assistant Lovelace UI

431 lines (387 loc) 13.4 kB
import { ActionHandlerEvent, debounce, EntityConfig, handleAction, hasAction, hasConfigOrEntityChanged, HomeAssistant, LovelaceCardConfig, LovelaceCardEditor, } from 'custom-card-helpers'; import { HassEntity } from 'home-assistant-js-websocket'; import { CSSResultGroup, html, LitElement, PropertyValues, TemplateResult } from 'lit'; import { customElement, property, state } from 'lit/decorators'; import { ifDefined } from 'lit/directives/if-defined'; import ResizeObserver from 'resize-observer-polyfill'; import Swiper, { Pagination } from 'swiper'; import { version as CARD_VERSION } from '../package.json'; import EventsParser from './events-praser'; import swiperStyles from './external/swiperStyles'; import { actionHandler } from './helpers/action-handler-directive'; import { processConfigEntities } from './helpers/process-config-entities'; import INTEGRATIONS from './integrations/integrations'; import { localize } from './localize/localize'; import { getCanvasFont, getTextWidth } from './measure-text'; import styles from './styles'; import { MeteoalarmCardConfig, MeteoalarmIntegration, MeteoalarmIntegrationEntityType, MeteoalarmScalingMode, } from './types'; // eslint-disable-next-line no-console console.info( `%c MeteoalarmCard %c ${CARD_VERSION} `, 'color: white; font-weight: bold; background: #1c1c1c', 'color: white; font-weight: bold; background: #db4437', ); // Push card into UI card picker (window as any).customCards = (window as any).customCards || []; (window as any).customCards.push({ preview: true, type: 'meteoalarm-card', name: localize('common.name'), description: localize('common.description'), }); @customElement('meteoalarm-card') export class MeteoalarmCard extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @state() private config!: MeteoalarmCardConfig; private resizeObserver!: ResizeObserver; private swiper!: Swiper; // Entity of which alert is displayed on currently selected slide // Used to display correct entity on click private currentEntity?: string; static get integrations(): MeteoalarmIntegration[] { return INTEGRATIONS.map((i) => new i()); } public static async getConfigElement(): Promise<LovelaceCardEditor> { await import('./editor'); return document.createElement('meteoalarm-card-editor'); } public static getStubConfig(hass: HomeAssistant, entities: string[]): Record<string, unknown> { // Find fist entity that is supported by any integration const ALLOWED_INTEGRATION_TYPES = [ MeteoalarmIntegrationEntityType.SingleEntity, MeteoalarmIntegrationEntityType.CurrentExpected, ]; for (const entity of entities) { const integrations = MeteoalarmCard.integrations.filter((x) => ALLOWED_INTEGRATION_TYPES.includes(x.metadata.type), ); for (const integration of integrations) { if (integration.supports(hass.states[entity])) { return { entities: { entity }, integration: integration.metadata.key, }; } } } return { entities: '', integration: '', }; } public setConfig(config: LovelaceCardConfig): void { if (!config) { throw new Error(localize('common.invalid_configuration')); } else if ( config.entities == undefined || (Array.isArray(config.entities) && config.entities.length == 0) || (Array.isArray(config.entities) && config.entities.every((e) => e == null)) ) { throw new Error(localize('error.missing_entity')); } else if (config.integration == undefined) { throw new Error(localize('error.invalid_integration')); } this.config = { name: 'Meteoalarm', ...config, }; } static get styles(): CSSResultGroup { return [swiperStyles, styles]; } public getCardSize(): number { return 2; } protected shouldUpdate(changedProps: PropertyValues): boolean { return hasConfigOrEntityChanged(this, changedProps, false); } public firstUpdated(): void { this.measureCard(); this.attachObserver(); const swiper = (this.renderRoot as ShadowRoot).getElementById('swiper'); if (!swiper) return; this.swiper = new Swiper(swiper, { modules: [Pagination], pagination: { el: swiper.getElementsByClassName('swiper-pagination')[0] as HTMLElement, }, observer: true, }); this.swiper.on('transitionEnd', () => { this.updateCurrentEntity(); }); this.swiper.on('observerUpdate', () => { this.updateCurrentEntity(); }); } // Updates the currentEntity variable private updateCurrentEntity(): void { const slide = this.swiper.slides[this.swiper.realIndex]; this.currentEntity = slide.getAttribute('entity_id') as string; } private attachObserver() { if (!this.resizeObserver) { this.resizeObserver = new ResizeObserver(debounce(() => this.measureCard(), 250, false)); } const card = this.shadowRoot!.querySelector('ha-card'); if (!card) return; this.resizeObserver.observe(card); } private getHeadlineElements(container: HTMLElement): [HTMLElement, HTMLElement, HTMLElement] { const regular = container.querySelector('.headline-regular') as HTMLElement; const narrow = container.querySelector('.headline-narrow') as HTMLElement; const veryNarrow = container.querySelector('.headline-verynarrow') as HTMLElement; return [regular, narrow, veryNarrow]; } private measureCard() { if (!this.isConnected) return; const card = this.shadowRoot!.querySelector('ha-card'); if (!card) return; if (this.scalingMode == MeteoalarmScalingMode.Disabled) return; const scaleHeadline = [ MeteoalarmScalingMode.Scale, MeteoalarmScalingMode.HeadlineAndScale, ].includes(this.scalingMode); const swapHeadline = [ MeteoalarmScalingMode.Headline, MeteoalarmScalingMode.HeadlineAndScale, ].includes(this.scalingMode); const MAX_FONT_SIZE = 22; const MIN_FONT_SIZE = 17; // Scale headlines of each swiper card const swiper = card.querySelector('.swiper-wrapper'); const slides = swiper?.getElementsByClassName('swiper-slide') as HTMLCollectionOf<HTMLElement>; for (const slide of slides) { const [regular, narrow, veryNarrow] = this.getHeadlineElements(slide); const sizes: [string, HTMLElement][] = [['regular', regular]]; if (swapHeadline) { sizes.push(['narrow', narrow]); sizes.push(['veryNarrow', veryNarrow]); } this.setCardScaling(slide, 'regular', MAX_FONT_SIZE); let isSizeSet = false; for (const [size, element] of sizes) { if (isSizeSet) break; const minFontSize = scaleHeadline ? MIN_FONT_SIZE : MAX_FONT_SIZE; for (let fontSize = MAX_FONT_SIZE; fontSize >= minFontSize; fontSize--) { const elementSize = getTextWidth( element.textContent!, getCanvasFont(regular, fontSize + 'px'), ); if (elementSize <= regular.clientWidth) { this.setCardScaling(slide, size as any, fontSize); isSizeSet = true; break; } } } // Fallback if measuring couldn't fit the text if (!isSizeSet) { if (swapHeadline) { this.setCardScaling(slide, 'icon', MAX_FONT_SIZE); } else { this.setCardScaling(slide, 'regular' as any, MIN_FONT_SIZE); } } } } private setCardScaling( container: HTMLElement, scale: 'regular' | 'narrow' | 'veryNarrow' | 'icon', fontSize: number, ) { const [regular, narrow, veryNarrow] = this.getHeadlineElements(container); if (scale == 'regular') { regular.style.fontSize = `${fontSize}px`; regular.style.display = 'block'; narrow.style.display = 'none'; veryNarrow.style.display = 'none'; } else if (scale == 'narrow') { narrow.style.fontSize = `${fontSize}px`; regular.style.display = 'none'; narrow.style.display = 'block'; veryNarrow.style.display = 'none'; } else if (scale == 'veryNarrow') { veryNarrow.style.fontSize = `${fontSize}px`; regular.style.display = 'none'; narrow.style.display = 'none'; veryNarrow.style.display = 'block'; } else if (scale == 'icon') { regular.style.display = 'none'; narrow.style.display = 'none'; veryNarrow.style.display = 'none'; } } private get entities(): HassEntity[] { const entities: EntityConfig[] = processConfigEntities(this.config.entities!); return entities.map((e) => this.hass.states[e.entity]); } private get integration(): MeteoalarmIntegration { const integration = MeteoalarmCard.integrations.find( (i) => i.metadata.key === this.config.integration, ); if (integration === undefined) { throw new Error(localize('error.invalid_integration')); } return integration!; } private get scalingMode(): MeteoalarmScalingMode { const modeString = this.config.scaling_mode; if (!modeString) return MeteoalarmScalingMode.HeadlineAndScale; if (!Object.values(MeteoalarmScalingMode).includes(modeString as any)) { throw new Error('MeteoalarmCard: ' + localize('error.invalid_scaling_mode')); } return modeString as MeteoalarmScalingMode; } protected render(): TemplateResult | void { try { const parser = new EventsParser(this.integration); const events = parser.getEvents( this.entities, this.config.disable_swiper, this.config.override_headline, this.config.hide_caption, this.config.ignored_levels, this.config.ignored_events, ); // Handle hide_when_no_warning if (events.every((e) => !e.isActive) && this.config.hide_when_no_warning) { // eslint-disable-next-line no-console console.log( 'MeteoalarmCard: Card is hidden - hide_when_no_warning is enabled and there are no warnings', ); this.setCardMargin(false); return html``; } this.setCardMargin(true); return html` <ha-card @action=${this.handleAction} .actionHandler=${actionHandler({ hasHold: hasAction(this.config.hold_action), hasDoubleClick: hasAction(this.config.double_tap_action), })} tabindex="0" > <div class="container"> <div class="swiper" id="swiper" > <div class="swiper-wrapper"> ${events.map( (event) => html` <div class="swiper-slide ${event.cssClass}" entity_id=${ifDefined(event.entity?.entity_id)} > <div class="content"> ${this.renderMainIcon(event.icon)} ${this.renderHeadlines(event.headlines)} </div> ${event.caption && event.captionIcon ? html` <div class="caption"> ${this.renderCaption(event.captionIcon, event.caption)} </div> ` : ''} </div> `, )} </div> <div class="swiper-pagination"></div> </div> </div> </ha-card> `; } catch (error) { // eslint-disable-next-line no-console console.error('[METEOALARM CARD ERROR]\nReport issue: https://bit.ly/3hK1hL4 \n\n', error); return this.showError(error as string); } } private renderMainIcon(icon: string): TemplateResult { return html`<ha-icon class="main-icon" icon="mdi:${icon}" ></ha-icon>`; } // Transfer array of one, two or three headlines in descending length // into TemplateResult. These will be selected depending on // card width (screen size) by resize observer private renderHeadlines(headlines: string[]): TemplateResult { // TODO: Fix this array mess let regular = '', narrow = '', verynarrow = ''; if (headlines.length == 0) { throw new Error('headlines array length is 0'); } else if (headlines.length == 1) { regular = headlines[0]; narrow = headlines[0]; verynarrow = headlines[0]; } else if (headlines.length == 2) { regular = headlines[0]; narrow = headlines[1]; verynarrow = headlines[1]; } else if (headlines.length == 3) { regular = headlines[0]; narrow = headlines[1]; verynarrow = headlines[2]; } else if (headlines.length > 3) { throw new Error('headlines array length is higher than 3'); } return html` <div class="headline headline-regular">${regular}</div> <div class="headline headline-narrow">${narrow}</div> <div class="headline headline-verynarrow">${verynarrow}</div> `; } private renderCaption(icon: string, caption: string): TemplateResult { return html` <span class="caption-text">${caption}</span> <ha-icon class="caption-icon" icon="mdi:${icon}" ></ha-icon> `; } private setCardMargin(showMargin: boolean): void { const container = this.shadowRoot?.host as HTMLElement; if (!container) return; container.style.margin = showMargin ? '' : '0px'; } private showError(error: string): TemplateResult { const errorCard = document.createElement('hui-error-card'); errorCard.setConfig({ type: 'error', error, origConfig: this.config, }); return html` ${errorCard} `; } private handleAction(ev: ActionHandlerEvent): void { const config = { ...this.config, entity: this.currentEntity, }; if (this.hass && this.config && ev.detail.action) { handleAction(this, this.hass, config, ev.detail.action); } } }