diff --git a/src/common/dom/setup-leaflet-map.js b/src/common/dom/setup-leaflet-map.js
new file mode 100644
index 0000000000..13e23ddb4b
--- /dev/null
+++ b/src/common/dom/setup-leaflet-map.js
@@ -0,0 +1,22 @@
+import Leaflet from 'leaflet';
+
+// Sets up a Leaflet map on the provided DOM element
+export default function setupLeafletMap(mapElement) {
+ const map = Leaflet.map(mapElement);
+ const style = document.createElement('link');
+ style.setAttribute('href', '/static/images/leaflet/leaflet.css');
+ style.setAttribute('rel', 'stylesheet');
+ mapElement.parentNode.appendChild(style);
+ map.setView([51.505, -0.09], 13);
+ Leaflet.tileLayer(
+ `https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}${Leaflet.Browser.retina ? '@2x.png' : '.png'}`,
+ {
+ attribution: '© OpenStreetMap, © CARTO',
+ subdomains: 'abcd',
+ minZoom: 0,
+ maxZoom: 20,
+ }
+ ).addTo(map);
+
+ return map;
+}
diff --git a/src/panels/lovelace/cards/hui-entity-filter-card.js b/src/panels/lovelace/cards/hui-entity-filter-card.js
index 29ae758fd8..1a3cfe0966 100644
--- a/src/panels/lovelace/cards/hui-entity-filter-card.js
+++ b/src/panels/lovelace/cards/hui-entity-filter-card.js
@@ -68,6 +68,7 @@ class HuiEntitiesCard extends PolymerElement {
element._filterRawConfig,
{ entities: entitiesList }
));
+ element.isPanel = this.isPanel;
}
}
customElements.define('hui-entity-filter-card', HuiEntitiesCard);
diff --git a/src/panels/lovelace/cards/hui-map-card.js b/src/panels/lovelace/cards/hui-map-card.js
new file mode 100644
index 0000000000..24bb90389f
--- /dev/null
+++ b/src/panels/lovelace/cards/hui-map-card.js
@@ -0,0 +1,257 @@
+import { html } from '@polymer/polymer/lib/utils/html-tag.js';
+import { PolymerElement } from '@polymer/polymer/polymer-element.js';
+import Leaflet from 'leaflet';
+
+import '../../map/ha-entity-marker.js';
+
+import setupLeafletMap from '../../../common/dom/setup-leaflet-map.js';
+import processConfigEntities from '../common/process-config-entities.js';
+import computeStateDomain from '../../../common/entity/compute_state_domain.js';
+import computeStateName from '../../../common/entity/compute_state_name.js';
+import debounce from '../../../common/util/debounce.js';
+
+Leaflet.Icon.Default.imagePath = '/static/images/leaflet';
+
+class HuiMapCard extends PolymerElement {
+ 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;
+ }
+
+ this.$.root.style.paddingTop = this._config.aspect_ratio || '100%';
+ }
+
+ setConfig(config) {
+ if (!config) {
+ throw new Error('Error in card configuration.');
+ }
+
+ this._configEntities = processConfigEntities(config.entities);
+ this._config = config;
+ }
+
+ getCardSize() {
+ let ar = this._config.aspect_ratio || '100%';
+ ar = ar.substr(0, ar.length - 1);
+ 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._map = setupLeafletMap(this.$.map);
+ this._drawEntities(this.hass);
+
+ setTimeout(() => {
+ this._resetMap();
+ this._fitMap();
+ }, 1);
+ }
+
+ 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() {
+ if (this._mapItems.length === 0) {
+ this._map.setView(
+ new Leaflet.LatLng(this.hass.config.core.latitude, this.hass.config.core.longitude),
+ 14
+ );
+ } else {
+ const bounds = new Leaflet.latLngBounds(this._mapItems.map(item => item.getLatLng()));
+ this._map.fitBounds(bounds.pad(0.5));
+ }
+ }
+
+ _drawEntities(hass) {
+ const map = this._map;
+ if (!map) {
+ return;
+ }
+
+ if (this._mapItems) {
+ this._mapItems.forEach(marker => marker.remove());
+ }
+ const mapItems = this._mapItems = [];
+
+ this._configEntities.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 = Leaflet.divIcon({
+ html: iconHTML,
+ iconSize: [24, 24],
+ className: '',
+ });
+
+ // create market with the icon
+ mapItems.push(Leaflet.marker([latitude, longitude], {
+ icon: markerIcon,
+ interactive: false,
+ title: title,
+ }).addTo(map));
+
+ // create circle around it
+ mapItems.push(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 || '');
+
+ /* 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 = Leaflet.divIcon({
+ html: el.outerHTML,
+ iconSize: [48, 48],
+ className: '',
+ });
+
+ // create market with the icon
+ mapItems.push(Leaflet.marker([latitude, longitude], {
+ icon: markerIcon,
+ 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));
+ }
+ });
+ }
+}
+
+customElements.define('hui-map-card', HuiMapCard);
diff --git a/src/panels/lovelace/common/create-card-element.js b/src/panels/lovelace/common/create-card-element.js
index e9d053d51f..b2495daad8 100644
--- a/src/panels/lovelace/common/create-card-element.js
+++ b/src/panels/lovelace/common/create-card-element.js
@@ -7,6 +7,7 @@ import '../cards/hui-glance-card';
import '../cards/hui-history-graph-card.js';
import '../cards/hui-horizontal-stack-card.js';
import '../cards/hui-iframe-card.js';
+import '../cards/hui-map-card.js';
import '../cards/hui-markdown-card.js';
import '../cards/hui-media-control-card.js';
import '../cards/hui-picture-card.js';
@@ -27,6 +28,7 @@ const CARD_TYPES = [
'history-graph',
'horizontal-stack',
'iframe',
+ 'map',
'markdown',
'media-control',
'picture',
diff --git a/src/panels/lovelace/hui-root.js b/src/panels/lovelace/hui-root.js
index dae350078d..6d33c15e35 100644
--- a/src/panels/lovelace/hui-root.js
+++ b/src/panels/lovelace/hui-root.js
@@ -52,6 +52,13 @@ class HUIRoot extends NavigateMixin(EventsMixin(PolymerElement)) {
}
#view {
min-height: calc(100vh - 112px);
+ /**
+ * Since we only set min-height, if child nodes need percentage
+ * heights they must use absolute positioning so we need relative
+ * positioning here.
+ *
+ * https://www.w3.org/TR/CSS2/visudet.html#the-height-property
+ */
position: relative;
}
#view.tabs-hidden {
@@ -207,6 +214,7 @@ class HUIRoot extends NavigateMixin(EventsMixin(PolymerElement)) {
const viewConfig = this.config.views[this._curView];
if (viewConfig.panel) {
view = createCardElement(viewConfig.cards[0]);
+ view.isPanel = true;
} else {
view = document.createElement('hui-view');
view.config = viewConfig;
diff --git a/src/panels/map/ha-panel-map.js b/src/panels/map/ha-panel-map.js
index 448ef27ace..025aa8eafd 100644
--- a/src/panels/map/ha-panel-map.js
+++ b/src/panels/map/ha-panel-map.js
@@ -11,6 +11,7 @@ import './ha-entity-marker.js';
import computeStateDomain from '../../common/entity/compute_state_domain.js';
import computeStateName from '../../common/entity/compute_state_name.js';
import LocalizeMixin from '../../mixins/localize-mixin.js';
+import setupLeafletMap from '../../common/dom/setup-leaflet-map.js';
Leaflet.Icon.Default.imagePath = '/static/images/leaflet';
@@ -57,21 +58,7 @@ class HaPanelMap extends LocalizeMixin(PolymerElement) {
connectedCallback() {
super.connectedCallback();
- var map = this._map = Leaflet.map(this.$.map);
- var style = document.createElement('link');
- style.setAttribute('href', '/static/images/leaflet/leaflet.css');
- style.setAttribute('rel', 'stylesheet');
- this.$.map.parentNode.appendChild(style);
- map.setView([51.505, -0.09], 13);
- Leaflet.tileLayer(
- `https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}${Leaflet.Browser.retina ? '@2x.png' : '.png'}`,
- {
- attribution: '© OpenStreetMap, © CARTO',
- subdomains: 'abcd',
- minZoom: 0,
- maxZoom: 20,
- }
- ).addTo(map);
+ var map = this._map = setupLeafletMap(this.$.map);
this.drawEntities(this.hass);
@@ -81,6 +68,12 @@ class HaPanelMap extends LocalizeMixin(PolymerElement) {
}, 1);
}
+ disconnectedCallback() {
+ if (this._map) {
+ this._map.remove();
+ }
+ }
+
fitMap() {
var bounds;