diff --git a/package.json b/package.json index 255429537b..170891b349 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "intl-messageformat": "^2.2.0", "js-yaml": "^3.13.1", "leaflet": "^1.4.0", + "leaflet-draw": "^1.0.4", "lit-element": "^2.2.1", "lit-html": "^1.1.0", "lit-virtualizer": "^0.4.2", @@ -123,6 +124,7 @@ "@types/hls.js": "^0.12.3", "@types/js-yaml": "^3.12.1", "@types/leaflet": "^1.4.3", + "@types/leaflet-draw": "^1.0.1", "@types/memoize-one": "4.1.0", "@types/mocha": "^5.2.6", "@types/webspeechapi": "^0.0.29", diff --git a/src/common/dom/setup-leaflet-map.ts b/src/common/dom/setup-leaflet-map.ts index abf9008295..f29b5e257b 100644 --- a/src/common/dom/setup-leaflet-map.ts +++ b/src/common/dom/setup-leaflet-map.ts @@ -2,10 +2,12 @@ import { Map } from "leaflet"; // Sets up a Leaflet map on the provided DOM element export type LeafletModuleType = typeof import("leaflet"); +export type LeafletDrawModuleType = typeof import("leaflet-draw"); export const setupLeafletMap = async ( mapElement: HTMLElement, - darkMode = false + darkMode = false, + draw = false ): Promise<[Map, LeafletModuleType]> => { if (!mapElement.parentNode) { throw new Error("Cannot setup Leaflet map on disconnected element"); @@ -16,6 +18,10 @@ export const setupLeafletMap = async ( )) as LeafletModuleType; Leaflet.Icon.Default.imagePath = "/static/images/leaflet/images/"; + if (draw) { + await import(/* webpackChunkName: "leaflet-draw" */ "leaflet-draw"); + } + const map = Leaflet.map(mapElement); const style = document.createElement("link"); style.setAttribute("href", "/static/images/leaflet/leaflet.css"); diff --git a/src/components/map/ha-location-editor.ts b/src/components/map/ha-location-editor.ts index 0d4133b832..c386967e58 100644 --- a/src/components/map/ha-location-editor.ts +++ b/src/components/map/ha-location-editor.ts @@ -8,24 +8,35 @@ import { customElement, PropertyValues, } from "lit-element"; -import { Marker, Map, LeafletMouseEvent, DragEndEvent, LatLng } from "leaflet"; +import { + Marker, + Map, + LeafletMouseEvent, + DragEndEvent, + LatLng, + Circle, + DivIcon, +} from "leaflet"; import { setupLeafletMap, LeafletModuleType, } from "../../common/dom/setup-leaflet-map"; import { fireEvent } from "../../common/dom/fire_event"; +import { nextRender } from "../../common/util/render-status"; @customElement("ha-location-editor") class LocationEditor extends LitElement { @property() public location?: [number, number]; + @property() public radius?: number; + @property() public icon?: string; public fitZoom = 16; - + private _iconEl?: DivIcon; private _ignoreFitToMap?: [number, number]; // tslint:disable-next-line private Leaflet?: LeafletModuleType; private _leafletMap?: Map; - private _locationMarker?: Marker; + private _locationMarker?: Marker | Circle; public fitMap(): void { if (!this._leafletMap || !this.location) { @@ -53,11 +64,24 @@ class LocationEditor extends LitElement { return; } - this._updateMarker(); - if (!this._ignoreFitToMap || this._ignoreFitToMap !== this.location) { - this.fitMap(); + if (changedProps.has("location")) { + this._updateMarker(); + if ( + this.location && + (!this._ignoreFitToMap || + this._ignoreFitToMap[0] !== this.location[0] || + this._ignoreFitToMap[1] !== this.location[1]) + ) { + this.fitMap(); + } + this._ignoreFitToMap = undefined; + } + if (changedProps.has("radius")) { + this._updateRadius(); + } + if (changedProps.has("icon")) { + this._updateIcon(); } - this._ignoreFitToMap = undefined; } private get _mapEl(): HTMLDivElement { @@ -65,18 +89,23 @@ class LocationEditor extends LitElement { } private async _initMap(): Promise { - [this._leafletMap, this.Leaflet] = await setupLeafletMap(this._mapEl); + [this._leafletMap, this.Leaflet] = await setupLeafletMap( + this._mapEl, + false, + Boolean(this.radius) + ); this._leafletMap.addEventListener( "click", // @ts-ignore - (ev: LeafletMouseEvent) => this._updateLocation(ev.latlng) + (ev: LeafletMouseEvent) => this._locationUpdated(ev.latlng) ); + this._updateIcon(); this._updateMarker(); this.fitMap(); this._leafletMap.invalidateSize(); } - private _updateLocation(latlng: LatLng) { + private _locationUpdated(latlng: LatLng) { let longitude = latlng.lng; if (Math.abs(longitude) > 180.0) { // Normalize longitude if map provides values beyond -180 to +180 degrees. @@ -86,7 +115,68 @@ class LocationEditor extends LitElement { fireEvent(this, "change", undefined, { bubbles: false }); } - private _updateMarker(): void { + private _radiusUpdated() { + this._ignoreFitToMap = this.location; + this.radius = (this._locationMarker as Circle).getRadius(); + fireEvent(this, "change", undefined, { bubbles: false }); + } + + private _updateIcon() { + if (!this.icon) { + this._iconEl = undefined; + return; + } + + // create icon + let iconHTML = ""; + const el = document.createElement("ha-icon"); + el.setAttribute("icon", this.icon); + iconHTML = el.outerHTML; + + this._iconEl = this.Leaflet!.divIcon({ + html: iconHTML, + iconSize: [24, 24], + className: "light leaflet-edit-move", + }); + this._setIcon(); + } + + private _setIcon() { + if (!this._locationMarker || !this._iconEl) { + return; + } + + if (!this.radius) { + (this._locationMarker as Marker).setIcon(this._iconEl); + return; + } + + // @ts-ignore + const moveMarker = this._locationMarker.editing._moveMarker; + moveMarker.setIcon(this._iconEl); + } + + private _setupEdit() { + // @ts-ignore + this._locationMarker.editing.enable(); + // @ts-ignore + const moveMarker = this._locationMarker.editing._moveMarker; + // @ts-ignore + const resizeMarker = this._locationMarker.editing._resizeMarkers[0]; + this._setIcon(); + moveMarker.addEventListener( + "dragend", + // @ts-ignore + (ev: DragEndEvent) => this._locationUpdated(ev.target.getLatLng()) + ); + resizeMarker.addEventListener( + "dragend", + // @ts-ignore + (ev: DragEndEvent) => this._radiusUpdated(ev) + ); + } + + private async _updateMarker(): Promise { if (!this.location) { if (this._locationMarker) { this._locationMarker.remove(); @@ -97,17 +187,41 @@ class LocationEditor extends LitElement { if (this._locationMarker) { this._locationMarker.setLatLng(this.location); + if (this.radius) { + // @ts-ignore + this._locationMarker.editing.disable(); + await nextRender(); + this._setupEdit(); + } return; } - this._locationMarker = this.Leaflet!.marker(this.location, { - draggable: true, - }); - this._locationMarker.addEventListener( - "dragend", - // @ts-ignore - (ev: DragEndEvent) => this._updateLocation(ev.target.getLatLng()) - ); - this._leafletMap!.addLayer(this._locationMarker); + + if (!this.radius) { + this._locationMarker = this.Leaflet!.marker(this.location, { + draggable: true, + }); + this._setIcon(); + this._locationMarker.addEventListener( + "dragend", + // @ts-ignore + (ev: DragEndEvent) => this._locationUpdated(ev.target.getLatLng()) + ); + this._leafletMap!.addLayer(this._locationMarker); + } else { + this._locationMarker = this.Leaflet!.circle(this.location, { + color: "#FF9800", + radius: this.radius, + }); + this._leafletMap!.addLayer(this._locationMarker); + this._setupEdit(); + } + } + + private _updateRadius(): void { + if (!this._locationMarker || !this.radius) { + return; + } + (this._locationMarker as Circle).setRadius(this.radius); } static get styles(): CSSResult { @@ -119,6 +233,13 @@ class LocationEditor extends LitElement { #map { height: 100%; } + .leaflet-edit-move { + cursor: move !important; + } + .leaflet-edit-resize { + border-radius: 50%; + cursor: nesw-resize !important; + } `; } } diff --git a/src/components/map/ha-locations-editor.ts b/src/components/map/ha-locations-editor.ts new file mode 100644 index 0000000000..c8ef9325fe --- /dev/null +++ b/src/components/map/ha-locations-editor.ts @@ -0,0 +1,273 @@ +import { + LitElement, + property, + TemplateResult, + html, + CSSResult, + css, + customElement, + PropertyValues, +} from "lit-element"; +import { + Marker, + Map, + DragEndEvent, + LatLng, + Circle, + MarkerOptions, + DivIcon, +} from "leaflet"; +import { + setupLeafletMap, + LeafletModuleType, +} from "../../common/dom/setup-leaflet-map"; +import { fireEvent } from "../../common/dom/fire_event"; + +declare global { + // for fire event + interface HASSDomEvents { + "location-updated": { id: string; location: [number, number] }; + "radius-updated": { id: string; radius: number }; + "marker-clicked": { id: string }; + } +} + +export interface Location { + latitude: number; + longitude: number; + radius: number; + name: string; + id: string; + icon: string; +} + +@customElement("ha-locations-editor") +export class HaLocationsEditor extends LitElement { + @property() public locations?: Location[]; + public fitZoom = 16; + + // tslint:disable-next-line + private Leaflet?: LeafletModuleType; + // tslint:disable-next-line + private _leafletMap?: Map; + private _locationMarkers?: { [key: string]: Marker | Circle }; + + public fitMap(): void { + if ( + !this._leafletMap || + !this._locationMarkers || + !Object.keys(this._locationMarkers).length + ) { + return; + } + const bounds = this.Leaflet!.latLngBounds( + Object.values(this._locationMarkers).map((item) => item.getLatLng()) + ); + this._leafletMap.fitBounds(bounds.pad(0.5)); + } + + public fitMarker(id: string): void { + if (!this._leafletMap || !this._locationMarkers) { + return; + } + const marker = this._locationMarkers[id]; + if (!marker) { + return; + } + this._leafletMap.setView(marker.getLatLng(), this.fitZoom); + } + + protected render(): TemplateResult | void { + return html` +
+ `; + } + + protected firstUpdated(changedProps: PropertyValues): void { + super.firstUpdated(changedProps); + this._initMap(); + } + + protected updated(changedProps: PropertyValues): void { + super.updated(changedProps); + + // Still loading. + if (!this.Leaflet) { + return; + } + + if (changedProps.has("locations")) { + this._updateMarkers(); + } + } + + private get _mapEl(): HTMLDivElement { + return this.shadowRoot!.querySelector("div")!; + } + + private async _initMap(): Promise { + [this._leafletMap, this.Leaflet] = await setupLeafletMap( + this._mapEl, + false, + true + ); + this._updateMarkers(); + this.fitMap(); + this._leafletMap.invalidateSize(); + } + + private _updateLocation(ev: DragEndEvent) { + const marker = ev.target; + const latlng: LatLng = marker.getLatLng(); + let longitude: number = latlng.lng; + if (Math.abs(longitude) > 180.0) { + // Normalize longitude if map provides values beyond -180 to +180 degrees. + longitude = (((longitude % 360.0) + 540.0) % 360.0) - 180.0; + } + const location: [number, number] = [latlng.lat, longitude]; + fireEvent( + this, + "location-updated", + { id: marker.id, location }, + { bubbles: false } + ); + } + + private _updateRadius(ev: DragEndEvent) { + const marker = ev.target; + const circle = this._locationMarkers![marker.id] as Circle; + fireEvent( + this, + "radius-updated", + { id: marker.id, radius: circle.getRadius() }, + { bubbles: false } + ); + } + + private _markerClicked(ev: DragEndEvent) { + const marker = ev.target; + fireEvent(this, "marker-clicked", { id: marker.id }, { bubbles: false }); + } + + private _updateMarkers(): void { + if (this._locationMarkers) { + Object.values(this._locationMarkers).forEach((marker) => { + marker.remove(); + }); + this._locationMarkers = undefined; + } + + if (!this.locations || !this.locations.length) { + return; + } + + this._locationMarkers = {}; + + this.locations.forEach((location: Location) => { + let icon: DivIcon | undefined; + if (location.icon) { + // create icon + let iconHTML = ""; + const el = document.createElement("ha-icon"); + el.setAttribute("icon", location.icon); + iconHTML = el.outerHTML; + + icon = this.Leaflet!.divIcon({ + html: iconHTML, + iconSize: [24, 24], + className: "light leaflet-edit-move", + }); + } + if (location.radius) { + const circle = this.Leaflet!.circle( + [location.latitude, location.longitude], + { + color: "#FF9800", + radius: location.radius, + } + ); + // @ts-ignore + circle.editing.enable(); + circle.addTo(this._leafletMap!); + // @ts-ignore + const moveMarker = circle.editing._moveMarker; + // @ts-ignore + const resizeMarker = circle.editing._resizeMarkers[0]; + if (icon) { + moveMarker.setIcon(icon); + } + resizeMarker.id = moveMarker.id = location.id; + moveMarker + .addEventListener( + "dragend", + // @ts-ignore + (ev: DragEndEvent) => this._updateLocation(ev) + ) + .addEventListener( + "click", + // @ts-ignore + (ev: MouseEvent) => this._markerClicked(ev) + ); + resizeMarker.addEventListener( + "dragend", + // @ts-ignore + (ev: DragEndEvent) => this._updateRadius(ev) + ); + this._locationMarkers![location.id] = circle; + } else { + const options: MarkerOptions = { + draggable: true, + title: location.name, + }; + + if (icon) { + options.icon = icon; + } + + const marker = this.Leaflet!.marker( + [location.latitude, location.longitude], + options + ) + .addEventListener( + "dragend", + // @ts-ignore + (ev: DragEndEvent) => this._updateLocation(ev) + ) + .addEventListener( + "click", + // @ts-ignore + (ev: MouseEvent) => this._markerClicked(ev) + ) + .addTo(this._leafletMap); + marker.id = location.id; + + this._locationMarkers![location.id] = marker; + } + }); + } + + static get styles(): CSSResult { + return css` + :host { + display: block; + height: 300px; + } + #map { + height: 100%; + } + .leaflet-edit-move { + cursor: move !important; + } + .leaflet-edit-resize { + border-radius: 50%; + cursor: nesw-resize !important; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-locations-editor": HaLocationsEditor; + } +} diff --git a/src/data/zone.ts b/src/data/zone.ts new file mode 100644 index 0000000000..b76f73aab0 --- /dev/null +++ b/src/data/zone.ts @@ -0,0 +1,46 @@ +import { HomeAssistant } from "../types"; + +export interface Zone { + id: string; + name: string; + icon?: string; + latitude?: number; + longitude?: number; + passive?: boolean; + radius?: number; +} + +export interface ZoneMutableParams { + icon: string; + latitude: number; + longitude: number; + name: string; + passive: boolean; + radius: number; +} + +export const fetchZones = (hass: HomeAssistant) => + hass.callWS({ type: "zone/list" }); + +export const createZone = (hass: HomeAssistant, values: ZoneMutableParams) => + hass.callWS({ + type: "zone/create", + ...values, + }); + +export const updateZone = ( + hass: HomeAssistant, + zoneId: string, + updates: Partial +) => + hass.callWS({ + type: "zone/update", + zone_id: zoneId, + ...updates, + }); + +export const deleteZone = (hass: HomeAssistant, zoneId: string) => + hass.callWS({ + type: "zone/delete", + zone_id: zoneId, + }); diff --git a/src/panels/config/dashboard/ha-config-dashboard.ts b/src/panels/config/dashboard/ha-config-dashboard.ts index 893e237f4f..143c80bb51 100644 --- a/src/panels/config/dashboard/ha-config-dashboard.ts +++ b/src/panels/config/dashboard/ha-config-dashboard.ts @@ -94,6 +94,7 @@ class HaConfigDashboard extends LitElement { .pages=${[ { page: "integrations", core: true }, { page: "devices", core: true }, + { page: "entities", core: true }, { page: "automation" }, { page: "script" }, { page: "scene" }, @@ -107,8 +108,8 @@ class HaConfigDashboard extends LitElement { .pages=${[ { page: "core", core: true }, { page: "server_control", core: true }, - { page: "entities", core: true }, { page: "areas", core: true }, + { page: "zone" }, { page: "person" }, { page: "users", core: true }, { page: "zha" }, diff --git a/src/panels/config/ha-config-router.ts b/src/panels/config/ha-config-router.ts index 81f5ab3f53..dd883f0d0a 100644 --- a/src/panels/config/ha-config-router.ts +++ b/src/panels/config/ha-config-router.ts @@ -125,6 +125,13 @@ class HaConfigRouter extends HassRouterPage { /* webpackChunkName: "panel-config-users" */ "./users/ha-config-users" ), }, + zone: { + tag: "ha-config-zone", + load: () => + import( + /* webpackChunkName: "panel-config-zone" */ "./zone/ha-config-zone" + ), + }, zha: { tag: "zha-config-dashboard-router", load: () => diff --git a/src/panels/config/ha-panel-config.ts b/src/panels/config/ha-panel-config.ts index 092287e9a9..b864929901 100644 --- a/src/panels/config/ha-panel-config.ts +++ b/src/panels/config/ha-panel-config.ts @@ -115,6 +115,7 @@ class HaPanelConfig extends LitElement { { page: "scene" }, { page: "core", core: true }, { page: "areas", core: true }, + { page: "zone" }, { page: "person" }, { page: "users", core: true }, { page: "server_control", core: true }, @@ -163,7 +164,7 @@ class HaPanelConfig extends LitElement { } .side-bar { - border-right: 1px solid #e0e0e0; + border-right: 1px solid var(--divider-color); background: white; width: 320px; float: left; diff --git a/src/panels/config/server_control/ha-config-section-server-control.js b/src/panels/config/server_control/ha-config-section-server-control.js index eac4ace9b5..3e44176e9e 100644 --- a/src/panels/config/server_control/ha-config-section-server-control.js +++ b/src/panels/config/server_control/ha-config-section-server-control.js @@ -148,6 +148,24 @@ class HaConfigSectionServerControl extends LocalizeMixin(PolymerElement) { + +
+ [[localize('ui.panel.config.server_control.section.reloading.zone')]] + +
{ + this._params = params; + this._error = undefined; + if (this._params.entry) { + this._name = this._params.entry.name || ""; + this._icon = this._params.entry.icon || ""; + this._latitude = this._params.entry.latitude || this.hass.config.latitude; + this._longitude = + this._params.entry.longitude || this.hass.config.longitude; + this._passive = this._params.entry.passive || false; + this._radius = this._params.entry.radius || 100; + } else { + this._name = ""; + this._icon = ""; + this._latitude = this.hass.config.latitude; + this._longitude = this.hass.config.longitude; + this._passive = false; + this._radius = 100; + } + await this.updateComplete; + } + + protected render(): TemplateResult | void { + if (!this._params) { + return html``; + } + return html` + +
+ ${this._error + ? html` +
${this._error}
+ ` + : ""} +
+ + + + + + +

+ ${this.hass!.localize("ui.panel.config.zone.detail.passive_note")} +

+ ${this.hass!.localize( + "ui.panel.config.zone.detail.passive" + )} +
+
+ ${this._params.entry + ? html` + + ${this.hass!.localize("ui.panel.config.zone.detail.delete")} + + ` + : html``} + + ${this._params.entry + ? this.hass!.localize("ui.panel.config.zone.detail.update") + : this.hass!.localize("ui.panel.config.zone.detail.create")} + +
+ `; + } + + private get _locationValue() { + return [Number(this._latitude), Number(this._longitude)]; + } + + private _locationChanged(ev) { + [this._latitude, this._longitude] = ev.currentTarget.location; + this._radius = ev.currentTarget.radius; + } + + private _passiveChanged(ev) { + this._passive = ev.target.checked; + } + + private _valueChanged(ev: CustomEvent) { + const configValue = (ev.target as any).configValue; + + this._error = undefined; + this[`_${configValue}`] = ev.detail.value; + } + + private async _updateEntry() { + this._submitting = true; + try { + const values: ZoneMutableParams = { + name: this._name.trim(), + icon: this._icon.trim(), + latitude: this._latitude, + longitude: this._longitude, + passive: this._passive, + radius: this._radius, + }; + if (this._params!.entry) { + await this._params!.updateEntry!(values); + } else { + await this._params!.createEntry(values); + } + this._params = undefined; + } catch (err) { + this._error = err ? err.message : "Unknown error"; + } finally { + this._submitting = false; + } + } + + private async _deleteEntry() { + this._submitting = true; + try { + if (await this._params!.removeEntry!()) { + this._params = undefined; + } + } finally { + this._submitting = false; + } + } + + private _close(): void { + this._params = undefined; + } + + static get styles(): CSSResult[] { + return [ + css` + mwc-dialog { + --mdc-dialog-title-ink-color: var(--primary-text-color); + } + @media only screen and (min-width: 600px) { + mwc-dialog { + --mdc-dialog-min-width: 600px; + } + } + .form { + padding-bottom: 24px; + } + ha-user-picker { + margin-top: 16px; + } + mwc-button.warning { + --mdc-theme-primary: var(--google-red-500); + } + .error { + color: var(--google-red-500); + } + a { + color: var(--primary-color); + } + p { + color: var(--primary-text-color); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-zone-detail": DialogZoneDetail; + } +} + +customElements.define("dialog-zone-detail", DialogZoneDetail); diff --git a/src/panels/config/zone/ha-config-zone.ts b/src/panels/config/zone/ha-config-zone.ts new file mode 100644 index 0000000000..1ab286f0c0 --- /dev/null +++ b/src/panels/config/zone/ha-config-zone.ts @@ -0,0 +1,296 @@ +import { + LitElement, + TemplateResult, + html, + css, + CSSResult, + property, + customElement, + query, +} from "lit-element"; +import "@polymer/paper-listbox/paper-listbox"; +import "@polymer/paper-item/paper-icon-item"; +import "@polymer/paper-item/paper-item-body"; + +import "../../../components/map/ha-locations-editor"; + +import { HomeAssistant } from "../../../types"; +import "../../../components/ha-card"; +import "../../../components/ha-fab"; +import "../../../layouts/hass-subpage"; +import "../../../layouts/hass-loading-screen"; +import { compare } from "../../../common/string/compare"; +import "../ha-config-section"; +import { showZoneDetailDialog } from "./show-dialog-zone-detail"; +import { + Zone, + fetchZones, + createZone, + updateZone, + deleteZone, + ZoneMutableParams, +} from "../../../data/zone"; +// tslint:disable-next-line +import { HaLocationsEditor } from "../../../components/map/ha-locations-editor"; + +@customElement("ha-config-zone") +export class HaConfigZone extends LitElement { + @property() public hass?: HomeAssistant; + @property() public isWide?: boolean; + @property() public narrow?: boolean; + @property() private _storageItems?: Zone[]; + @property() private _activeEntry: string = ""; + @query("ha-locations-editor") private _map?: HaLocationsEditor; + + protected render(): TemplateResult | void { + if (!this.hass || this._storageItems === undefined) { + return html` + + `; + } + const hass = this.hass; + const listBox = html` + + ${this._storageItems.map((entry) => { + return html` + + + + + ${entry.name} + + ${ + !this.narrow + ? html` + + ` + : "" + } + + + `; + })} + + ${this._storageItems.length === 0 + ? html` +
+ ${hass.localize("ui.panel.config.zone.no_zones_created_yet")} + + ${hass.localize("ui.panel.config.zone.create_zone")} +
+ ` + : html``} + `; + + return html` + + ${this.narrow + ? html` + + + ${hass.localize("ui.panel.config.zone.introduction")} + + ${listBox} + + ` + : ""} + ${!this.narrow + ? html` +
+ + ${listBox} +
+ ` + : ""} +
+ + + `; + } + + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + this._fetchData(); + } + + private async _fetchData() { + this._storageItems = (await fetchZones(this.hass!)).sort((ent1, ent2) => + compare(ent1.name, ent2.name) + ); + } + + private _locationUpdated(ev: CustomEvent) { + this._activeEntry = ev.detail.id; + const entry = this._storageItems!.find((item) => item.id === ev.detail.id); + if (!entry) { + return; + } + this._updateEntry(entry, { + latitude: ev.detail.location[0], + longitude: ev.detail.location[1], + }); + } + + private _radiusUpdated(ev: CustomEvent) { + this._activeEntry = ev.detail.id; + const entry = this._storageItems!.find((item) => item.id === ev.detail.id); + if (!entry) { + return; + } + this._updateEntry(entry, { + radius: ev.detail.radius, + }); + } + + private _markerClicked(ev: CustomEvent) { + this._activeEntry = ev.detail.id; + } + + private _createZone() { + this._openDialog(); + } + + private _itemClicked(ev: MouseEvent) { + if (this.narrow) { + this._openEditEntry(ev); + return; + } + + const entry: Zone = (ev.currentTarget! as any).entry; + this._map?.fitMarker(entry.id); + } + + private _openEditEntry(ev: MouseEvent) { + const entry: Zone = (ev.currentTarget! as any).entry; + this._openDialog(entry); + } + + private async _createEntry(values: ZoneMutableParams) { + const created = await createZone(this.hass!, values); + this._storageItems = this._storageItems!.concat( + created + ).sort((ent1, ent2) => compare(ent1.name, ent2.name)); + } + + private async _updateEntry(entry: Zone, values: Partial) { + const updated = await updateZone(this.hass!, entry!.id, values); + this._storageItems = this._storageItems!.map((ent) => + ent === entry ? updated : ent + ); + } + + private async _removeEntry(entry: Zone) { + if ( + !confirm(`${this.hass!.localize("ui.panel.config.zone.confirm_delete")} + +${this.hass!.localize("ui.panel.config.zone.confirm_delete2")}`) + ) { + return false; + } + + try { + await deleteZone(this.hass!, entry!.id); + this._storageItems = this._storageItems!.filter((ent) => ent !== entry); + return true; + } catch (err) { + return false; + } + } + + private async _openDialog(entry?: Zone) { + showZoneDetailDialog(this, { + entry, + createEntry: (values) => this._createEntry(values), + updateEntry: entry + ? (values) => this._updateEntry(entry, values) + : undefined, + removeEntry: entry ? () => this._removeEntry(entry) : undefined, + }); + } + + static get styles(): CSSResult { + return css` + a { + color: var(--primary-color); + } + ha-card { + max-width: 600px; + margin: 16px auto; + overflow: hidden; + } + .empty { + text-align: center; + padding: 8px; + } + .flex { + display: flex; + height: 100%; + } + ha-locations-editor { + flex-grow: 1; + height: 100%; + } + .flex paper-listbox { + border-left: 1px solid var(--divider-color); + width: 250px; + } + paper-icon-item { + padding-top: 4px; + padding-bottom: 4px; + } + paper-icon-item.iron-selected:before { + border-radius: 4px; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + pointer-events: none; + content: ""; + background-color: var(--sidebar-selected-icon-color); + opacity: 0.12; + transition: opacity 15ms linear; + will-change: opacity; + } + ha-card paper-item { + cursor: pointer; + } + ha-fab { + position: fixed; + bottom: 16px; + right: 16px; + z-index: 1; + } + ha-fab[is-wide] { + bottom: 24px; + right: 24px; + } + `; + } +} diff --git a/src/panels/config/zone/show-dialog-zone-detail.ts b/src/panels/config/zone/show-dialog-zone-detail.ts new file mode 100644 index 0000000000..58a9c9261a --- /dev/null +++ b/src/panels/config/zone/show-dialog-zone-detail.ts @@ -0,0 +1,23 @@ +import { fireEvent } from "../../../common/dom/fire_event"; +import { Zone, ZoneMutableParams } from "../../../data/zone"; + +export interface ZoneDetailDialogParams { + entry?: Zone; + createEntry: (values: ZoneMutableParams) => Promise; + updateEntry?: (updates: Partial) => Promise; + removeEntry?: () => Promise; +} + +export const loadZoneDetailDialog = () => + import(/* webpackChunkName: "zone-detail-dialog" */ "./dialog-zone-detail"); + +export const showZoneDetailDialog = ( + element: HTMLElement, + systemLogDetailParams: ZoneDetailDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-zone-detail", + dialogImport: loadZoneDetailDialog, + dialogParams: systemLogDetailParams, + }); +}; diff --git a/src/translations/en.json b/src/translations/en.json index 1c5e3f76a8..490639a65b 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -739,7 +739,9 @@ "group": "Reload groups", "automation": "Reload automations", "script": "Reload scripts", - "scene": "Reload scenes" + "scene": "Reload scenes", + "person": "Reload persons", + "zone": "Reload zones" }, "server_management": { "heading": "Server management", @@ -1335,6 +1337,30 @@ "update": "Update" } }, + "zone": { + "caption": "Zones", + "description": "Manage the zones you want to track persons in.", + "introduction": "Zones allow you to specify certain regions on earth. When a person is within a zone, the state will take the name from the zone. Zones can also be used as a trigger or condition inside automation setups.", + "no_zones_created_yet": "Looks like you have not created any zones yet.", + "create_zone": "Create Zone", + "add_zone": "Add Zone", + "confirm_delete": "Are you sure you want to delete this zone?", + "detail": { + "new_zone": "New Zone", + "name": "Name", + "icon": "Icon", + "icon_error_msg": "Icon should be in the format prefix:iconname, for example: mdi:home", + "radius": "Radius", + "latitude": "Latitude", + "longitude": "Longitude", + "passive": "Passive", + "passive_note": "Passive zones are hidden in the frontend and are not used as location for device trackers. This is usefull if you just want to use it for automations.", + "required_error_msg": "This field is required", + "delete": "Delete", + "create": "Create", + "update": "Update" + } + }, "integrations": { "caption": "Integrations", "description": "Manage and setup integrations", diff --git a/yarn.lock b/yarn.lock index a9acee3454..ade5077687 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2473,6 +2473,20 @@ resolved "https://registry.yarnpkg.com/@types/launchpad/-/launchpad-0.6.0.tgz#37296109b7f277f6e6c5fd7e0c0706bc918fbb51" integrity sha1-NylhCbfyd/bmxf1+DAcGvJGPu1E= +"@types/leaflet-draw@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@types/leaflet-draw/-/leaflet-draw-1.0.1.tgz#66e0c2c8b93b23487f836a8d65a769b98aa0bc5b" + integrity sha512-/urwtXkpvv7rtre5A6plvXHSUDmFvDrwqpQRKseBCC2bIhIhBtMDf+plqQmi0vhvSk0Pqgk8qH1rtC8EVxPdmg== + dependencies: + "@types/leaflet" "*" + +"@types/leaflet@*": + version "1.5.8" + resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.5.8.tgz#1c550803672fc5866b8b2c38512009f2b5d4205d" + integrity sha512-qpi5n4LmwenUFZ+VZ7ytRgHK+ZAclIvloL2zoKCmmj244WD2hBcLbUZ6Szvajfe3sIkSYEJ8WZ1p9VYl8tRsMA== + dependencies: + "@types/geojson" "*" + "@types/leaflet@^1.4.3": version "1.4.3" resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.4.3.tgz#62638cb73770eeaed40222042afbcc7b495f0cc4" @@ -8649,6 +8663,11 @@ lead@^1.0.0: dependencies: flush-write-stream "^1.0.2" +leaflet-draw@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/leaflet-draw/-/leaflet-draw-1.0.4.tgz#45be92f378ed253e7202fdeda1fcc71885198d46" + integrity sha512-rsQ6saQO5ST5Aj6XRFylr5zvarWgzWnrg46zQ1MEOEIHsppdC/8hnN8qMoFvACsPvTioAuysya/TVtog15tyAQ== + leaflet@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.4.0.tgz#d5f56eeb2aa32787c24011e8be4c77e362ae171b"