Add feature map history (#5331)

* add feature: show a geocode history on hui-map-card

* refactor feature to use hass cache via rest api and omit osm request

* prepare for PR

* squash duplicates of allEntities to omit duplicated layers on the map

* refactor to use device_tracker entity

* add asdf's .tool-versions file to gitignore

* add lokalize and cleanup

* ajust  logic to match backend api

* add changes to fit new backend behaviour

* fix error in history ts

* cleanup history for map card
This commit is contained in:
Hoytron 2020-03-31 23:25:44 +02:00 committed by GitHub
parent 6f556f69d6
commit 5f765e8b96
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 221 additions and 18 deletions

3
.gitignore vendored
View File

@ -31,3 +31,6 @@ src/cast/dev_const.ts
# Secrets
.lokalise_token
yarn-error.log
#asdf
.tool-versions

View File

@ -55,7 +55,8 @@ export const fetchRecent = (
entityId,
startTime,
endTime,
skipInitialState = false
skipInitialState = false,
significantChangesOnly?: boolean
): Promise<HassEntity[][]> => {
let url = "history/period";
if (startTime) {
@ -68,6 +69,9 @@ export const fetchRecent = (
if (skipInitialState) {
url += "&skip_initial_state";
}
if (significantChangesOnly !== undefined) {
url += `&significant_changes_only=${Number(significantChangesOnly)}`;
}
return hass.callApi("GET", url);
};

View File

@ -1,5 +1,13 @@
import "@polymer/paper-icon-button/paper-icon-button";
import { Layer, Marker, Circle, Map } from "leaflet";
import {
Layer,
Marker,
Circle,
Map,
CircleMarker,
Polyline,
LatLngTuple,
} from "leaflet";
import {
LitElement,
TemplateResult,
@ -10,7 +18,6 @@ import {
CSSResult,
customElement,
} from "lit-element";
import "../../map/ha-entity-marker";
import {
@ -32,6 +39,9 @@ import { MapCardConfig } from "./types";
import { classMap } from "lit-html/directives/class-map";
import { findEntities } from "../common/find-entites";
import { HassEntity } from "home-assistant-js-websocket";
import { fetchRecent } from "../../../data/history";
@customElement("hui-map-card")
class HuiMapCard extends LitElement implements LovelaceCard {
public static async getConfigElement() {
@ -66,6 +76,10 @@ class HuiMapCard extends LitElement implements LovelaceCard {
@property({ type: Boolean, reflect: true })
public editMode = false;
@property()
private _history?: HassEntity[][];
private _date?: Date;
@property()
private _config?: MapCardConfig;
private _configEntities?: EntityConfig[];
@ -86,7 +100,24 @@ class HuiMapCard extends LitElement implements LovelaceCard {
);
private _mapItems: Array<Marker | Circle> = [];
private _mapZones: Array<Marker | Circle> = [];
private _mapPaths: Array<Polyline | CircleMarker> = [];
private _connected = false;
private _colorDict: { [key: string]: string } = {};
private _colorIndex: number = 0;
private _colors: string[] = [
"#0288D1",
"#00AA00",
"#984ea3",
"#00d2d5",
"#ff7f00",
"#af8d00",
"#7f80cd",
"#b3e900",
"#c42e60",
"#a65628",
"#f781bf",
"#8dd3c7",
];
public setConfig(config: MapCardConfig): void {
if (!config) {
@ -112,6 +143,8 @@ class HuiMapCard extends LitElement implements LovelaceCard {
this._configEntities = config.entities
? processConfigEntities(config.entities)
: [];
this._cleanupHistory();
}
public getCardSize(): number {
@ -223,7 +256,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
}
protected updated(changedProps: PropertyValues): void {
if (changedProps.has("hass")) {
if (changedProps.has("hass") || changedProps.has("_history")) {
this._drawEntities();
this._fitMap();
}
@ -233,6 +266,15 @@ class HuiMapCard extends LitElement implements LovelaceCard {
) {
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) {
this._getHistory();
}
}
}
private get _mapEl(): HTMLDivElement {
@ -285,9 +327,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
return;
}
const bounds = this.Leaflet.latLngBounds(
this._mapItems ? this._mapItems.map((item) => item.getLatLng()) : []
);
const bounds = this.Leaflet.featureGroup(this._mapItems).getBounds();
this._leafletMap.fitBounds(bounds.pad(0.5));
if (zoom && this._leafletMap.getZoom() > zoom) {
@ -295,6 +335,18 @@ class HuiMapCard extends LitElement implements LovelaceCard {
}
}
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;
}
return color;
}
private _drawEntities(): void {
const hass = this.hass;
const map = this._leafletMap;
@ -314,6 +366,11 @@ class HuiMapCard extends LitElement implements LovelaceCard {
}
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
@ -331,6 +388,60 @@ class HuiMapCard extends LitElement implements LovelaceCard {
}
}
// DRAW history
if (this._config!.hours_to_show && this._history) {
for (const entityStates of this._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;
if (latitude && longitude) {
accumulator.push([latitude, longitude] as LatLngTuple);
}
return accumulator;
},
[]
) 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,
})
);
}
}
}
// DRAW entities
for (const entity of allEntities) {
const entityId = entity.entity;
const stateObj = hass.states[entityId];
@ -414,6 +525,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
entity-id="${entityId}"
entity-name="${entityName}"
entity-picture="${entityPicture || ""}"
entity-color="${this._getColor(entityId)}"
></ha-entity-marker>
`,
iconSize: [48, 48],
@ -428,7 +540,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
mapItems.push(
Leaflet.circle([latitude, longitude], {
interactive: false,
color: "#0288D1",
color: this._getColor(entityId),
radius: gpsAccuracy,
})
);
@ -437,6 +549,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
this._mapItems.forEach((marker) => map.addLayer(marker));
this._mapZones.forEach((marker) => map.addLayer(marker));
this._mapPaths.forEach((marker) => map.addLayer(marker));
}
private _attachObserver(): void {
@ -455,6 +568,62 @@ class HuiMapCard extends LitElement implements LovelaceCard {
}
}
private async _getHistory(): Promise<void> {
this._date = new Date();
if (!this._configEntities) {
return;
}
const entityIds = this._configEntities!.map((entity) => entity.entity).join(
","
);
const endTime = new Date();
const startTime = new Date();
startTime.setHours(endTime.getHours() - this._config!.hours_to_show!);
const skipInitialState = false;
const significantChangesOnly = false;
const stateHistory = await fetchRecent(
this.hass,
entityIds,
startTime,
endTime,
skipInitialState,
significantChangesOnly
);
if (stateHistory.length < 1) {
return;
}
this._history = stateHistory;
}
private _cleanupHistory() {
if (!this._history) {
return;
}
if (this._config!.hours_to_show! <= 0) {
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)) {
accumulator.push(entityStates);
}
return accumulator;
},
[]
) as HassEntity[][];
}
}
static get styles(): CSSResult {
return css`
:host([ispanel]) ha-card {

View File

@ -149,6 +149,7 @@ export interface MapCardConfig extends LovelaceCardConfig {
aspect_ratio?: string;
default_zoom?: number;
entities?: Array<EntityConfig | string>;
hours_to_show?: number;
geo_location_sources?: string[];
dark_mode?: boolean;
}

View File

@ -34,6 +34,7 @@ const cardConfigStruct = struct({
default_zoom: "number?",
dark_mode: "boolean?",
entities: [entitiesConfigStruct],
hours_to_show: "number?",
geo_location_sources: "array?",
});
@ -48,7 +49,9 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
public setConfig(config: MapCardConfig): void {
config = cardConfigStruct(config);
this._config = config;
this._configEntities = processEditorEntities(config.entities);
this._configEntities = config.entities
? processEditorEntities(config.entities)
: [];
}
get _title(): string {
@ -67,6 +70,10 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
return this._config!.geo_location_sources || [];
}
get _hours_to_show(): number {
return this._config!.hours_to_show || 0;
}
get _dark_mode(): boolean {
return this._config!.dark_mode || false;
}
@ -112,14 +119,27 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
@value-changed="${this._valueChanged}"
></paper-input>
</div>
<ha-switch
.checked="${this._dark_mode}"
.configValue="${"dark_mode"}"
@change="${this._valueChanged}"
>${this.hass.localize(
"ui.panel.lovelace.editor.card.map.dark_mode"
)}</ha-switch
>
<div class="side-by-side">
<ha-switch
.checked="${this._dark_mode}"
.configValue="${"dark_mode"}"
@change="${this._valueChanged}"
>${this.hass.localize(
"ui.panel.lovelace.editor.card.map.dark_mode"
)}</ha-switch
>
<paper-input
.label="${this.hass.localize(
"ui.panel.lovelace.editor.card.map.hours_to_show"
)} (${this.hass.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})"
type="number"
.value="${this._hours_to_show}"
.configValue="${"hours_to_show"}"
@value-changed="${this._valueChanged}"
></paper-input>
</div>
<hui-entity-editor
.hass=${this.hass}
.entities="${this._configEntities}"

View File

@ -33,7 +33,7 @@ class HaEntityMarker extends EventsMixin(PolymerElement) {
}
</style>
<div class="marker">
<div class="marker" style$="border-color:{{entityColor}}">
<template is="dom-if" if="[[entityName]]">[[entityName]]</template>
<template is="dom-if" if="[[entityPicture]]">
<iron-image
@ -66,6 +66,11 @@ class HaEntityMarker extends EventsMixin(PolymerElement) {
type: String,
value: null,
},
entityColor: {
type: String,
value: null,
},
};
}

View File

@ -2100,6 +2100,7 @@
"geo_location_sources": "Geolocation Sources",
"dark_mode": "Dark Mode?",
"default_zoom": "Default Zoom",
"hours_to_show": "Hours to Show",
"source": "Source",
"description": "The Map card that allows you to display entities on a map."
},