diff --git a/src/common/const.ts b/src/common/const.ts index 3abe76e91b..a2dc5412f7 100644 --- a/src/common/const.ts +++ b/src/common/const.ts @@ -44,6 +44,7 @@ export const DOMAINS_WITH_MORE_INFO = [ "light", "lock", "media_player", + "person", "script", "sun", "timer", diff --git a/src/components/map/ha-map.ts b/src/components/map/ha-map.ts new file mode 100644 index 0000000000..d86a9e7c51 --- /dev/null +++ b/src/components/map/ha-map.ts @@ -0,0 +1,311 @@ +import "@polymer/paper-icon-button/paper-icon-button"; +import { Circle, Layer, Map, Marker } from "leaflet"; +import { + css, + CSSResult, + customElement, + html, + LitElement, + property, + PropertyValues, + TemplateResult, +} from "lit-element"; +import { + LeafletModuleType, + setupLeafletMap, +} 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 { HomeAssistant } from "../../types"; +import "../../panels/map/ha-entity-marker"; + +@customElement("ha-map") +class HaMap extends LitElement { + @property() public hass?: HomeAssistant; + + @property() public entities?: string[]; + @property() public darkMode = false; + @property() public zoom?: number; + // 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 _mapZones: Array = []; + private _connected = false; + + public connectedCallback(): void { + super.connectedCallback(); + this._connected = true; + if (this.hasUpdated) { + this.loadMap(); + this._attachObserver(); + } + } + + public disconnectedCallback(): void { + super.disconnectedCallback(); + this._connected = false; + + if (this._leafletMap) { + this._leafletMap.remove(); + this._leafletMap = undefined; + this.Leaflet = undefined; + } + + if (this._resizeObserver) { + this._resizeObserver.unobserve(this._mapEl); + } else { + window.removeEventListener("resize", this._debouncedResizeListener); + } + } + + protected render(): TemplateResult { + if (!this.entities) { + return html``; + } + return html` +
+ `; + } + + protected firstUpdated(changedProps: PropertyValues): void { + super.firstUpdated(changedProps); + this.loadMap(); + + if (this._connected) { + this._attachObserver(); + } + } + + protected updated(changedProps: PropertyValues): void { + if (changedProps.has("hass")) { + this._drawEntities(); + this._fitMap(); + } + } + + private get _mapEl(): HTMLDivElement { + return this.shadowRoot!.getElementById("map") as HTMLDivElement; + } + + private async loadMap(): Promise { + [this._leafletMap, this.Leaflet] = await setupLeafletMap( + this._mapEl, + this.darkMode + ); + this._drawEntities(); + this._leafletMap.invalidateSize(); + this._fitMap(); + } + + private _fitMap(): void { + if (!this._leafletMap || !this.Leaflet || !this.hass) { + return; + } + if (this._mapItems.length === 0) { + this._leafletMap.setView( + new this.Leaflet.LatLng( + this.hass.config.latitude, + this.hass.config.longitude + ), + this.zoom || 14 + ); + return; + } + + const bounds = this.Leaflet.latLngBounds( + this._mapItems ? this._mapItems.map((item) => item.getLatLng()) : [] + ); + this._leafletMap.fitBounds(bounds.pad(0.5)); + + if (this.zoom && this._leafletMap.getZoom() > this.zoom) { + this._leafletMap.setZoom(this.zoom); + } + } + + private _drawEntities(): void { + const hass = this.hass; + const map = this._leafletMap; + const Leaflet = this.Leaflet; + if (!hass || !map || !Leaflet) { + return; + } + + if (this._mapItems) { + this._mapItems.forEach((marker) => marker.remove()); + } + const mapItems: Layer[] = (this._mapItems = []); + + if (this._mapZones) { + this._mapZones.forEach((marker) => marker.remove()); + } + const mapZones: Layer[] = (this._mapZones = []); + + const allEntities = this.entities!.concat(); + + for (const entity of allEntities) { + const entityId = 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 icon + let iconHTML = ""; + if (icon) { + const el = document.createElement("ha-icon"); + el.setAttribute("icon", icon); + iconHTML = el.outerHTML; + } else { + const el = document.createElement("span"); + el.innerHTML = title; + iconHTML = el.outerHTML; + } + + // create marker with the icon + mapZones.push( + Leaflet.marker([latitude, longitude], { + icon: Leaflet.divIcon({ + html: iconHTML, + iconSize: [24, 24], + className: this.darkMode ? "dark" : "light", + }), + interactive: false, + title, + }) + ); + + // create circle around it + mapZones.push( + Leaflet.circle([latitude, longitude], { + interactive: false, + color: "#FF9800", + radius, + }) + ); + + 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), + }) + ); + + // create circle around if entity has accuracy + if (gpsAccuracy) { + mapItems.push( + Leaflet.circle([latitude, longitude], { + interactive: false, + color: "#0288D1", + radius: gpsAccuracy, + }) + ); + } + } + + this._mapItems.forEach((marker) => map.addLayer(marker)); + this._mapZones.forEach((marker) => map.addLayer(marker)); + } + + 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 { + display: block; + height: 300px; + } + #map { + height: 100%; + } + #map.dark { + background: #090909; + } + + .dark { + color: #ffffff; + } + + .light { + color: #000000; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-map": HaMap; + } +} diff --git a/src/data/zone.ts b/src/data/zone.ts index 85464a975b..d7d2306457 100644 --- a/src/data/zone.ts +++ b/src/data/zone.ts @@ -1,4 +1,5 @@ import { HomeAssistant } from "../types"; +import { navigate } from "../common/navigate"; export const defaultRadiusColor = "#FF9800"; export const homeRadiusColor: string = "#03a9f4"; @@ -48,3 +49,19 @@ export const deleteZone = (hass: HomeAssistant, zoneId: string) => type: "zone/delete", zone_id: zoneId, }); + +let inititialZoneEditorData: Partial | undefined; + +export const showZoneEditor = ( + el: HTMLElement, + data?: Partial +) => { + inititialZoneEditorData = data; + navigate(el, "/config/zone/new"); +}; + +export const getZoneEditorInitData = () => { + const data = inititialZoneEditorData; + inititialZoneEditorData = undefined; + return data; +}; diff --git a/src/dialogs/more-info/controls/more-info-content.ts b/src/dialogs/more-info/controls/more-info-content.ts index c84f4a5156..c86b52b9dd 100644 --- a/src/dialogs/more-info/controls/more-info-content.ts +++ b/src/dialogs/more-info/controls/more-info-content.ts @@ -16,6 +16,7 @@ import "./more-info-input_datetime"; import "./more-info-light"; import "./more-info-lock"; import "./more-info-media_player"; +import "./more-info-person"; import "./more-info-script"; import "./more-info-sun"; import "./more-info-timer"; diff --git a/src/dialogs/more-info/controls/more-info-person.ts b/src/dialogs/more-info/controls/more-info-person.ts new file mode 100644 index 0000000000..4e5ae33c32 --- /dev/null +++ b/src/dialogs/more-info/controls/more-info-person.ts @@ -0,0 +1,85 @@ +import { + LitElement, + html, + TemplateResult, + CSSResult, + css, + property, + customElement, +} from "lit-element"; +import { HassEntity } from "home-assistant-js-websocket"; +import "@material/mwc-button"; + +import "../../../components/map/ha-map"; + +import { HomeAssistant } from "../../../types"; +import { showZoneEditor } from "../../../data/zone"; +import { fireEvent } from "../../../common/dom/fire_event"; + +@customElement("more-info-person") +class MoreInfoPerson extends LitElement { + @property() public hass!: HomeAssistant; + @property() public stateObj?: HassEntity; + + protected render(): TemplateResult { + if (!this.hass || !this.stateObj) { + return html``; + } + + return html` + + ${this.stateObj.attributes.latitude && this.stateObj.attributes.longitude + ? html` + + ` + : ""} + ${this.hass.user?.is_admin && + this.stateObj.state === "not_home" && + this.stateObj.attributes.latitude && + this.stateObj.attributes.longitude + ? html` +
+ + ${this.hass.localize( + "ui.dialogs.more_info_control.person.create_zone" + )} + +
+ ` + : ""} + `; + } + + private _handleAction() { + showZoneEditor(this, { + latitude: this.stateObj!.attributes.latitude, + longitude: this.stateObj!.attributes.longitude, + }); + fireEvent(this, "hass-more-info", { entityId: null }); + } + + static get styles(): CSSResult { + return css` + .flex { + display: flex; + justify-content: space-between; + } + .actions { + margin: 36px 0 8px 0; + text-align: right; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "more-info-person": MoreInfoPerson; + } +} diff --git a/src/dialogs/more-info/controls/more-info-vacuum.ts b/src/dialogs/more-info/controls/more-info-vacuum.ts index 4d6d1c362f..ce1c5a30ae 100644 --- a/src/dialogs/more-info/controls/more-info-vacuum.ts +++ b/src/dialogs/more-info/controls/more-info-vacuum.ts @@ -123,12 +123,15 @@ class MoreInfoVacuum extends LitElement { : ""} ${supportsFeature(stateObj, VACUUM_SUPPORT_BATTERY) ? html` - +
- - ${stateObj.attributes.battery_level} % - -
` + + ${stateObj.attributes.battery_level}% + + + ` : ""} diff --git a/src/panels/config/zone/dialog-zone-detail.ts b/src/panels/config/zone/dialog-zone-detail.ts index 850b78133b..984b42623e 100644 --- a/src/panels/config/zone/dialog-zone-detail.ts +++ b/src/panels/config/zone/dialog-zone-detail.ts @@ -20,6 +20,7 @@ import { ZoneMutableParams, passiveRadiusColor, defaultRadiusColor, + getZoneEditorInitData, } from "../../../data/zone"; import { addDistanceToCoord } from "../../../common/location/add_distance_to_coord"; @@ -47,15 +48,20 @@ class DialogZoneDetail extends LitElement { this._passive = this._params.entry.passive || false; this._radius = this._params.entry.radius || 100; } else { - const movedHomeLocation = addDistanceToCoord( - [this.hass.config.latitude, this.hass.config.longitude], - 500, - 500 - ); - this._name = ""; - this._icon = "mdi:map-marker"; - this._latitude = movedHomeLocation[0]; - this._longitude = movedHomeLocation[1]; + const initConfig = getZoneEditorInitData(); + let movedHomeLocation; + if (!initConfig?.latitude || !initConfig?.longitude) { + movedHomeLocation = addDistanceToCoord( + [this.hass.config.latitude, this.hass.config.longitude], + Math.random() * 500 * (Math.random() < 0.5 ? -1 : 1), + Math.random() * 500 * (Math.random() < 0.5 ? -1 : 1) + ); + } + this._latitude = initConfig?.latitude || movedHomeLocation[0]; + this._longitude = initConfig?.longitude || movedHomeLocation[1]; + this._name = initConfig?.name || ""; + this._icon = initConfig?.icon || "mdi:map-marker"; + this._passive = false; this._radius = 100; } diff --git a/src/panels/config/zone/ha-config-zone.ts b/src/panels/config/zone/ha-config-zone.ts index 52a734ab04..e015af15ac 100644 --- a/src/panels/config/zone/ha-config-zone.ts +++ b/src/panels/config/zone/ha-config-zone.ts @@ -46,6 +46,7 @@ import memoizeOne from "memoize-one"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { subscribeEntityRegistry } from "../../../data/entity_registry"; import { configSections } from "../ha-panel-config"; +import { navigate } from "../../../common/navigate"; @customElement("ha-config-zone") export class HaConfigZone extends SubscribeMixin(LitElement) { @@ -234,6 +235,10 @@ export class HaConfigZone extends SubscribeMixin(LitElement) { protected firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); this._fetchData(); + if (this.route.path === "/new") { + navigate(this, "/config/zone", true); + this._createZone(); + } } protected updated(changedProps: PropertyValues) { diff --git a/src/translations/en.json b/src/translations/en.json index 1efb96a42a..9aab1d472e 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -630,6 +630,9 @@ "locate": "Locate", "return_home": "Return home", "start_pause": "Start/Pause" + }, + "person": { + "create_zone": "Create zone from current location" } }, "entity_registry": {