mirror of
https://github.com/home-assistant/frontend.git
synced 2025-08-01 13:37:47 +00:00
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:
parent
6f556f69d6
commit
5f765e8b96
3
.gitignore
vendored
3
.gitignore
vendored
@ -31,3 +31,6 @@ src/cast/dev_const.ts
|
||||
# Secrets
|
||||
.lokalise_token
|
||||
yarn-error.log
|
||||
|
||||
#asdf
|
||||
.tool-versions
|
||||
|
@ -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);
|
||||
};
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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}"
|
||||
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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."
|
||||
},
|
||||
|
Loading…
x
Reference in New Issue
Block a user