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"