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;