mirror of
https://github.com/home-assistant/frontend.git
synced 2025-08-02 14:07:55 +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
|
# Secrets
|
||||||
.lokalise_token
|
.lokalise_token
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
|
|
||||||
|
#asdf
|
||||||
|
.tool-versions
|
||||||
|
@ -55,7 +55,8 @@ export const fetchRecent = (
|
|||||||
entityId,
|
entityId,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
skipInitialState = false
|
skipInitialState = false,
|
||||||
|
significantChangesOnly?: boolean
|
||||||
): Promise<HassEntity[][]> => {
|
): Promise<HassEntity[][]> => {
|
||||||
let url = "history/period";
|
let url = "history/period";
|
||||||
if (startTime) {
|
if (startTime) {
|
||||||
@ -68,6 +69,9 @@ export const fetchRecent = (
|
|||||||
if (skipInitialState) {
|
if (skipInitialState) {
|
||||||
url += "&skip_initial_state";
|
url += "&skip_initial_state";
|
||||||
}
|
}
|
||||||
|
if (significantChangesOnly !== undefined) {
|
||||||
|
url += `&significant_changes_only=${Number(significantChangesOnly)}`;
|
||||||
|
}
|
||||||
|
|
||||||
return hass.callApi("GET", url);
|
return hass.callApi("GET", url);
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,13 @@
|
|||||||
import "@polymer/paper-icon-button/paper-icon-button";
|
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 {
|
import {
|
||||||
LitElement,
|
LitElement,
|
||||||
TemplateResult,
|
TemplateResult,
|
||||||
@ -10,7 +18,6 @@ import {
|
|||||||
CSSResult,
|
CSSResult,
|
||||||
customElement,
|
customElement,
|
||||||
} from "lit-element";
|
} from "lit-element";
|
||||||
|
|
||||||
import "../../map/ha-entity-marker";
|
import "../../map/ha-entity-marker";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -32,6 +39,9 @@ import { MapCardConfig } from "./types";
|
|||||||
import { classMap } from "lit-html/directives/class-map";
|
import { classMap } from "lit-html/directives/class-map";
|
||||||
import { findEntities } from "../common/find-entites";
|
import { findEntities } from "../common/find-entites";
|
||||||
|
|
||||||
|
import { HassEntity } from "home-assistant-js-websocket";
|
||||||
|
import { fetchRecent } from "../../../data/history";
|
||||||
|
|
||||||
@customElement("hui-map-card")
|
@customElement("hui-map-card")
|
||||||
class HuiMapCard extends LitElement implements LovelaceCard {
|
class HuiMapCard extends LitElement implements LovelaceCard {
|
||||||
public static async getConfigElement() {
|
public static async getConfigElement() {
|
||||||
@ -66,6 +76,10 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
|||||||
@property({ type: Boolean, reflect: true })
|
@property({ type: Boolean, reflect: true })
|
||||||
public editMode = false;
|
public editMode = false;
|
||||||
|
|
||||||
|
@property()
|
||||||
|
private _history?: HassEntity[][];
|
||||||
|
private _date?: Date;
|
||||||
|
|
||||||
@property()
|
@property()
|
||||||
private _config?: MapCardConfig;
|
private _config?: MapCardConfig;
|
||||||
private _configEntities?: EntityConfig[];
|
private _configEntities?: EntityConfig[];
|
||||||
@ -86,7 +100,24 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
|||||||
);
|
);
|
||||||
private _mapItems: Array<Marker | Circle> = [];
|
private _mapItems: Array<Marker | Circle> = [];
|
||||||
private _mapZones: Array<Marker | Circle> = [];
|
private _mapZones: Array<Marker | Circle> = [];
|
||||||
|
private _mapPaths: Array<Polyline | CircleMarker> = [];
|
||||||
private _connected = false;
|
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 {
|
public setConfig(config: MapCardConfig): void {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
@ -112,6 +143,8 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
|||||||
this._configEntities = config.entities
|
this._configEntities = config.entities
|
||||||
? processConfigEntities(config.entities)
|
? processConfigEntities(config.entities)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
this._cleanupHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCardSize(): number {
|
public getCardSize(): number {
|
||||||
@ -223,7 +256,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected updated(changedProps: PropertyValues): void {
|
protected updated(changedProps: PropertyValues): void {
|
||||||
if (changedProps.has("hass")) {
|
if (changedProps.has("hass") || changedProps.has("_history")) {
|
||||||
this._drawEntities();
|
this._drawEntities();
|
||||||
this._fitMap();
|
this._fitMap();
|
||||||
}
|
}
|
||||||
@ -233,6 +266,15 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
|||||||
) {
|
) {
|
||||||
this.updateMap(changedProps.get("_config") as MapCardConfig);
|
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 {
|
private get _mapEl(): HTMLDivElement {
|
||||||
@ -285,9 +327,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bounds = this.Leaflet.latLngBounds(
|
const bounds = this.Leaflet.featureGroup(this._mapItems).getBounds();
|
||||||
this._mapItems ? this._mapItems.map((item) => item.getLatLng()) : []
|
|
||||||
);
|
|
||||||
this._leafletMap.fitBounds(bounds.pad(0.5));
|
this._leafletMap.fitBounds(bounds.pad(0.5));
|
||||||
|
|
||||||
if (zoom && this._leafletMap.getZoom() > zoom) {
|
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 {
|
private _drawEntities(): void {
|
||||||
const hass = this.hass;
|
const hass = this.hass;
|
||||||
const map = this._leafletMap;
|
const map = this._leafletMap;
|
||||||
@ -314,6 +366,11 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
|||||||
}
|
}
|
||||||
const mapZones: Layer[] = (this._mapZones = []);
|
const mapZones: Layer[] = (this._mapZones = []);
|
||||||
|
|
||||||
|
if (this._mapPaths) {
|
||||||
|
this._mapPaths.forEach((marker) => marker.remove());
|
||||||
|
}
|
||||||
|
const mapPaths: Layer[] = (this._mapPaths = []);
|
||||||
|
|
||||||
const allEntities = this._configEntities!.concat();
|
const allEntities = this._configEntities!.concat();
|
||||||
|
|
||||||
// Calculate visible geo location sources
|
// 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) {
|
for (const entity of allEntities) {
|
||||||
const entityId = entity.entity;
|
const entityId = entity.entity;
|
||||||
const stateObj = hass.states[entityId];
|
const stateObj = hass.states[entityId];
|
||||||
@ -414,6 +525,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
|||||||
entity-id="${entityId}"
|
entity-id="${entityId}"
|
||||||
entity-name="${entityName}"
|
entity-name="${entityName}"
|
||||||
entity-picture="${entityPicture || ""}"
|
entity-picture="${entityPicture || ""}"
|
||||||
|
entity-color="${this._getColor(entityId)}"
|
||||||
></ha-entity-marker>
|
></ha-entity-marker>
|
||||||
`,
|
`,
|
||||||
iconSize: [48, 48],
|
iconSize: [48, 48],
|
||||||
@ -428,7 +540,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
|||||||
mapItems.push(
|
mapItems.push(
|
||||||
Leaflet.circle([latitude, longitude], {
|
Leaflet.circle([latitude, longitude], {
|
||||||
interactive: false,
|
interactive: false,
|
||||||
color: "#0288D1",
|
color: this._getColor(entityId),
|
||||||
radius: gpsAccuracy,
|
radius: gpsAccuracy,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -437,6 +549,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
|||||||
|
|
||||||
this._mapItems.forEach((marker) => map.addLayer(marker));
|
this._mapItems.forEach((marker) => map.addLayer(marker));
|
||||||
this._mapZones.forEach((marker) => map.addLayer(marker));
|
this._mapZones.forEach((marker) => map.addLayer(marker));
|
||||||
|
this._mapPaths.forEach((marker) => map.addLayer(marker));
|
||||||
}
|
}
|
||||||
|
|
||||||
private _attachObserver(): void {
|
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 {
|
static get styles(): CSSResult {
|
||||||
return css`
|
return css`
|
||||||
:host([ispanel]) ha-card {
|
:host([ispanel]) ha-card {
|
||||||
|
@ -149,6 +149,7 @@ export interface MapCardConfig extends LovelaceCardConfig {
|
|||||||
aspect_ratio?: string;
|
aspect_ratio?: string;
|
||||||
default_zoom?: number;
|
default_zoom?: number;
|
||||||
entities?: Array<EntityConfig | string>;
|
entities?: Array<EntityConfig | string>;
|
||||||
|
hours_to_show?: number;
|
||||||
geo_location_sources?: string[];
|
geo_location_sources?: string[];
|
||||||
dark_mode?: boolean;
|
dark_mode?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -34,6 +34,7 @@ const cardConfigStruct = struct({
|
|||||||
default_zoom: "number?",
|
default_zoom: "number?",
|
||||||
dark_mode: "boolean?",
|
dark_mode: "boolean?",
|
||||||
entities: [entitiesConfigStruct],
|
entities: [entitiesConfigStruct],
|
||||||
|
hours_to_show: "number?",
|
||||||
geo_location_sources: "array?",
|
geo_location_sources: "array?",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -48,7 +49,9 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
|
|||||||
public setConfig(config: MapCardConfig): void {
|
public setConfig(config: MapCardConfig): void {
|
||||||
config = cardConfigStruct(config);
|
config = cardConfigStruct(config);
|
||||||
this._config = config;
|
this._config = config;
|
||||||
this._configEntities = processEditorEntities(config.entities);
|
this._configEntities = config.entities
|
||||||
|
? processEditorEntities(config.entities)
|
||||||
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
get _title(): string {
|
get _title(): string {
|
||||||
@ -67,6 +70,10 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
|
|||||||
return this._config!.geo_location_sources || [];
|
return this._config!.geo_location_sources || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get _hours_to_show(): number {
|
||||||
|
return this._config!.hours_to_show || 0;
|
||||||
|
}
|
||||||
|
|
||||||
get _dark_mode(): boolean {
|
get _dark_mode(): boolean {
|
||||||
return this._config!.dark_mode || false;
|
return this._config!.dark_mode || false;
|
||||||
}
|
}
|
||||||
@ -112,14 +119,27 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
|
|||||||
@value-changed="${this._valueChanged}"
|
@value-changed="${this._valueChanged}"
|
||||||
></paper-input>
|
></paper-input>
|
||||||
</div>
|
</div>
|
||||||
<ha-switch
|
<div class="side-by-side">
|
||||||
.checked="${this._dark_mode}"
|
<ha-switch
|
||||||
.configValue="${"dark_mode"}"
|
.checked="${this._dark_mode}"
|
||||||
@change="${this._valueChanged}"
|
.configValue="${"dark_mode"}"
|
||||||
>${this.hass.localize(
|
@change="${this._valueChanged}"
|
||||||
"ui.panel.lovelace.editor.card.map.dark_mode"
|
>${this.hass.localize(
|
||||||
)}</ha-switch
|
"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
|
<hui-entity-editor
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.entities="${this._configEntities}"
|
.entities="${this._configEntities}"
|
||||||
|
@ -33,7 +33,7 @@ class HaEntityMarker extends EventsMixin(PolymerElement) {
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="marker">
|
<div class="marker" style$="border-color:{{entityColor}}">
|
||||||
<template is="dom-if" if="[[entityName]]">[[entityName]]</template>
|
<template is="dom-if" if="[[entityName]]">[[entityName]]</template>
|
||||||
<template is="dom-if" if="[[entityPicture]]">
|
<template is="dom-if" if="[[entityPicture]]">
|
||||||
<iron-image
|
<iron-image
|
||||||
@ -66,6 +66,11 @@ class HaEntityMarker extends EventsMixin(PolymerElement) {
|
|||||||
type: String,
|
type: String,
|
||||||
value: null,
|
value: null,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
entityColor: {
|
||||||
|
type: String,
|
||||||
|
value: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2100,6 +2100,7 @@
|
|||||||
"geo_location_sources": "Geolocation Sources",
|
"geo_location_sources": "Geolocation Sources",
|
||||||
"dark_mode": "Dark Mode?",
|
"dark_mode": "Dark Mode?",
|
||||||
"default_zoom": "Default Zoom",
|
"default_zoom": "Default Zoom",
|
||||||
|
"hours_to_show": "Hours to Show",
|
||||||
"source": "Source",
|
"source": "Source",
|
||||||
"description": "The Map card that allows you to display entities on a map."
|
"description": "The Map card that allows you to display entities on a map."
|
||||||
},
|
},
|
||||||
|
Loading…
x
Reference in New Issue
Block a user