meteoalarm-card
Version:
Meteoalarm card for Home Assistant Lovelace UI
431 lines (387 loc) • 13.4 kB
text/typescript
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'),
});
export class MeteoalarmCard extends LitElement {
public hass!: HomeAssistant;
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
=${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);
}
}
}