diff --git a/package.json b/package.json index 4577c34aed..1d604cb278 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/common/dom/setup-leaflet-map.ts b/src/common/dom/setup-leaflet-map.ts index b8f7922114..75d7311e3c 100644 --- a/src/common/dom/setup-leaflet-map.ts +++ b/src/common/dom/setup-leaflet-map.ts @@ -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); diff --git a/src/panels/lovelace/cards/hui-map-card.js b/src/panels/lovelace/cards/hui-map-card.js deleted file mode 100644 index a64eadd606..0000000000 --- a/src/panels/lovelace/cards/hui-map-card.js +++ /dev/null @@ -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` - - - -
-
- -
-
- `; - } - - 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); diff --git a/src/panels/lovelace/cards/hui-map-card.ts b/src/panels/lovelace/cards/hui-map-card.ts new file mode 100644 index 0000000000..a061c86c7a --- /dev/null +++ b/src/panels/lovelace/cards/hui-map-card.ts @@ -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; + 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 = []; + 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` + +
+
+ +
+
+ `; + } + + 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 { + [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 ? `` : 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: ` + + `, + 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; + } +} diff --git a/src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts index a00f4fcf46..b2161e4bb3 100644 --- a/src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts @@ -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``; diff --git a/yarn.lock b/yarn.lock index 318b196f2c..52134e3a39 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"