Generalize map (#9331)

* Generalize map

* Fix path opacity

* Add fitZones
This commit is contained in:
Bram Kragten 2021-06-10 14:22:44 +02:00 committed by GitHub
parent 11a77253f4
commit 194829f5b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 878 additions and 1513 deletions

View File

@ -66,9 +66,7 @@
"@polymer/iron-autogrow-textarea": "^3.0.1",
"@polymer/iron-flex-layout": "^3.0.1",
"@polymer/iron-icon": "^3.0.1",
"@polymer/iron-image": "^3.0.1",
"@polymer/iron-input": "^3.0.1",
"@polymer/iron-label": "^3.0.1",
"@polymer/iron-overlay-behavior": "^3.0.2",
"@polymer/iron-resizable-behavior": "^3.0.1",
"@polymer/paper-checkbox": "^3.1.0",

View File

@ -6,8 +6,7 @@ export type LeafletDrawModuleType = typeof import("leaflet-draw");
export const setupLeafletMap = async (
mapElement: HTMLElement,
darkMode?: boolean,
draw = false
darkMode?: boolean
): Promise<[Map, LeafletModuleType, TileLayer]> => {
if (!mapElement.parentNode) {
throw new Error("Cannot setup Leaflet map on disconnected element");
@ -17,10 +16,6 @@ export const setupLeafletMap = async (
.default as LeafletModuleType;
Leaflet.Icon.Default.imagePath = "/static/images/leaflet/images/";
if (draw) {
await import("leaflet-draw");
}
const map = Leaflet.map(mapElement);
const style = document.createElement("link");
style.setAttribute("href", "/static/images/leaflet/leaflet.css");

View File

@ -0,0 +1,69 @@
import { LitElement, html, css } from "lit";
import { property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../../common/dom/fire_event";
import { HomeAssistant } from "../../types";
class HaEntityMarker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "entity-id" }) public entityId?: string;
@property({ attribute: "entity-name" }) public entityName?: string;
@property({ attribute: "entity-picture" }) public entityPicture?: string;
@property({ attribute: "entity-color" }) public entityColor?: string;
protected render() {
return html`
<div
class="marker"
style=${styleMap({ "border-color": this.entityColor })}
@click=${this._badgeTap}
>
${this.entityPicture
? html`<div
class="entity-picture"
style=${styleMap({
"background-image": `url(${this.entityPicture})`,
})}
></div>`
: this.entityName}
</div>
`;
}
private _badgeTap(ev: Event) {
ev.stopPropagation();
if (this.entityId) {
fireEvent(this, "hass-more-info", { entityId: this.entityId });
}
}
static get styles() {
return css`
.marker {
display: flex;
justify-content: center;
align-items: center;
box-sizing: border-box;
overflow: hidden;
width: 48px;
height: 48px;
font-size: var(--ha-marker-font-size, 1.5em);
border-radius: 50%;
border: 1px solid var(--ha-marker-color, var(--primary-color));
color: var(--primary-text-color);
background-color: var(--card-background-color);
}
.entity-picture {
background-size: cover;
height: 100%;
width: 100%;
}
`;
}
}
customElements.define("ha-entity-marker", HaEntityMarker);

View File

@ -1,299 +0,0 @@
import {
Circle,
DivIcon,
DragEndEvent,
LatLng,
LeafletMouseEvent,
Map,
Marker,
TileLayer,
} from "leaflet";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import {
LeafletModuleType,
replaceTileLayer,
setupLeafletMap,
} from "../../common/dom/setup-leaflet-map";
import { nextRender } from "../../common/util/render-status";
import { defaultRadiusColor } from "../../data/zone";
import { HomeAssistant } from "../../types";
@customElement("ha-location-editor")
class LocationEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Array }) public location?: [number, number];
@property({ type: Number }) public radius?: number;
@property() public radiusColor?: string;
@property() public icon?: string;
@property({ type: Boolean }) public darkMode?: boolean;
public fitZoom = 16;
private _iconEl?: DivIcon;
private _ignoreFitToMap?: [number, number];
// eslint-disable-next-line
private Leaflet?: LeafletModuleType;
private _leafletMap?: Map;
private _tileLayer?: TileLayer;
private _locationMarker?: Marker | Circle;
public fitMap(): void {
if (!this._leafletMap || !this.location) {
return;
}
if (this._locationMarker && "getBounds" in this._locationMarker) {
this._leafletMap.fitBounds(this._locationMarker.getBounds());
} else {
this._leafletMap.setView(this.location, this.fitZoom);
}
this._ignoreFitToMap = this.location;
}
protected render(): TemplateResult {
return html` <div id="map"></div> `;
}
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("location")) {
this._updateMarker();
if (
this.location &&
(!this._ignoreFitToMap ||
this._ignoreFitToMap[0] !== this.location[0] ||
this._ignoreFitToMap[1] !== this.location[1])
) {
this.fitMap();
}
}
if (changedProps.has("radius")) {
this._updateRadius();
}
if (changedProps.has("radiusColor")) {
this._updateRadiusColor();
}
if (changedProps.has("icon")) {
this._updateIcon();
}
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.themes.darkMode === this.hass.themes.darkMode) {
return;
}
if (!this._leafletMap || !this._tileLayer) {
return;
}
this._tileLayer = replaceTileLayer(
this.Leaflet,
this._leafletMap,
this._tileLayer,
this.hass.themes.darkMode
);
}
}
private get _mapEl(): HTMLDivElement {
return this.shadowRoot!.querySelector("div")!;
}
private async _initMap(): Promise<void> {
[this._leafletMap, this.Leaflet, this._tileLayer] = await setupLeafletMap(
this._mapEl,
this.darkMode ?? this.hass.themes.darkMode,
Boolean(this.radius)
);
this._leafletMap.addEventListener(
"click",
// @ts-ignore
(ev: LeafletMouseEvent) => this._locationUpdated(ev.latlng)
);
this._updateIcon();
this._updateMarker();
this.fitMap();
this._leafletMap.invalidateSize();
}
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.
longitude = (((longitude % 360.0) + 540.0) % 360.0) - 180.0;
}
this.location = this._ignoreFitToMap = [latlng.lat, longitude];
fireEvent(this, "change", undefined, { bubbles: false });
}
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<void> {
if (!this.location) {
if (this._locationMarker) {
this._locationMarker.remove();
this._locationMarker = undefined;
}
return;
}
if (this._locationMarker) {
this._locationMarker.setLatLng(this.location);
if (this.radius) {
// @ts-ignore
this._locationMarker.editing.disable();
await nextRender();
this._setupEdit();
}
return;
}
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: this.radiusColor || defaultRadiusColor,
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);
}
private _updateRadiusColor(): void {
if (!this._locationMarker || !this.radius) {
return;
}
(this._locationMarker as Circle).setStyle({ color: this.radiusColor });
}
static get styles(): CSSResultGroup {
return css`
:host {
display: block;
height: 300px;
}
#map {
height: 100%;
background: inherit;
}
.leaflet-edit-move {
border-radius: 50%;
cursor: move !important;
}
.leaflet-edit-resize {
border-radius: 50%;
cursor: nesw-resize !important;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-location-editor": LocationEditor;
}
}

View File

@ -3,10 +3,8 @@ import {
DivIcon,
DragEndEvent,
LatLng,
Map,
Marker,
MarkerOptions,
TileLayer,
} from "leaflet";
import {
css,
@ -16,15 +14,13 @@ import {
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import {
LeafletModuleType,
replaceTileLayer,
setupLeafletMap,
} from "../../common/dom/setup-leaflet-map";
import { defaultRadiusColor } from "../../data/zone";
import { HomeAssistant } from "../../types";
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
import type { HomeAssistant } from "../../types";
import "./ha-map";
import type { HaMap } from "./ha-map";
declare global {
// for fire event
@ -51,38 +47,40 @@ export interface MarkerLocation {
export class HaLocationsEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public locations?: MarkerLocation[];
@property({ attribute: false }) public locations?: MarkerLocation[];
public fitZoom = 16;
@property({ type: Boolean }) public autoFit = false;
@property({ type: Number }) public zoom = 16;
@property({ type: Boolean }) public darkMode?: boolean;
@state() private _locationMarkers?: Record<string, Marker | Circle>;
@state() private _circles: Record<string, Circle> = {};
@query("ha-map", true) private map!: HaMap;
// eslint-disable-next-line
private Leaflet?: LeafletModuleType;
// eslint-disable-next-line
private _leafletMap?: Map;
constructor() {
super();
private _tileLayer?: TileLayer;
private _locationMarkers?: { [key: string]: Marker | Circle };
private _circles: Record<string, Circle> = {};
import("leaflet").then((module) => {
import("leaflet-draw").then(() => {
this.Leaflet = module.default as LeafletModuleType;
this._updateMarkers();
this.updateComplete.then(() => this.fitMap());
});
});
}
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));
this.map.fitMap();
}
public fitMarker(id: string): void {
if (!this._leafletMap || !this._locationMarkers) {
if (!this.map.leafletMap || !this._locationMarkers) {
return;
}
const marker = this._locationMarkers[id];
@ -90,29 +88,44 @@ export class HaLocationsEditor extends LitElement {
return;
}
if ("getBounds" in marker) {
this._leafletMap.fitBounds(marker.getBounds());
this.map.leafletMap.fitBounds(marker.getBounds());
(marker as Circle).bringToFront();
} else {
const circle = this._circles[id];
if (circle) {
this._leafletMap.fitBounds(circle.getBounds());
this.map.leafletMap.fitBounds(circle.getBounds());
} else {
this._leafletMap.setView(marker.getLatLng(), this.fitZoom);
this.map.leafletMap.setView(marker.getLatLng(), this.zoom);
}
}
}
protected render(): TemplateResult {
return html` <div id="map"></div> `;
return html`<ha-map
.hass=${this.hass}
.layers=${this._getLayers(this._circles, this._locationMarkers)}
.zoom=${this.zoom}
.autoFit=${this.autoFit}
.darkMode=${this.darkMode}
></ha-map>`;
}
protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps);
this._initMap();
}
private _getLayers = memoizeOne(
(
circles: Record<string, Circle>,
markers?: Record<string, Marker | Circle>
): Array<Marker | Circle> => {
const layers: Array<Marker | Circle> = [];
Array.prototype.push.apply(layers, Object.values(circles));
if (markers) {
Array.prototype.push.apply(layers, Object.values(markers));
}
return layers;
}
);
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
public willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
// Still loading.
if (!this.Leaflet) {
@ -122,37 +135,6 @@ export class HaLocationsEditor extends LitElement {
if (changedProps.has("locations")) {
this._updateMarkers();
}
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.themes.darkMode === this.hass.themes.darkMode) {
return;
}
if (!this._leafletMap || !this._tileLayer) {
return;
}
this._tileLayer = replaceTileLayer(
this.Leaflet,
this._leafletMap,
this._tileLayer,
this.hass.themes.darkMode
);
}
}
private get _mapEl(): HTMLDivElement {
return this.shadowRoot!.querySelector("div")!;
}
private async _initMap(): Promise<void> {
[this._leafletMap, this.Leaflet, this._tileLayer] = await setupLeafletMap(
this._mapEl,
this.hass.themes.darkMode,
true
);
this._updateMarkers();
this.fitMap();
this._leafletMap.invalidateSize();
}
private _updateLocation(ev: DragEndEvent) {
@ -189,21 +171,18 @@ export class HaLocationsEditor extends LitElement {
}
private _updateMarkers(): void {
if (this._locationMarkers) {
Object.values(this._locationMarkers).forEach((marker) => {
marker.remove();
});
this._locationMarkers = undefined;
Object.values(this._circles).forEach((circle) => circle.remove());
this._circles = {};
}
if (!this.locations || !this.locations.length) {
this._circles = {};
this._locationMarkers = undefined;
return;
}
this._locationMarkers = {};
const locationMarkers = {};
const circles = {};
const defaultZoneRadiusColor = getComputedStyle(this).getPropertyValue(
"--accent-color"
);
this.locations.forEach((location: MarkerLocation) => {
let icon: DivIcon | undefined;
@ -228,45 +207,46 @@ export class HaLocationsEditor extends LitElement {
const circle = this.Leaflet!.circle(
[location.latitude, location.longitude],
{
color: location.radius_color || defaultRadiusColor,
color: location.radius_color || defaultZoneRadiusColor,
radius: location.radius,
}
);
circle.addTo(this._leafletMap!);
if (location.radius_editable || location.location_editable) {
// @ts-ignore
circle.editing.enable();
// @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)
);
if (location.radius_editable) {
resizeMarker.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._updateRadius(ev)
);
} else {
resizeMarker.remove();
}
this._locationMarkers![location.id] = circle;
circle.addEventListener("add", () => {
// @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)
);
if (location.radius_editable) {
resizeMarker.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._updateRadius(ev)
);
} else {
resizeMarker.remove();
}
});
locationMarkers[location.id] = circle;
} else {
this._circles[location.id] = circle;
circles[location.id] = circle;
}
}
if (
@ -275,6 +255,7 @@ export class HaLocationsEditor extends LitElement {
) {
const options: MarkerOptions = {
title: location.name,
draggable: location.location_editable,
};
if (icon) {
@ -293,13 +274,14 @@ export class HaLocationsEditor extends LitElement {
"click",
// @ts-ignore
(ev: MouseEvent) => this._markerClicked(ev)
)
.addTo(this._leafletMap!);
);
(marker as any).id = location.id;
this._locationMarkers![location.id] = marker;
locationMarkers[location.id] = marker;
}
});
this._circles = circles;
this._locationMarkers = locationMarkers;
}
static get styles(): CSSResultGroup {
@ -308,23 +290,9 @@ export class HaLocationsEditor extends LitElement {
display: block;
height: 300px;
}
#map {
ha-map {
height: 100%;
}
.leaflet-marker-draggable {
cursor: move !important;
}
.leaflet-edit-resize {
border-radius: 50%;
cursor: nesw-resize !important;
}
.named-icon {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
text-align: center;
}
`;
}
}

