Convert map card to Lit/TS (#2826)

* Convert map card to Lit/TS

* Address comments
This commit is contained in:
Paulus Schoutsen 2019-02-25 11:10:22 -08:00 committed by GitHub
parent 63e6506510
commit 90a1f7e51c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 431 additions and 379 deletions

View File

@ -107,6 +107,7 @@
"@gfx/zopfli": "^1.0.9",
"@types/chai": "^4.1.7",
"@types/codemirror": "^0.0.71",
"@types/leaflet": "^1.4.3",
"@types/memoize-one": "^4.1.0",
"@types/mocha": "^5.2.5",
"babel-eslint": "^10",

View File

@ -1,8 +1,13 @@
import { Map } from "leaflet";
// Sets up a Leaflet map on the provided DOM element
export const setupLeafletMap = async (mapElement) => {
export type LeafletModuleType = typeof import("leaflet");
export const setupLeafletMap = async (
mapElement
): Promise<[Map, LeafletModuleType]> => {
// tslint:disable-next-line
const Leaflet = (await import(/* webpackChunkName: "leaflet" */ "leaflet"))
.default;
const Leaflet = (await import(/* webpackChunkName: "leaflet" */ "leaflet")) as LeafletModuleType;
Leaflet.Icon.Default.imagePath = "/static/images/leaflet";
const map = Leaflet.map(mapElement);

View File

@ -1,369 +0,0 @@
import { html } from "@polymer/polymer/lib/utils/html-tag";
import { PolymerElement } from "@polymer/polymer/polymer-element";
import "@polymer/paper-icon-button/paper-icon-button";
import "../../map/ha-entity-marker";
import { setupLeafletMap } from "../../../common/dom/setup-leaflet-map";
import { processConfigEntities } from "../common/process-config-entities";
import computeStateDomain from "../../../common/entity/compute_state_domain";
import computeStateName from "../../../common/entity/compute_state_name";
import debounce from "../../../common/util/debounce";
import parseAspectRatio from "../../../common/util/parse-aspect-ratio";
// should be interface when converted to TS
export const Config = {
title: "",
aspect_ratio: "",
default_zoom: 14,
entities: [],
};
class HuiMapCard extends PolymerElement {
static async getConfigElement() {
await import(/* webpackChunkName: "hui-map-card-editor" */ "../editor/config-elements/hui-map-card-editor");
return document.createElement("hui-map-card-editor");
}
static getStubConfig() {
return { entities: [] };
}
static get template() {
return html`
<style>
:host([is-panel]) ha-card {
left: 0;
top: 0;
width: 100%;
/**
* In panel mode we want a full height map. Since parent #view
* only sets min-height, we need absolute positioning here
*/
height: 100%;
position: absolute;
}
ha-card {
overflow: hidden;
}
#map {
z-index: 0;
border: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
paper-icon-button {
position: absolute;
top: 75px;
left: 7px;
}
#root {
position: relative;
}
:host([is-panel]) #root {
height: 100%;
}
</style>
<ha-card id="card" header="[[_config.title]]">
<div id="root">
<div id="map"></div>
<paper-icon-button
on-click="_fitMap"
icon="hass:image-filter-center-focus"
title="Reset focus"
></paper-icon-button>
</div>
</ha-card>
`;
}
static get properties() {
return {
hass: {
type: Object,
observer: "_drawEntities",
},
_config: Object,
isPanel: {
type: Boolean,
reflectToAttribute: true,
},
};
}
constructor() {
super();
this._debouncedResizeListener = debounce(this._resetMap.bind(this), 100);
}
ready() {
super.ready();
if (!this._config || this.isPanel) {
return;
}
const ratio = parseAspectRatio(this._config.aspect_ratio);
if (ratio && ratio.w > 0 && ratio.h > 0) {
this.$.root.style.paddingBottom = `${((100 * ratio.h) / ratio.w).toFixed(
2
)}%`;
} else {
this.$.root.style.paddingBottom = "100%";
}
}
setConfig(config) {
if (!config) {
throw new Error("Error in card configuration.");
}
if (!config.entities && !config.geo_location_sources) {
throw new Error(
"Either entities or geo_location_sources must be defined"
);
}
if (config.entities && !Array.isArray(config.entities)) {
throw new Error("Entities need to be an array");
}
if (
config.geo_location_sources &&
!Array.isArray(config.geo_location_sources)
) {
throw new Error("Geo_location_sources needs to be an array");
}
this._config = config;
this._configGeoLocationSources = config.geo_location_sources;
this._configEntities = config.entities;
}
getCardSize() {
const ratio = parseAspectRatio(this._config.aspect_ratio);
let ar;
if (ratio && ratio.w > 0 && ratio.h > 0) {
ar = `${((100 * ratio.h) / ratio.w).toFixed(2)}`;
} else {
ar = "100";
}
return 1 + Math.floor(ar / 25) || 3;
}
connectedCallback() {
super.connectedCallback();
// Observe changes to map size and invalidate to prevent broken rendering
// Uses ResizeObserver in Chrome, otherwise window resize event
if (typeof ResizeObserver === "function") {
this._resizeObserver = new ResizeObserver(() =>
this._debouncedResizeListener()
);
this._resizeObserver.observe(this.$.map);
} else {
window.addEventListener("resize", this._debouncedResizeListener);
}
this.loadMap();
}
async loadMap() {
[this._map, this.Leaflet] = await setupLeafletMap(this.$.map);
this._drawEntities(this.hass);
this._map.invalidateSize();
this._fitMap();
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._map) {
this._map.remove();
}
if (this._resizeObserver) {
this._resizeObserver.unobserve(this.$.map);
} else {
window.removeEventListener("resize", this._debouncedResizeListener);
}
}
_resetMap() {
if (!this._map) {
return;
}
this._map.invalidateSize();
}
_fitMap() {
const zoom = this._config.default_zoom;
if (this._mapItems.length === 0) {
this._map.setView(
new this.Leaflet.LatLng(
this.hass.config.latitude,
this.hass.config.longitude
),
zoom || 14
);
return;
}
const bounds = new this.Leaflet.latLngBounds(
this._mapItems.map((item) => item.getLatLng())
);
this._map.fitBounds(bounds.pad(0.5));
if (zoom && this._map.getZoom() > zoom) {
this._map.setZoom(zoom);
}
}
_drawEntities(hass) {
const map = this._map;
if (!map) {
return;
}
if (this._mapItems) {
this._mapItems.forEach((marker) => marker.remove());
}
const mapItems = (this._mapItems = []);
let allEntities = [];
if (this._configEntities) {
allEntities = allEntities.concat(this._configEntities);
}
if (this._configGeoLocationSources) {
Object.keys(this.hass.states).forEach((entityId) => {
const stateObj = this.hass.states[entityId];
if (
computeStateDomain(stateObj) === "geo_location" &&
(this._configGeoLocationSources.includes(
stateObj.attributes.source
) ||
this._configGeoLocationSources.includes("all"))
) {
allEntities.push(entityId);
}
});
}
allEntities = processConfigEntities(allEntities);
allEntities.forEach((entity) => {
const entityId = entity.entity;
if (!(entityId in hass.states)) {
return;
}
const stateObj = hass.states[entityId];
const title = computeStateName(stateObj);
const {
latitude,
longitude,
passive,
icon,
radius,
entity_picture: entityPicture,
gps_accuracy: gpsAccuracy,
} = stateObj.attributes;
if (!(latitude && longitude)) {
return;
}
let markerIcon;
let iconHTML;
let el;
if (computeStateDomain(stateObj) === "zone") {
// DRAW ZONE
if (passive) return;
// create icon
if (icon) {
el = document.createElement("ha-icon");
el.setAttribute("icon", icon);
iconHTML = el.outerHTML;
} else {
iconHTML = title;
}
markerIcon = this.Leaflet.divIcon({
html: iconHTML,
iconSize: [24, 24],
className: "",
});
// create market with the icon
mapItems.push(
this.Leaflet.marker([latitude, longitude], {
icon: markerIcon,
interactive: false,
title: title,
}).addTo(map)
);
// create circle around it
mapItems.push(
this.Leaflet.circle([latitude, longitude], {
interactive: false,
color: "#FF9800",
radius: radius,
}).addTo(map)
);
return;
}
// DRAW ENTITY
// create icon
const entityName = title
.split(" ")
.map((part) => part[0])
.join("")
.substr(0, 3);
el = document.createElement("ha-entity-marker");
el.setAttribute("entity-id", entityId);
el.setAttribute("entity-name", entityName);
el.setAttribute("entity-picture", entityPicture || "");
/* this.Leaflet clones this element before adding it to the map. This messes up
our Polymer object and we can't pass data through. Thus we hack like this. */
markerIcon = this.Leaflet.divIcon({
html: el.outerHTML,
iconSize: [48, 48],
className: "",
});
// create market with the icon
mapItems.push(
this.Leaflet.marker([latitude, longitude], {
icon: markerIcon,
title: computeStateName(stateObj),
}).addTo(map)
);
// create circle around if entity has accuracy
if (gpsAccuracy) {
mapItems.push(
this.Leaflet.circle([latitude, longitude], {
interactive: false,
color: "#0288D1",
radius: gpsAccuracy,
}).addTo(map)
);
}
});
}
}
customElements.define("hui-map-card", HuiMapCard);

View File

@ -0,0 +1,407 @@
import "@polymer/paper-icon-button/paper-icon-button";
import { Layer, Marker, Circle, Map } from "leaflet";
import {
LitElement,
TemplateResult,
css,
html,
property,
PropertyValues,
CSSResult,
customElement,
} from "lit-element";
import "../../map/ha-entity-marker";
import {
setupLeafletMap,
LeafletModuleType,
} from "../../../common/dom/setup-leaflet-map";
import computeStateDomain from "../../../common/entity/compute_state_domain";
import computeStateName from "../../../common/entity/compute_state_name";
import debounce from "../../../common/util/debounce";
import parseAspectRatio from "../../../common/util/parse-aspect-ratio";
import { HomeAssistant } from "../../../types";
import computeDomain from "../../../common/entity/compute_domain";
import { LovelaceCard } from "../types";
import { LovelaceCardConfig } from "../../../data/lovelace";
import { EntityConfig } from "../entity-rows/types";
import { processConfigEntities } from "../common/process-config-entities";
export interface MapCardConfig extends LovelaceCardConfig {
title: string;
aspect_ratio: string;
default_zoom?: number;
entities?: Array<EntityConfig | string>;
geo_location_sources?: string[];
}
@customElement("hui-map-card")
class HuiMapCard extends LitElement implements LovelaceCard {
public static async getConfigElement() {
await import(/* webpackChunkName: "hui-map-card-editor" */ "../editor/config-elements/hui-map-card-editor");
return document.createElement("hui-map-card-editor");
}
public static getStubConfig() {
return { entities: [] };
}
@property() public hass?: HomeAssistant;
@property({ type: Boolean, reflect: true })
public isPanel = false;
@property()
private _config?: MapCardConfig;
private _configEntities?: EntityConfig[];
// tslint:disable-next-line
private Leaflet?: LeafletModuleType;
private _leafletMap?: Map;
// @ts-ignore
private _resizeObserver?: ResizeObserver;
private _debouncedResizeListener = debounce(
() => {
if (!this._leafletMap) {
return;
}
this._leafletMap.invalidateSize();
},
100,
false
);
private _mapItems: Array<Marker | Circle> = [];
private _connected = false;
public setConfig(config: MapCardConfig): void {
if (!config) {
throw new Error("Error in card configuration.");
}
if (!config.entities && !config.geo_location_sources) {
throw new Error(
"Either entities or geo_location_sources must be defined"
);
}
if (config.entities && !Array.isArray(config.entities)) {
throw new Error("Entities need to be an array");
}
if (
config.geo_location_sources &&
!Array.isArray(config.geo_location_sources)
) {
throw new Error("Geo_location_sources needs to be an array");
}
this._config = config;
this._configEntities = config.entities
? processConfigEntities(config.entities)
: [];
}
public getCardSize(): number {
if (!this._config) {
return 3;
}
const ratio = parseAspectRatio(this._config.aspect_ratio);
const ar =
ratio && ratio.w > 0 && ratio.h > 0
? `${((100 * ratio.h) / ratio.w).toFixed(2)}`
: "100";
return 1 + Math.floor(Number(ar) / 25) || 3;
}
public connectedCallback(): void {
super.connectedCallback();
this._connected = true;
if (this.hasUpdated) {
this._attachObserver();
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
if (this._leafletMap) {
this._leafletMap.remove();
}
if (this._resizeObserver) {
this._resizeObserver.unobserve(this._mapEl);
} else {
window.removeEventListener("resize", this._debouncedResizeListener);
}
}
protected render(): TemplateResult | void {
if (!this._config) {
return html``;
}
return html`
<ha-card id="card" .header=${this._config.title}>
<div id="root">
<div id="map"></div>
<paper-icon-button
@click=${this._fitMap}
icon="hass:image-filter-center-focus"
title="Reset focus"
></paper-icon-button>
</div>
</ha-card>
`;
}
protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps);
this.loadMap();
const root = this.shadowRoot!.getElementById("root");
if (!this._config || this.isPanel || !root) {
return;
}
if (this._connected) {
this._attachObserver();
}
const ratio = parseAspectRatio(this._config.aspect_ratio);
root.style.paddingBottom =
ratio && ratio.w > 0 && ratio.h > 0
? `${((100 * ratio.h) / ratio.w).toFixed(2)}%`
: (root.style.paddingBottom = "100%");
}
protected updated(changedProps: PropertyValues): void {
if (changedProps.has("hass")) {
this._drawEntities();
}
}
private get _mapEl(): HTMLDivElement {
return this.shadowRoot!.getElementById("map") as HTMLDivElement;
}
private async loadMap(): Promise<void> {
[this._leafletMap, this.Leaflet] = await setupLeafletMap(this._mapEl);
this._drawEntities();
this._leafletMap.invalidateSize();
this._fitMap();
}
private _fitMap(): void {
if (!this._leafletMap || !this.Leaflet || !this._config || !this.hass) {
return;
}
const zoom = this._config.default_zoom;
if (this._mapItems.length === 0) {
this._leafletMap.setView(
new this.Leaflet.LatLng(
this.hass.config.latitude,
this.hass.config.longitude
),
zoom || 14
);
return;
}
const bounds = this.Leaflet.latLngBounds(
this._mapItems ? this._mapItems.map((item) => item.getLatLng()) : []
);
this._leafletMap.fitBounds(bounds.pad(0.5));
if (zoom && this._leafletMap.getZoom() > zoom) {
this._leafletMap.setZoom(zoom);
}
}
private _drawEntities(): void {
const hass = this.hass;
const map = this._leafletMap;
const config = this._config;
const Leaflet = this.Leaflet;
if (!hass || !map || !config || !Leaflet) {
return;
}
if (this._mapItems) {
this._mapItems.forEach((marker) => marker.remove());
}
const mapItems: Layer[] = (this._mapItems = []);
const allEntities = this._configEntities!.concat();
// Calculate visible geo location sources
if (config.geo_location_sources) {
const includesAll = config.geo_location_sources.includes("all");
for (const entityId of Object.keys(hass.states)) {
const stateObj = hass.states[entityId];
if (
computeDomain(entityId) === "geo_location" &&
(includesAll ||
config.geo_location_sources.includes(stateObj.attributes.source))
) {
allEntities.push({ entity: entityId });
}
}
}
for (const entity of allEntities) {
const entityId = entity.entity;
const stateObj = hass.states[entityId];
if (!stateObj) {
continue;
}
const title = computeStateName(stateObj);
const {
latitude,
longitude,
passive,
icon,
radius,
entity_picture: entityPicture,
gps_accuracy: gpsAccuracy,
} = stateObj.attributes;
if (!(latitude && longitude)) {
continue;
}
if (computeStateDomain(stateObj) === "zone") {
// DRAW ZONE
if (passive) {
continue;
}
// create marker with the icon
mapItems.push(
Leaflet.marker([latitude, longitude], {
icon: Leaflet.divIcon({
html: icon ? `<ha-icon icon="${icon}"></ha-icon>` : title,
iconSize: [24, 24],
className: "",
}),
interactive: false,
title,
}).addTo(map)
);
// create circle around it
mapItems.push(
Leaflet.circle([latitude, longitude], {
interactive: false,
color: "#FF9800",
radius,
}).addTo(map)
);
continue;
}
// DRAW ENTITY
// create icon
const entityName = title
.split(" ")
.map((part) => part[0])
.join("")
.substr(0, 3);
// create market with the icon
mapItems.push(
Leaflet.marker([latitude, longitude], {
icon: Leaflet.divIcon({
// Leaflet clones this element before adding it to the map. This messes up
// our Polymer object and we can't pass data through. Thus we hack like this.
html: `
<ha-entity-marker
entity-id="${entityId}"
entity-name="${entityName}"
entity-picture="${entityPicture || ""}
></ha-entity-marker>
`,
iconSize: [48, 48],
className: "",
}),
title: computeStateName(stateObj),
}).addTo(map)
);
// create circle around if entity has accuracy
if (gpsAccuracy) {
mapItems.push(
Leaflet.circle([latitude, longitude], {
interactive: false,
color: "#0288D1",
radius: gpsAccuracy,
}).addTo(map)
);
}
}
}
private _attachObserver(): void {
// Observe changes to map size and invalidate to prevent broken rendering
// Uses ResizeObserver in Chrome, otherwise window resize event
// @ts-ignore
if (typeof ResizeObserver === "function") {
// @ts-ignore
this._resizeObserver = new ResizeObserver(() =>
this._debouncedResizeListener()
);
this._resizeObserver.observe(this._mapEl);
} else {
window.addEventListener("resize", this._debouncedResizeListener);
}
}
static get styles(): CSSResult {
return css`
:host([ispanel]) ha-card {
left: 0;
top: 0;
width: 100%;
/**
* In panel mode we want a full height map. Since parent #view
* only sets min-height, we need absolute positioning here
*/
height: 100%;
position: absolute;
}
ha-card {
overflow: hidden;
}
#map {
z-index: 0;
border: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
paper-icon-button {
position: absolute;
top: 75px;
left: 7px;
}
#root {
position: relative;
}
:host([ispanel]) #root {
height: 100%;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-map-card": HuiMapCard;
}
}

View File

@ -11,7 +11,7 @@ import { EntitiesEditorEvent, EditorTarget } from "../types";
import { HomeAssistant } from "../../../../types";
import { LovelaceCardEditor } from "../../types";
import { fireEvent } from "../../../../common/dom/fire_event";
import { Config } from "../../cards/hui-alarm-panel-card";
import { MapCardConfig } from "../../cards/hui-map-card";
import { configElementStyle } from "./config-elements-style";
import { processEditorEntities } from "../process-editor-entities";
import { EntityConfig } from "../../entity-rows/types";
@ -37,10 +37,10 @@ const cardConfigStruct = struct({
export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
public hass?: HomeAssistant;
private _config?: Config;
private _config?: MapCardConfig;
private _configEntities?: EntityConfig[];
public setConfig(config: Config): void {
public setConfig(config: MapCardConfig): void {
config = cardConfigStruct(config);
this._config = config;
this._configEntities = processEditorEntities(config.entities);
@ -62,10 +62,6 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
return this._config!.default_zoom || NaN;
}
get _entities(): string[] {
return this._config!.entities || [];
}
protected render(): TemplateResult | void {
if (!this.hass) {
return html``;

View File

@ -1653,6 +1653,11 @@
resolved "https://registry.yarnpkg.com/@types/freeport/-/freeport-1.0.21.tgz#73f6543ed67d3ca3fff97b985591598b7092066f"
integrity sha1-c/ZUPtZ9PKP/+XuYVZFZi3CSBm8=
"@types/geojson@*":
version "7946.0.6"
resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.6.tgz#416f388a06b227784a2d91a88a53f14de05cd54b"
integrity sha512-f6qai3iR62QuMPPdgyH+LyiXTL2n9Rf62UniJjV7KHrbiwzLTZUKsdq0mFSTxAHbO7JvwxwC4tH0m1UnweuLrA==
"@types/glob-stream@*":
version "6.1.0"
resolved "https://registry.yarnpkg.com/@types/glob-stream/-/glob-stream-6.1.0.tgz#7ede8a33e59140534f8d8adfb8ac9edfb31897bc"
@ -1725,6 +1730,13 @@
resolved "https://registry.yarnpkg.com/@types/launchpad/-/launchpad-0.6.0.tgz#37296109b7f277f6e6c5fd7e0c0706bc918fbb51"
integrity sha1-NylhCbfyd/bmxf1+DAcGvJGPu1E=
"@types/leaflet@^1.4.3":
version "1.4.3"
resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.4.3.tgz#62638cb73770eeaed40222042afbcc7b495f0cc4"
integrity sha512-jFRBSsPHi1EwQSwrN0cOJLdPhwOZsRl4IMxvm/2ShLh0YM5GfCtQXCzsrv8RE7DWL+AykXdYSAd9bFLWbZT4CQ==
dependencies:
"@types/geojson" "*"
"@types/memoize-one@^4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@types/memoize-one/-/memoize-one-4.1.0.tgz#62119f26055b3193ae43ca1882c5b29b88b71ece"