View File

@ -1,13 +1,15 @@
import { Circle, Layer, Map, Marker, TileLayer } from "leaflet";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property } from "lit/decorators";
Circle,
CircleMarker,
LatLngTuple,
Layer,
Map,
Marker,
Polyline,
TileLayer,
} from "leaflet";
import { css, CSSResultGroup, PropertyValues, ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import {
LeafletModuleType,
replaceTileLayer,
@ -15,194 +17,324 @@ import {
} 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 "../../panels/map/ha-entity-marker";
import "./ha-entity-marker";
import { HomeAssistant } from "../../types";
import "../ha-icon-button";
import { installResizeObserver } from "../../panels/lovelace/common/install-resize-observer";
const getEntityId = (entity: string | HaMapEntity): string =>
typeof entity === "string" ? entity : entity.entity_id;
export interface HaMapPaths {
points: LatLngTuple[];
color?: string;
gradualOpacity?: number;
}
export interface HaMapEntity {
entity_id: string;
color: string;
}
@customElement("ha-map")
class HaMap extends LitElement {
export class HaMap extends ReactiveElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public entities?: string[];
@property({ attribute: false }) public entities?: string[] | HaMapEntity[];
@property() public darkMode?: boolean;
@property({ attribute: false }) public paths?: HaMapPaths[];
@property() public zoom?: number;
@property({ attribute: false }) public layers?: Layer[];
@property({ type: Boolean }) public autoFit = false;
@property({ type: Boolean }) public fitZones?: boolean;
@property({ type: Boolean }) public darkMode?: boolean;
@property({ type: Number }) public zoom = 14;
@state() private _loaded = false;
public leafletMap?: Map;
// eslint-disable-next-line
private Leaflet?: LeafletModuleType;
private _leafletMap?: Map;
private _tileLayer?: TileLayer;
// @ts-ignore
private _resizeObserver?: ResizeObserver;
private _debouncedResizeListener = debounce(
() => {
if (!this._leafletMap) {
return;
}
this._leafletMap.invalidateSize();
},
100,
false
);
private _mapItems: Array<Marker | Circle> = [];
private _mapZones: Array<Marker | Circle> = [];
private _connected = false;
private _mapPaths: Array<Polyline | CircleMarker> = [];
public connectedCallback(): void {
super.connectedCallback();
this._connected = true;
if (this.hasUpdated) {
this.loadMap();
this._attachObserver();
}
this._loadMap();
this._attachObserver();
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._connected = false;
if (this._leafletMap) {
this._leafletMap.remove();
this._leafletMap = undefined;
if (this.leafletMap) {
this.leafletMap.remove();
this.leafletMap = undefined;
this.Leaflet = undefined;
}
this._loaded = false;
if (this._resizeObserver) {
this._resizeObserver.unobserve(this._mapEl);
} else {
window.removeEventListener("resize", this._debouncedResizeListener);
this._resizeObserver.unobserve(this);
}
}
protected render(): TemplateResult {
if (!this.entities) {
return html``;
}
return html` <div id="map"></div> `;
}
protected update(changedProps: PropertyValues) {
super.update(changedProps);
protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps);
this.loadMap();
if (this._connected) {
this._attachObserver();
}
}
protected shouldUpdate(changedProps) {
if (!changedProps.has("hass") || changedProps.size > 1) {
return true;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || !this.entities) {
return true;
}
// Check if any state has changed
for (const entity of this.entities) {
if (oldHass.states[entity] !== this.hass!.states[entity]) {
return true;
}
}
return false;
}
protected updated(changedProps: PropertyValues): void {
if (changedProps.has("hass")) {
this._drawEntities();
this._fitMap();
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.themes.darkMode === this.hass.themes.darkMode) {
return;
}
if (!this.Leaflet || !this._leafletMap || !this._tileLayer) {
return;
}
this._tileLayer = replaceTileLayer(
this.Leaflet,
this._leafletMap,
this._tileLayer,
this.hass.themes.darkMode
);
}
}
private get _mapEl(): HTMLDivElement {
return this.shadowRoot!.getElementById("map") as HTMLDivElement;
}
private async loadMap(): Promise<void> {
[this._leafletMap, this.Leaflet, this._tileLayer] = await setupLeafletMap(
this._mapEl,
this.darkMode ?? this.hass.themes.darkMode
);
this._drawEntities();
this._leafletMap.invalidateSize();
this._fitMap();
}
private _fitMap(): void {
if (!this._leafletMap || !this.Leaflet || !this.hass) {
if (!this._loaded) {
return;
}
if (this._mapItems.length === 0) {
this._leafletMap.setView(
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (changedProps.has("_loaded") || changedProps.has("entities")) {
this._drawEntities();
} else if (this._loaded && oldHass && this.entities) {
// Check if any state has changed
for (const entity of this.entities) {
if (
oldHass.states[getEntityId(entity)] !==
this.hass!.states[getEntityId(entity)]
) {
this._drawEntities();
break;
}
}
}
if (changedProps.has("_loaded") || changedProps.has("paths")) {
this._drawPaths();
}
if (changedProps.has("_loaded") || changedProps.has("layers")) {
this._drawLayers(changedProps.get("layers") as Layer[] | undefined);
}
if (
changedProps.has("_loaded") ||
((changedProps.has("entities") || changedProps.has("layers")) &&
this.autoFit)
) {
this.fitMap();
}
if (changedProps.has("zoom")) {
this.leafletMap!.setZoom(this.zoom);
}
if (
!changedProps.has("darkMode") &&
(!changedProps.has("hass") ||
(oldHass && oldHass.themes.darkMode === this.hass.themes.darkMode))
) {
return;
}
const darkMode = this.darkMode ?? this.hass.themes.darkMode;
this._tileLayer = replaceTileLayer(
this.Leaflet!,
this.leafletMap!,
this._tileLayer!,
darkMode
);
this.shadowRoot!.getElementById("map")!.classList.toggle("dark", darkMode);
}
private async _loadMap(): Promise<void> {
let map = this.shadowRoot!.getElementById("map");
if (!map) {
map = document.createElement("div");
map.id = "map";
this.shadowRoot!.append(map);
}
const darkMode = this.darkMode ?? this.hass.themes.darkMode;
[this.leafletMap, this.Leaflet, this._tileLayer] = await setupLeafletMap(
map,
darkMode
);
this.shadowRoot!.getElementById("map")!.classList.toggle("dark", darkMode);
this._loaded = true;
}
public fitMap(): void {
if (!this.leafletMap || !this.Leaflet || !this.hass) {
return;
}
if (!this._mapItems.length && !this.layers?.length) {
this.leafletMap.setView(
new this.Leaflet.LatLng(
this.hass.config.latitude,
this.hass.config.longitude
),
this.zoom || 14
this.zoom
);
return;
}
const bounds = this.Leaflet.latLngBounds(
let 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);
if (this.fitZones) {
this._mapZones?.forEach((zone) => {
bounds.extend(
"getBounds" in zone ? zone.getBounds() : zone.getLatLng()
);
});
}
this.layers?.forEach((layer: any) => {
bounds.extend(
"getBounds" in layer ? layer.getBounds() : layer.getLatLng()
);
});
if (!this.layers) {
bounds = bounds.pad(0.5);
}
this.leafletMap.fitBounds(bounds, { maxZoom: this.zoom });
}
private _drawLayers(prevLayers: Layer[] | undefined): void {
if (prevLayers) {
prevLayers.forEach((layer) => layer.remove());
}
if (!this.layers) {
return;
}
const map = this.leafletMap!;
this.layers.forEach((layer) => {
map.addLayer(layer);
});
}
private _drawPaths(): void {
const hass = this.hass;
const map = this.leafletMap;
const Leaflet = this.Leaflet;
if (!hass || !map || !Leaflet) {
return;
}
if (this._mapPaths.length) {
this._mapPaths.forEach((marker) => marker.remove());
this._mapPaths = [];
}
if (!this.paths) {
return;
}
const darkPrimaryColor = getComputedStyle(this).getPropertyValue(
"--dark-primary-color"
);
this.paths.forEach((path) => {
let opacityStep: number;
let baseOpacity: number;
if (path.gradualOpacity) {
opacityStep = path.gradualOpacity / (path.points.length - 2);
baseOpacity = 1 - path.gradualOpacity;
}
for (
let pointIndex = 0;
pointIndex < path.points.length - 1;
pointIndex++
) {
const opacity = path.gradualOpacity
? baseOpacity! + pointIndex * opacityStep!
: undefined;
// DRAW point
this._mapPaths.push(
Leaflet!.circleMarker(path.points[pointIndex], {
radius: 3,
color: path.color || darkPrimaryColor,
opacity,
fillOpacity: opacity,
interactive: false,
})
);
// DRAW line between this and next point
this._mapPaths.push(
Leaflet!.polyline(
[path.points[pointIndex], path.points[pointIndex + 1]],
{
color: path.color || darkPrimaryColor,
opacity,
interactive: false,
}
)
);
}
const pointIndex = path.points.length - 1;
if (pointIndex >= 0) {
const opacity = path.gradualOpacity
? baseOpacity! + pointIndex * opacityStep!
: undefined;
// DRAW end path point
this._mapPaths.push(
Leaflet!.circleMarker(path.points[pointIndex], {
radius: 3,
color: path.color || darkPrimaryColor,
opacity,
fillOpacity: opacity,
interactive: false,
})
);
}
this._mapPaths.forEach((marker) => map.addLayer(marker));
});
}
private _drawEntities(): void {
const hass = this.hass;
const map = this._leafletMap;
const map = this.leafletMap;
const Leaflet = this.Leaflet;
if (!hass || !map || !Leaflet) {
return;
}
if (this._mapItems) {
if (this._mapItems.length) {
this._mapItems.forEach((marker) => marker.remove());
this._mapItems = [];
}
const mapItems: Layer[] = (this._mapItems = []);
if (this._mapZones) {
if (this._mapZones.length) {
this._mapZones.forEach((marker) => marker.remove());
this._mapZones = [];
}
const mapZones: Layer[] = (this._mapZones = []);
const allEntities = this.entities!.concat();
if (!this.entities) {
return;
}
for (const entity of allEntities) {
const entityId = entity;
const stateObj = hass.states[entityId];
const computedStyles = getComputedStyle(this);
const zoneColor = computedStyles.getPropertyValue("--accent-color");
const darkPrimaryColor = computedStyles.getPropertyValue(
"--dark-primary-color"
);
const className =
this.darkMode ?? this.hass.themes.darkMode ? "dark" : "light";
for (const entity of this.entities) {
const stateObj = hass.states[getEntityId(entity)];
if (!stateObj) {
continue;
}
@ -240,13 +372,12 @@ class HaMap extends LitElement {
}
// create marker with the icon
mapZones.push(
this._mapZones.push(
Leaflet.marker([latitude, longitude], {
icon: Leaflet.divIcon({
html: iconHTML,
iconSize: [24, 24],
className:
this.darkMode ?? this.hass.themes.darkMode ? "dark" : "light",
className,
}),
interactive: false,
title,
@ -254,10 +385,10 @@ class HaMap extends LitElement {
);
// create circle around it
mapZones.push(
this._mapZones.push(
Leaflet.circle([latitude, longitude], {
interactive: false,
color: "#FF9800",
color: zoneColor,
radius,
})
);
@ -273,17 +404,20 @@ class HaMap extends LitElement {
.join("")
.substr(0, 3);
// create market with the icon
mapItems.push(
// create marker with the icon
this._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: `
<ha-entity-marker
entity-id="${entityId}"
entity-id="${getEntityId(entity)}"
entity-name="${entityName}"
entity-picture="${entityPicture || ""}"
${
typeof entity !== "string"
? `entity-color="${entity.color}"`
: ""
}
></ha-entity-marker>
`,
iconSize: [48, 48],
@ -295,10 +429,10 @@ class HaMap extends LitElement {
// create circle around if entity has accuracy
if (gpsAccuracy) {
mapItems.push(
this._mapItems.push(
Leaflet.circle([latitude, longitude], {
interactive: false,
color: "#0288D1",
color: darkPrimaryColor,
radius: gpsAccuracy,
})
);
@ -309,20 +443,14 @@ class HaMap extends LitElement {
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);
private async _attachObserver(): Promise<void> {
if (!this._resizeObserver) {
await installResizeObserver();
this._resizeObserver = new ResizeObserver(() => {
this.leafletMap?.invalidateSize({ debounceMoveend: true });
});
}
this._resizeObserver.observe(this);
}
static get styles(): CSSResultGroup {
@ -337,13 +465,25 @@ class HaMap extends LitElement {
#map.dark {
background: #090909;
}
.light {
color: #000000;
}
.dark {
color: #ffffff;
}
.light {
color: #000000;
.leaflet-marker-draggable {
cursor: move !important;
}
.leaflet-edit-resize {
border-radius: 50%;
cursor: nesw-resize !important;
}
.named-icon {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
text-align: center;
}
`;
}

View File

@ -1,14 +1,6 @@
import { navigate } from "../common/navigate";
import {
DEFAULT_ACCENT_COLOR,
DEFAULT_PRIMARY_COLOR,
} from "../resources/ha-style";
import { HomeAssistant } from "../types";
export const defaultRadiusColor = DEFAULT_ACCENT_COLOR;
export const homeRadiusColor = DEFAULT_PRIMARY_COLOR;
export const passiveRadiusColor = "#9b9b9b";
export interface Zone {
id: string;
name: string;

View File

@ -28,6 +28,7 @@ class MoreInfoPerson extends LitElement {
<ha-map
.hass=${this.hass}
.entities=${this._entityArray(this.stateObj.entity_id)}
autoFit
></ha-map>
`
: ""}

View File

@ -5,9 +5,11 @@ import "@polymer/paper-radio-button/paper-radio-button";
import "@polymer/paper-radio-group/paper-radio-group";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/map/ha-location-editor";
import "../components/map/ha-locations-editor";
import type { MarkerLocation } from "../components/map/ha-locations-editor";
import { createTimezoneListEl } from "../components/timezone-datalist";
import {
ConfigUpdateValues,
@ -81,14 +83,14 @@ class OnboardingCoreConfig extends LitElement {
</div>
<div class="row">
<ha-location-editor
<ha-locations-editor
class="flex"
.hass=${this.hass}
.location=${this._locationValue}
.fitZoom=${14}
.locations=${this._markerLocation(this._locationValue)}
zoom="14"
.darkMode=${mql.matches}
@change=${this._locationChanged}
></ha-location-editor>
@location-updated=${this._locationChanged}
></ha-locations-editor>
</div>
<div class="row">
@ -208,13 +210,24 @@ class OnboardingCoreConfig extends LitElement {
return this._unitSystem !== undefined ? this._unitSystem : "metric";
}
private _markerLocation = memoizeOne(
(location: [number, number]): MarkerLocation[] => [
{
id: "location",
latitude: location[0],
longitude: location[1],
location_editable: true,
},
]
);
private _handleChange(ev: PolymerChangedEvent<string>) {
const target = ev.currentTarget as PaperInputElement;
this[`_${target.name}`] = target.value;
}
private _locationChanged(ev) {
this._location = ev.currentTarget.location;
this._location = ev.detail.location;
}
private _unitSystemChanged(

View File

@ -8,7 +8,8 @@ import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { UNIT_C } from "../../../common/const";
import "../../../components/ha-card";
import "../../../components/map/ha-location-editor";
import "../../../components/map/ha-locations-editor";
import type { MarkerLocation } from "../../../components/map/ha-locations-editor";
import { createTimezoneListEl } from "../../../components/timezone-datalist";
import { ConfigUpdateValues, saveCoreConfig } from "../../../data/core";
import type { PolymerChangedEvent } from "../../../polymer-types";
@ -20,13 +21,13 @@ class ConfigCoreForm extends LitElement {
@state() private _working = false;
@state() private _location!: [number, number];
@state() private _location?: [number, number];
@state() private _elevation!: string;
@state() private _elevation?: string;
@state() private _unitSystem!: ConfigUpdateValues["unit_system"];
@state() private _unitSystem?: ConfigUpdateValues["unit_system"];
@state() private _timeZone!: string;
@state() private _timeZone?: string;
protected render(): TemplateResult {
const canEdit = ["storage", "default"].includes(
@ -52,16 +53,16 @@ class ConfigCoreForm extends LitElement {
: ""}
<div class="row">
<ha-location-editor
<ha-locations-editor
class="flex"
.hass=${this.hass}
.location=${this._locationValue(
this._location,
.locations=${this._markerLocation(
this.hass.config.latitude,
this.hass.config.longitude
this.hass.config.longitude,
this._location
)}
@change=${this._locationChanged}
></ha-location-editor>
@location-updated=${this._locationChanged}
></ha-locations-editor>
</div>
<div class="row">
@ -162,8 +163,19 @@ class ConfigCoreForm extends LitElement {
input.inputElement.appendChild(createTimezoneListEl());
}
private _locationValue = memoizeOne(
(location, lat, lng) => location || [Number(lat), Number(lng)]
private _markerLocation = memoizeOne(
(
lat: number,
lng: number,
location?: [number, number]
): MarkerLocation[] => [
{
id: "location",
latitude: location ? location[0] : lat,
longitude: location ? location[1] : lng,
location_editable: true,
},
]
);
private get _elevationValue() {
@ -192,7 +204,7 @@ class ConfigCoreForm extends LitElement {
}
private _locationChanged(ev) {
this._location = ev.currentTarget.location;
this._location = ev.detail.location;
}
private _unitSystemChanged(
@ -204,11 +216,10 @@ class ConfigCoreForm extends LitElement {
private async _save() {
this._working = true;
try {
const location = this._locationValue(
this._location,
const location = this._location || [
this.hass.config.latitude,
this.hass.config.longitude
);
this.hass.config.longitude,
];
await saveCoreConfig(this.hass, {
latitude: location[0],
longitude: location[1],

View File

@ -9,13 +9,9 @@ import { computeRTLDirection } from "../../../common/util/compute_rtl";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-formfield";
import "../../../components/ha-switch";
import "../../../components/map/ha-location-editor";
import {
defaultRadiusColor,
getZoneEditorInitData,
passiveRadiusColor,
ZoneMutableParams,
} from "../../../data/zone";
import "../../../components/map/ha-locations-editor";
import type { MarkerLocation } from "../../../components/map/ha-locations-editor";
import { getZoneEditorInitData, ZoneMutableParams } from "../../../data/zone";
import { haStyleDialog } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
import { ZoneDetailDialogParams } from "./show-dialog-zone-detail";
@ -132,17 +128,19 @@ class DialogZoneDetail extends LitElement {
)}"
.invalid=${iconValid}
></paper-input>
<ha-location-editor
<ha-locations-editor
class="flex"
.hass=${this.hass}
.location=${this._locationValue(this._latitude, this._longitude)}
.radius=${this._radius}
.radiusColor=${this._passive
? passiveRadiusColor
: defaultRadiusColor}
.icon=${this._icon}
@change=${this._locationChanged}
></ha-location-editor>
.locations=${this._location(
this._latitude,
this._longitude,
this._radius,
this._passive,
this._icon
)}
@location-updated=${this._locationChanged}
@radius-updated=${this._radiusChanged}
></ha-locations-editor>
<div class="location">
<paper-input
.value=${this._latitude}
@ -222,11 +220,40 @@ class DialogZoneDetail extends LitElement {
`;
}
private _locationValue = memoizeOne((lat, lng) => [Number(lat), Number(lng)]);
private _location = memoizeOne(
(
lat: number,
lng: number,
radius: number,
passive: boolean,
icon: string
): MarkerLocation[] => {
const computedStyles = getComputedStyle(this);
const zoneRadiusColor = computedStyles.getPropertyValue("--accent-color");
const passiveRadiusColor = computedStyles.getPropertyValue(
"--secondary-text-color"
);
return [
{
id: "location",
latitude: Number(lat),
longitude: Number(lng),
radius,
radius_color: passive ? passiveRadiusColor : zoneRadiusColor,
icon,
location_editable: true,
radius_editable: true,
},
];
}
);
private _locationChanged(ev) {
[this._latitude, this._longitude] = ev.currentTarget.location;
this._radius = ev.currentTarget.radius;
private _locationChanged(ev: CustomEvent) {
[this._latitude, this._longitude] = ev.detail.location;
}
private _radiusChanged(ev: CustomEvent) {
this._radius = ev.detail.radius;
}
private _passiveChanged(ev) {
@ -292,7 +319,7 @@ class DialogZoneDetail extends LitElement {
.location > *:last-child {
margin-left: 4px;
}
ha-location-editor {
ha-locations-editor {
margin-top: 16px;
}
a {

View File

@ -31,11 +31,8 @@ import { saveCoreConfig } from "../../../data/core";
import { subscribeEntityRegistry } from "../../../data/entity_registry";
import {
createZone,
defaultRadiusColor,
deleteZone,
fetchZones,
homeRadiusColor,
passiveRadiusColor,
updateZone,
Zone,
ZoneMutableParams,
@ -73,6 +70,15 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
private _getZones = memoizeOne(
(storageItems: Zone[], stateItems: HassEntity[]): MarkerLocation[] => {
const computedStyles = getComputedStyle(this);
const zoneRadiusColor = computedStyles.getPropertyValue("--accent-color");
const passiveRadiusColor = computedStyles.getPropertyValue(
"--secondary-text-color"
);
const homeRadiusColor = computedStyles.getPropertyValue(
"--primary-color"
);
const stateLocations: MarkerLocation[] = stateItems.map(
(entityState) => ({
id: entityState.entity_id,
@ -86,7 +92,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
? homeRadiusColor
: entityState.attributes.passive
? passiveRadiusColor
: defaultRadiusColor,
: zoneRadiusColor,
location_editable:
entityState.entity_id === "zone.home" && this._canEditCore,
radius_editable: false,
@ -94,7 +100,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
);
const storageLocations: MarkerLocation[] = storageItems.map((zone) => ({
...zone,
radius_color: zone.passive ? passiveRadiusColor : defaultRadiusColor,
radius_color: zone.passive ? passiveRadiusColor : zoneRadiusColor,
location_editable: true,
radius_editable: true,
}));
@ -274,7 +280,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
}
protected updated(changedProps: PropertyValues) {
public willUpdate(changedProps: PropertyValues) {
super.updated(changedProps);
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (oldHass && this._stateItems) {
@ -410,8 +416,9 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
if (this.narrow) {
return;
}
await this.updateComplete;
this._activeEntry = created.id;
await this.updateComplete;
await this._map?.updateComplete;
this._map?.fitMarker(created.id);
}
@ -427,8 +434,9 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
if (this.narrow || !fitMap) {
return;
}
await this.updateComplete;
this._activeEntry = entry.id;
await this.updateComplete;
await this._map?.updateComplete;
this._map?.fitMarker(entry.id);
}

View File

@ -1,4 +1,5 @@
import { mdiRefresh } from "@mdi/js";
import "@material/mwc-icon-button";
import "@polymer/app-layout/app-header/app-header";
import "@polymer/app-layout/app-toolbar/app-toolbar";
import { css, html, LitElement, PropertyValues } from "lit";
@ -9,7 +10,6 @@ import "../../components/entity/ha-entity-picker";
import "../../components/ha-circular-progress";
import "../../components/ha-date-range-picker";
import type { DateRangePickerRanges } from "../../components/ha-date-range-picker";
import "../../components/ha-icon-button";
import "../../components/ha-menu-button";
import {
clearLogbookCache,

View File

@ -1,14 +1,5 @@
import { HassEntity } from "home-assistant-js-websocket";
import {
Circle,
CircleMarker,
LatLngTuple,
Layer,
Map,
Marker,
Polyline,
TileLayer,
} from "leaflet";
import { HassEntities, HassEntity } from "home-assistant-js-websocket";
import { LatLngTuple } from "leaflet";
import {
css,
CSSResultGroup,
@ -17,32 +8,106 @@ import {
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import {
LeafletModuleType,
replaceTileLayer,
setupLeafletMap,
} from "../../../common/dom/setup-leaflet-map";
import { customElement, property, query, state } from "lit/decorators";
import { computeDomain } from "../../../common/entity/compute_domain";
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 "../../../components/ha-card";
import "../../../components/ha-icon-button";
import { fetchRecent } from "../../../data/history";
import { HomeAssistant } from "../../../types";
import "../../map/ha-entity-marker";
import "../../../components/map/ha-entity-marker";
import { findEntities } from "../common/find-entities";
import { installResizeObserver } from "../common/install-resize-observer";
import { processConfigEntities } from "../common/process-config-entities";
import { EntityConfig } from "../entity-rows/types";
import { LovelaceCard } from "../types";
import { MapCardConfig } from "./types";
import "../../../components/map/ha-map";
import { mdiImageFilterCenterFocus } from "@mdi/js";
import type { HaMap, HaMapPaths } from "../../../components/map/ha-map";
import memoizeOne from "memoize-one";
const MINUTE = 60000;
const COLORS = [
"#0288D1",
"#00AA00",
"#984ea3",
"#00d2d5",
"#ff7f00",
"#af8d00",
"#7f80cd",
"#b3e900",
"#c42e60",
"#a65628",
"#f781bf",
"#8dd3c7",
];
@customElement("hui-map-card")
class HuiMapCard extends LitElement implements LovelaceCard {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true })
public isPanel = false;
@state()
private _history?: HassEntity[][];
@state()
private _config?: MapCardConfig;
@query("ha-map")
private _map?: HaMap;
private _date?: Date;
private _configEntities?: string[];
private _colorDict: Record<string, string> = {};
private _colorIndex = 0;
public setConfig(config: MapCardConfig): void {
if (!config) {
throw new Error("Error in card configuration.");
}
if (!config.entities?.length && !config.geo_location_sources) {
throw new Error(
"Either entities or geo_location_sources must be specified"
);
}
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<EntityConfig>(config.entities)
: []
).map((entity) => entity.entity);
this._cleanupHistory();
}
public getCardSize(): number {
if (!this._config?.aspect_ratio) {
return 7;
}
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 static async getConfigElement() {
await import("../editor/config-elements/hui-map-card-editor");
return document.createElement("hui-map-card-editor");
@ -66,129 +131,6 @@ class HuiMapCard extends LitElement implements LovelaceCard {
return { type: "map", entities: foundEntities };
}
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true })
public isPanel = false;
@property()
private _history?: HassEntity[][];
private _date?: Date;
@property()
private _config?: MapCardConfig;
private _configEntities?: EntityConfig[];
// eslint-disable-next-line
private Leaflet?: LeafletModuleType;
private _leafletMap?: Map;
private _tileLayer?: TileLayer;
private _resizeObserver?: ResizeObserver;
private _debouncedResizeListener = debounce(
() => {
if (!this.isConnected || !this._leafletMap) {
return;
}
this._leafletMap.invalidateSize();
},
250,
false
);
private _mapItems: Array<Marker | Circle> = [];
private _mapZones: Array<Marker | Circle> = [];
private _mapPaths: Array<Polyline | CircleMarker> = [];
private _colorDict: Record<string, string> = {};
private _colorIndex = 0;
private _colors: string[] = [
"#0288D1",
"#00AA00",
"#984ea3",
"#00d2d5",
"#ff7f00",
"#af8d00",
"#7f80cd",
"#b3e900",
"#c42e60",
"#a65628",
"#f781bf",
"#8dd3c7",
];
public setConfig(config: MapCardConfig): void {
if (!config) {
throw new Error("Error in card configuration.");
}
if (!config.entities?.length && !config.geo_location_sources) {
throw new Error(
"Either entities or geo_location_sources must be specified"
);
}
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)
: [];
this._cleanupHistory();
}
public getCardSize(): number {
if (!this._config?.aspect_ratio) {
return 7;
}
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._attachObserver();
if (this.hasUpdated) {
this.loadMap();
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
if (this._leafletMap) {
this._leafletMap.remove();
this._leafletMap = undefined;
this.Leaflet = undefined;
}
if (this._resizeObserver) {
this._resizeObserver.unobserve(this._mapEl);
}
}
protected render(): TemplateResult {
if (!this._config) {
return html``;
@ -196,22 +138,29 @@ class HuiMapCard extends LitElement implements LovelaceCard {
return html`
<ha-card id="card" .header=${this._config.title}>
<div id="root">
<div
id="map"
class=${classMap({ dark: this._config.dark_mode === true })}
></div>
<ha-icon-button
<ha-map
.hass=${this.hass}
.entities=${this._getEntities(
this.hass.states,
this._config,
this._configEntities
)}
.paths=${this._getHistoryPaths(this._config, this._history)}
.darkMode=${this._config.dark_mode}
></ha-map>
<mwc-icon-button
@click=${this._fitMap}
tabindex="0"
icon="hass:image-filter-center-focus"
title="Reset focus"
></ha-icon-button>
>
<ha-svg-icon .path=${mdiImageFilterCenterFocus}></ha-svg-icon>
</mwc-icon-button>
</div>
</ha-card>
`;
}
protected shouldUpdate(changedProps) {
protected shouldUpdate(changedProps: PropertyValues) {
if (!changedProps.has("hass") || changedProps.size > 1) {
return true;
}
@ -228,7 +177,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
// Check if any state has changed
for (const entity of this._configEntities) {
if (oldHass.states[entity.entity] !== this.hass!.states[entity.entity]) {
if (oldHass.states[entity] !== this.hass!.states[entity]) {
return true;
}
}
@ -238,17 +187,12 @@ class HuiMapCard extends LitElement implements LovelaceCard {
protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps);
if (this.isConnected) {
this.loadMap();
}
const root = this.shadowRoot!.getElementById("root");
if (!this._config || this.isPanel || !root) {
return;
}
this._attachObserver();
if (!this._config.aspect_ratio) {
root.style.paddingBottom = "100%";
return;
@ -263,172 +207,86 @@ class HuiMapCard extends LitElement implements LovelaceCard {
}
protected updated(changedProps: PropertyValues): void {
if (changedProps.has("hass") || changedProps.has("_history")) {
this._drawEntities();
this._fitMap();
}
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (oldHass && oldHass.themes.darkMode !== this.hass.themes.darkMode) {
this._replaceTileLayer();
}
}
if (
changedProps.has("_config") &&
changedProps.get("_config") !== undefined
) {
this.updateMap(changedProps.get("_config") as MapCardConfig);
}
if (this._config?.hours_to_show && this._configEntities?.length) {
const minute = 60000;
if (changedProps.has("_config")) {
this._getHistory();
} else if (Date.now() - this._date!.getTime() >= minute) {
} else if (Date.now() - this._date!.getTime() >= MINUTE) {
this._getHistory();
}
}
}
private get _mapEl(): HTMLDivElement {
return this.shadowRoot!.getElementById("map") as HTMLDivElement;
private _fitMap() {
this._map?.fitMap();
}
private async loadMap(): Promise<void> {
[this._leafletMap, this.Leaflet, this._tileLayer] = await setupLeafletMap(
this._mapEl,
this._config!.dark_mode ?? this.hass.themes.darkMode
);
this._drawEntities();
this._leafletMap.invalidateSize();
this._fitMap();
}
private _replaceTileLayer() {
const map = this._leafletMap;
const config = this._config;
const Leaflet = this.Leaflet;
if (!map || !config || !Leaflet || !this._tileLayer) {
return;
}
this._tileLayer = replaceTileLayer(
Leaflet,
map,
this._tileLayer,
this._config!.dark_mode ?? this.hass.themes.darkMode
);
}
private updateMap(oldConfig: MapCardConfig): void {
const map = this._leafletMap;
const config = this._config;
const Leaflet = this.Leaflet;
if (!map || !config || !Leaflet || !this._tileLayer) {
return;
}
if (this._config!.dark_mode !== oldConfig.dark_mode) {
this._replaceTileLayer();
}
if (
config.entities !== oldConfig.entities ||
config.geo_location_sources !== oldConfig.geo_location_sources
) {
this._drawEntities();
}
map.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.featureGroup(this._mapItems).getBounds();
this._leafletMap.fitBounds(bounds.pad(0.5));
if (zoom && this._leafletMap.getZoom() > zoom) {
this._leafletMap.setZoom(zoom);
}
}
private _getColor(entityId: string) {
let color;
if (this._colorDict[entityId]) {
color = this._colorDict[entityId];
} else {
color = this._colors[this._colorIndex];
this._colorIndex = (this._colorIndex + 1) % this._colors.length;
this._colorDict[entityId] = color;
private _getColor(entityId: string): string {
let color = this._colorDict[entityId];
if (color) {
return color;
}
color = COLORS[this._colorIndex % COLORS.length];
this._colorIndex++;
this._colorDict[entityId] = color;
return color;
}
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 = []);
if (this._mapZones) {
this._mapZones.forEach((marker) => marker.remove());
}
const mapZones: Layer[] = (this._mapZones = []);
if (this._mapPaths) {
this._mapPaths.forEach((marker) => marker.remove());
}
const mapPaths: Layer[] = (this._mapPaths = []);
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 });
}
private _getEntities = memoizeOne(
(
states: HassEntities,
config: MapCardConfig,
configEntities?: string[]
) => {
if (!states || !config) {
return undefined;
}
}
// DRAW history
if (this._config!.hours_to_show && this._history) {
for (const entityStates of this._history) {
let entities = configEntities || [];
if (config.geo_location_sources) {
const geoEntities: string[] = [];
// Calculate visible geo location sources
const includesAll = config.geo_location_sources.includes("all");
for (const stateObj of Object.values(states)) {
if (
computeDomain(stateObj.entity_id) === "geo_location" &&
(includesAll ||
config.geo_location_sources.includes(stateObj.attributes.source))
) {
geoEntities.push(stateObj.entity_id);
}
}
entities = [...entities, ...geoEntities];
}
return entities.map((entity) => ({
entity_id: entity,
color: this._getColor(entity),
}));
}
);
private _getHistoryPaths = memoizeOne(
(
config: MapCardConfig,
history?: HassEntity[][]
): HaMapPaths[] | undefined => {
if (!config.hours_to_show || !history) {
return undefined;
}
const paths: HaMapPaths[] = [];
for (const entityStates of history) {
if (entityStates?.length <= 1) {
continue;
}
const entityId = entityStates[0].entity_id;
// filter location data from states and remove all invalid locations
const path = entityStates.reduce(
(accumulator: LatLngTuple[], state) => {
const latitude = state.attributes.latitude;
const longitude = state.attributes.longitude;
const points = entityStates.reduce(
(accumulator: LatLngTuple[], entityState) => {
const latitude = entityState.attributes.latitude;
const longitude = entityState.attributes.longitude;
if (latitude && longitude) {
accumulator.push([latitude, longitude] as LatLngTuple);
}
@ -437,162 +295,15 @@ class HuiMapCard extends LitElement implements LovelaceCard {
[]
) as LatLngTuple[];
// DRAW HISTORY
for (
let markerIndex = 0;
markerIndex < path.length - 1;
markerIndex++
) {
const opacityStep = 0.8 / (path.length - 2);
const opacity = 0.2 + markerIndex * opacityStep;
// DRAW history path dots
mapPaths.push(
Leaflet.circleMarker(path[markerIndex], {
radius: 3,
color: this._getColor(entityId),
opacity,
interactive: false,
})
);
// DRAW history path lines
const line = [path[markerIndex], path[markerIndex + 1]];
mapPaths.push(
Leaflet.polyline(line, {
color: this._getColor(entityId),
opacity,
interactive: false,
})
);
}
paths.push({
points,
color: this._getColor(entityStates[0].entity_id),
gradualOpacity: 0.8,
});
}
return paths;
}
// DRAW entities
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 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._config!.dark_mode
? "dark"
: this._config!.dark_mode === false
? "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: `
<ha-entity-marker
entity-id="${entityId}"
entity-name="${entityName}"
entity-picture="${entityPicture || ""}"
entity-color="${this._getColor(entityId)}"
></ha-entity-marker>
`,
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: this._getColor(entityId),
radius: gpsAccuracy,
})
);
}
}
this._mapItems.forEach((marker) => map.addLayer(marker));
this._mapZones.forEach((marker) => map.addLayer(marker));
this._mapPaths.forEach((marker) => map.addLayer(marker));
}
private async _attachObserver(): Promise<void> {
// Observe changes to map size and invalidate to prevent broken rendering
if (!this._resizeObserver) {
await installResizeObserver();
this._resizeObserver = new ResizeObserver(this._debouncedResizeListener);
}
this._resizeObserver.observe(this);
}
);
private async _getHistory(): Promise<void> {
this._date = new Date();
@ -601,9 +312,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
return;
}
const entityIds = this._configEntities!.map((entity) => entity.entity).join(
","
);
const entityIds = this._configEntities!.join(",");
const endTime = new Date();
const startTime = new Date();
startTime.setHours(endTime.getHours() - this._config!.hours_to_show!);
@ -624,7 +333,6 @@ class HuiMapCard extends LitElement implements LovelaceCard {
if (stateHistory.length < 1) {
return;
}
this._history = stateHistory;
}
@ -636,13 +344,10 @@ class HuiMapCard extends LitElement implements LovelaceCard {
this._history = undefined;
} else {
// remove unused entities
const configEntityIds = this._configEntities?.map(
(configEntity) => configEntity.entity
);
this._history = this._history!.reduce(
(accumulator: HassEntity[][], entityStates) => {
const entityId = entityStates[0].entity_id;
if (configEntityIds?.includes(entityId)) {
if (this._configEntities?.includes(entityId)) {
accumulator.push(entityStates);
}
return accumulator;
@ -660,7 +365,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
height: 100%;
}
#map {
ha-map {
z-index: 0;
border: none;
position: absolute;
@ -671,7 +376,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
background: inherit;
}
ha-icon-button {
mwc-icon-button {
position: absolute;
top: 75px;
left: 3px;
@ -685,14 +390,6 @@ class HuiMapCard extends LitElement implements LovelaceCard {
:host([ispanel]) #root {
height: 100%;
}
.dark {
color: #ffffff;
}
.light {
color: #000000;
}
`;
}
}

View File

@ -29,6 +29,7 @@ export class HuiInputListEditor extends LitElement {
.index=${index}
@value-changed=${this._valueChanged}
@blur=${this._consolidateEntries}
@keydown=${this._handleKeyDown}
><ha-icon-button
slot="suffix"
class="clear-button"
@ -70,6 +71,13 @@ export class HuiInputListEditor extends LitElement {
});
}
private _handleKeyDown(ev: KeyboardEvent) {
if (ev.key === "Enter") {
ev.stopPropagation();
this._consolidateEntries(ev);
}
}
private _consolidateEntries(ev: Event): void {
const target = ev.target! as EditorTarget;
if (target.value === "") {

View File

@ -34,7 +34,7 @@ const cardConfigStruct = object({
dark_mode: optional(boolean()),
entities: array(entitiesConfigStruct),
hours_to_show: optional(number()),
geo_location_sources: optional(array()),
geo_location_sources: optional(array(string())),
});
@customElement("hui-map-card-editor")

View File

@ -1,88 +0,0 @@
import "@polymer/iron-image/iron-image";
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import { EventsMixin } from "../../mixins/events-mixin";
/*
* @appliesMixin EventsMixin
*/
class HaEntityMarker extends EventsMixin(PolymerElement) {
static get template() {
return html`
<style include="iron-positioning"></style>
<style>
.marker {
position: relative;
display: block;
margin: 0 auto;
width: 2.5em;
text-align: center;
height: 2.5em;
line-height: 2.5em;
font-size: 1.5em;
border-radius: 50%;
border: 0.1em solid var(--ha-marker-color, var(--primary-color));
color: var(--primary-text-color);
background-color: var(--card-background-color);
}
iron-image {
border-radius: 50%;
}
</style>
<div class="marker" style$="border-color:{{entityColor}}">
<template is="dom-if" if="[[entityName]]">[[entityName]]</template>
<template is="dom-if" if="[[entityPicture]]">
<iron-image
sizing="cover"
class="fit"
src="[[entityPicture]]"
></iron-image>
</template>
</div>
`;
}
static get properties() {
return {
hass: {
type: Object,
},
entityId: {
type: String,
value: "",
},
entityName: {
type: String,
value: null,
},
entityPicture: {
type: String,
value: null,
},
entityColor: {
type: String,
value: null,
},
};
}
ready() {
super.ready();
this.addEventListener("click", (ev) => this.badgeTap(ev));
}
badgeTap(ev) {
ev.stopPropagation();
if (this.entityId) {
this.fire("hass-more-info", { entityId: this.entityId });
}
}
}
customElements.define("ha-entity-marker", HaEntityMarker);

View File

@ -1,263 +0,0 @@
import "@polymer/app-layout/app-toolbar/app-toolbar";
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import {
replaceTileLayer,
setupLeafletMap,
} from "../../common/dom/setup-leaflet-map";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { navigate } from "../../common/navigate";
import "../../components/ha-icon";
import "../../components/ha-menu-button";
import { defaultRadiusColor } from "../../data/zone";
import "../../layouts/ha-app-layout";
import LocalizeMixin from "../../mixins/localize-mixin";
import "../../styles/polymer-ha-style";
import "./ha-entity-marker";
/*
* @appliesMixin LocalizeMixin
*/
class HaPanelMap extends LocalizeMixin(PolymerElement) {
static get template() {
return html`
<style include="ha-style">
#map {
height: calc(100vh - var(--header-height));
width: 100%;
z-index: 0;
background: inherit;
}
.icon {
color: var(--primary-text-color);
}
</style>
<ha-app-layout>
<app-header fixed slot="header">
<app-toolbar>
<ha-menu-button
hass="[[hass]]"
narrow="[[narrow]]"
></ha-menu-button>
<div main-title>[[localize('panel.map')]]</div>
<template is="dom-if" if="[[computeShowEditZone(hass)]]">
<ha-icon-button
icon="hass:pencil"
on-click="openZonesEditor"
></ha-icon-button>
</template>
</app-toolbar>
</app-header>
<div id="map"></div>
</ha-app-layout>
`;
}
static get properties() {
return {
hass: {
type: Object,
observer: "drawEntities",
},
narrow: Boolean,
};
}
connectedCallback() {
super.connectedCallback();
this.loadMap();
}
async loadMap() {
this._darkMode = this.hass.themes.darkMode;
[this._map, this.Leaflet, this._tileLayer] = await setupLeafletMap(
this.$.map,
this._darkMode
);
this.drawEntities(this.hass);
this._map.invalidateSize();
this.fitMap();
}
disconnectedCallback() {
if (this._map) {
this._map.remove();
}
}
computeShowEditZone(hass) {
return !__DEMO__ && hass.user.is_admin;
}
openZonesEditor() {
navigate("/config/zone");
}
fitMap() {
let bounds;
if (this._mapItems.length === 0) {
this._map.setView(
new this.Leaflet.LatLng(
this.hass.config.latitude,
this.hass.config.longitude
),
14
);
} else {
bounds = new this.Leaflet.latLngBounds(
this._mapItems.map((item) => item.getLatLng())
);
this._map.fitBounds(bounds.pad(0.5));
}
}
drawEntities(hass) {
/* eslint-disable vars-on-top */
const map = this._map;
if (!map) return;
if (this._darkMode !== this.hass.themes.darkMode) {
this._darkMode = this.hass.themes.darkMode;
this._tileLayer = replaceTileLayer(
this.Leaflet,
map,
this._tileLayer,
this.hass.themes.darkMode
);
}
if (this._mapItems) {
this._mapItems.forEach(function (marker) {
marker.remove();
});
}
const mapItems = (this._mapItems = []);
if (this._mapZones) {
this._mapZones.forEach(function (marker) {
marker.remove();
});
}
const mapZones = (this._mapZones = []);
Object.keys(hass.states).forEach((entityId) => {
const entity = hass.states[entityId];
if (
entity.state === "home" ||
!("latitude" in entity.attributes) ||
!("longitude" in entity.attributes)
) {
return;
}
const title = computeStateName(entity);
let icon;
if (computeStateDomain(entity) === "zone") {
// DRAW ZONE
if (entity.attributes.passive) return;
// create icon
let iconHTML = "";
if (entity.attributes.icon) {
const el = document.createElement("ha-icon");
el.setAttribute("icon", entity.attributes.icon);
iconHTML = el.outerHTML;
} else {
const el = document.createElement("span");
el.innerHTML = title;
iconHTML = el.outerHTML;
}
icon = this.Leaflet.divIcon({
html: iconHTML,
iconSize: [24, 24],
className: "icon",
});
// create marker with the icon
mapZones.push(
this.Leaflet.marker(
[entity.attributes.latitude, entity.attributes.longitude],
{
icon: icon,
interactive: false,
title: title,
}
).addTo(map)
);
// create circle around it
mapZones.push(
this.Leaflet.circle(
[entity.attributes.latitude, entity.attributes.longitude],
{
interactive: false,
color: defaultRadiusColor,
radius: entity.attributes.radius,
}
).addTo(map)
);
return;
}
// DRAW ENTITY
// create icon
const entityPicture = entity.attributes.entity_picture || "";
const entityName = title
.split(" ")
.map(function (part) {
return part.substr(0, 1);
})
.join("");
/* 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. */
icon = this.Leaflet.divIcon({
html:
"<ha-entity-marker entity-id='" +
entity.entity_id +
"' entity-name='" +
entityName +
"' entity-picture='" +
entityPicture +
"'></ha-entity-marker>",
iconSize: [45, 45],
className: "",
});
// create market with the icon
mapItems.push(
this.Leaflet.marker(
[entity.attributes.latitude, entity.attributes.longitude],
{
icon: icon,
title: computeStateName(entity),
}
).addTo(map)
);
// create circle around if entity has accuracy
if (entity.attributes.gps_accuracy) {
mapItems.push(
this.Leaflet.circle(
[entity.attributes.latitude, entity.attributes.longitude],
{
interactive: false,
color: "#0288D1",
radius: entity.attributes.gps_accuracy,
}
).addTo(map)
);
}
});
}
}
customElements.define("ha-panel-map", HaPanelMap);

View File

@ -0,0 +1,103 @@
import { mdiPencil } from "@mdi/js";
import "@material/mwc-icon-button";
import "@polymer/app-layout/app-toolbar/app-toolbar";
import { css, CSSResultGroup, html, LitElement, PropertyValues } from "lit";
import { property } from "lit/decorators";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { navigate } from "../../common/navigate";
import "../../components/ha-svg-icon";
import "../../components/ha-menu-button";
import "../../layouts/ha-app-layout";
import { HomeAssistant } from "../../types";
import "../../components/map/ha-map";
import { haStyle } from "../../resources/styles";
class HaPanelMap extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow!: boolean;
private _entities: string[] = [];
protected render() {
return html`
<ha-app-layout>
<app-header fixed slot="header">
<app-toolbar>
<ha-menu-button
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
<div main-title>${this.hass.localize("panel.map")}</div>
${!__DEMO__ && this.hass.user?.is_admin
? html`<mwc-icon-button @click=${this._openZonesEditor}
><ha-svg-icon .path=${mdiPencil}></ha-svg-icon
></mwc-icon-button>`
: ""}
</app-toolbar>
</app-header>
<ha-map .hass=${this.hass} .entities=${this._entities} autoFit></ha-map>
</ha-app-layout>
`;
}
private _openZonesEditor() {
navigate("/config/zone");
}
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
this._getStates(oldHass);
}
private _getStates(oldHass?: HomeAssistant) {
let changed = false;
const personSources = new Set<string>();
const locationEntities: string[] = [];
Object.values(this.hass!.states).forEach((entity) => {
if (
entity.state === "home" ||
!("latitude" in entity.attributes) ||
!("longitude" in entity.attributes)
) {
return;
}
locationEntities.push(entity.entity_id);
if (computeStateDomain(entity) === "person" && entity.attributes.source) {
personSources.add(entity.attributes.source);
}
if (oldHass?.states[entity.entity_id] !== entity) {
changed = true;
}
});
if (changed) {
this._entities = locationEntities.filter(
(entity) => !personSources.has(entity)
);
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
ha-map {
height: calc(100vh - var(--header-height));
}
`,
];
}
}
customElements.define("ha-panel-map", HaPanelMap);
declare global {
interface HTMLElementTagNameMap {
"ha-panel-map": HaPanelMap;
}
}

View File

@ -1,5 +1,4 @@
import "@polymer/iron-flex-layout/iron-flex-layout-classes";
import "@polymer/iron-label/iron-label";
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";

View File

@ -29,10 +29,10 @@ documentContainer.innerHTML = `<custom-style>
--disabled-text-color: #bdbdbd;
/* main interface colors */
--primary-color: #03a9f4;
--primary-color: ${DEFAULT_PRIMARY_COLOR};
--dark-primary-color: #0288d1;
--light-primary-color: #b3e5fC;
--accent-color: #ff9800;
--accent-color: ${DEFAULT_ACCENT_COLOR};
--divider-color: rgba(0, 0, 0, .12);
--scrollbar-thumb-color: rgb(194, 194, 194);

View File

@ -2104,13 +2104,6 @@
"@polymer/iron-meta" "^3.0.0-pre.26"
"@polymer/polymer" "^3.0.0"
"@polymer/iron-image@^3.0.1":
version "3.0.2"
resolved "https://registry.yarnpkg.com/@polymer/iron-image/-/iron-image-3.0.2.tgz#425ee6269634e024dbea726a91a61724ae4402b6"
integrity sha512-VyYtnewGozDb5sUeoLR1OvKzlt5WAL6b8Od7fPpio5oYL+9t061/nTV8+ZMrpMgF2WgB0zqM/3K53o3pbK5v8Q==
dependencies:
"@polymer/polymer" "^3.0.0"
"@polymer/iron-input@^3.0.0-pre.26", "@polymer/iron-input@^3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@polymer/iron-input/-/iron-input-3.0.1.tgz#dc866a25107f9b38d9ca4512dd9a3e51b78b4915"
@ -2120,13 +2113,6 @@
"@polymer/iron-validatable-behavior" "^3.0.0-pre.26"
"@polymer/polymer" "^3.0.0"
"@polymer/iron-label@^3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@polymer/iron-label/-/iron-label-3.0.1.tgz#170247dc50d63f4e2ae6c80711dbf5b64fa953d6"
integrity sha512-MkIZ1WfOy10pnIxRwTVPfsoDZYlqMkUp0hmimMj0pGRHmrc9n5phuJUY1pC+S7WoKP1/98iH2qnXQukPGTzoVA==
dependencies:
"@polymer/polymer" "^3.0.0"
"@polymer/iron-list@^3.0.0":
version "3.0.2"
resolved "https://registry.yarnpkg.com/@polymer/iron-list/-/iron-list-3.0.2.tgz#9e6b80e503328dc29217dbe26f94faa47adb4124"