mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-07 20:07:26 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e949e28c12 | ||
|
|
d7aa815631 | ||
|
|
fbc4106c48 | ||
|
|
3e001665d5 | ||
|
|
c0f15212c9 |
@@ -127,11 +127,12 @@ export class HaLocationSelector extends LitElement {
|
||||
? this.hass.config.longitude
|
||||
: value.longitude,
|
||||
radius: selector.location?.radius ? value?.radius || 1000 : undefined,
|
||||
radius_color: zoneRadiusColor,
|
||||
radius_color: selector.location?.color || zoneRadiusColor,
|
||||
icon:
|
||||
selector.location?.icon || selector.location?.radius
|
||||
selector.location?.icon ||
|
||||
(selector.location?.radius
|
||||
? "mdi:map-marker-radius"
|
||||
: "mdi:map-marker",
|
||||
: "mdi:map-marker"),
|
||||
location_editable: true,
|
||||
radius_editable:
|
||||
!!selector.location?.radius && !selector.location?.radius_readonly,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { getEntityLocation } from "../../common/entity/get_entity_location";
|
||||
import { supportsWebGL2 } from "../../common/map/base-layer";
|
||||
import type {
|
||||
MapClusterIcon,
|
||||
MapControlPosition,
|
||||
MapEngine,
|
||||
MapItemHandle,
|
||||
MapLatLng,
|
||||
@@ -287,6 +288,9 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
@property({ attribute: "fit-zones", type: Boolean }) public fitZones = false;
|
||||
|
||||
@property({ attribute: "zoom-position" })
|
||||
public zoomPosition: MapControlPosition = "topleft";
|
||||
|
||||
private _zonePositions: Record<string, MapLatLng> = {};
|
||||
|
||||
@property({ attribute: "theme-mode", type: String })
|
||||
@@ -437,6 +441,10 @@ export class HaMap extends ReactiveElement {
|
||||
this._drawEntities();
|
||||
}
|
||||
|
||||
if (changedProps.has("zoomPosition")) {
|
||||
this._engine?.setZoomControlPosition(this.zoomPosition);
|
||||
}
|
||||
|
||||
const oldConfig = changedProps.get("_config") as HassConfig | undefined;
|
||||
if (
|
||||
changedProps.has("_loaded") ||
|
||||
@@ -577,7 +585,7 @@ export class HaMap extends ReactiveElement {
|
||||
darkMode: this._darkMode,
|
||||
token,
|
||||
rasterOnly: this._forceLeaflet,
|
||||
zoomControlPosition: "topleft",
|
||||
zoomControlPosition: this.zoomPosition,
|
||||
events: {
|
||||
click: (location) => this._handleEngineClick(location),
|
||||
zoomStart: () => {
|
||||
@@ -772,6 +780,8 @@ export class HaMap extends ReactiveElement {
|
||||
if (this._deferIfUnsized(() => this.fitBounds(boundingbox, options))) {
|
||||
return;
|
||||
}
|
||||
// An explicit fit is user intent; the reset focus control resumes auto-fit
|
||||
this._pauseAutoFit = true;
|
||||
this._withProgrammaticFit(() => {
|
||||
this._engine!.fitBounds(boundingbox, {
|
||||
maxZoom: options?.zoom || this.zoom,
|
||||
@@ -1437,6 +1447,11 @@ export class HaMap extends ReactiveElement {
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.maplibregl-ctrl-bottom-left,
|
||||
.maplibregl-ctrl-bottom-right {
|
||||
/* Lets a card keep the attribution and scale clear of an overlay */
|
||||
margin-bottom: var(--ha-map-bottom-inset, 0);
|
||||
}
|
||||
.dark .maplibregl-ctrl.maplibregl-ctrl-group {
|
||||
background-color: #1c1c1c;
|
||||
}
|
||||
@@ -1536,6 +1551,10 @@ export class HaMap extends ReactiveElement {
|
||||
--ha-marker-border-radius: 10px;
|
||||
}
|
||||
${unsafeCSS(zoneMarkerStyles)}
|
||||
.leaflet-bottom {
|
||||
/* Lets a card keep the attribution and scale clear of an overlay */
|
||||
margin-bottom: var(--ha-map-bottom-inset, 0);
|
||||
}
|
||||
.leaflet-control,
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
|
||||
+2
-2
@@ -93,14 +93,14 @@ export interface HistoryStreamMessage {
|
||||
}
|
||||
|
||||
export const entityIdHistoryNeedsAttributes = (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "states">,
|
||||
entityId: string
|
||||
) =>
|
||||
!hass.states[entityId] ||
|
||||
NEED_ATTRIBUTE_DOMAINS.includes(computeDomain(entityId));
|
||||
|
||||
export const fetchDateWS = (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "states" | "callWS">,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
entityIds: string[]
|
||||
|
||||
@@ -368,6 +368,8 @@ export interface LocationSelector {
|
||||
radius?: boolean;
|
||||
radius_readonly?: boolean;
|
||||
icon?: string;
|
||||
/** Marker and radius color; defaults to the theme's zone color */
|
||||
color?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,14 +12,21 @@ import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mi
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { HomeZoneDetailDialogParams } from "./show-dialog-home-zone-detail";
|
||||
import {
|
||||
HOME_ZONE_ENTITY_ID,
|
||||
zoneColor,
|
||||
} from "../../../common/map/entity-map-colors";
|
||||
|
||||
const SCHEMA = [
|
||||
{
|
||||
name: "location",
|
||||
required: true,
|
||||
selector: { location: { radius: true } },
|
||||
},
|
||||
];
|
||||
const SCHEMA = memoizeOne(
|
||||
(icon: string, color: string) =>
|
||||
[
|
||||
{
|
||||
name: "location",
|
||||
required: true,
|
||||
selector: { location: { radius: true, icon, color } },
|
||||
},
|
||||
] as const
|
||||
);
|
||||
|
||||
@customElement("dialog-home-zone-detail")
|
||||
class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams>()(
|
||||
@@ -81,7 +88,11 @@ class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams
|
||||
<ha-form
|
||||
autofocus
|
||||
.hass=${this.hass}
|
||||
.schema=${SCHEMA}
|
||||
.schema=${SCHEMA(
|
||||
this.hass.states[HOME_ZONE_ENTITY_ID]?.attributes.icon ||
|
||||
"mdi:home",
|
||||
zoneColor(HOME_ZONE_ENTITY_ID, false, getComputedStyle(this))
|
||||
)}
|
||||
.data=${this._formData(this._data)}
|
||||
.error=${this._error}
|
||||
.computeLabel=${this._computeLabel}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mi
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { ZoneDetailDialogParams } from "./show-dialog-zone-detail";
|
||||
import { zoneColor } from "../../../common/map/entity-map-colors";
|
||||
|
||||
@customElement("dialog-zone-detail")
|
||||
class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
|
||||
@@ -105,7 +106,16 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
|
||||
<ha-form
|
||||
autofocus
|
||||
.hass=${this.hass}
|
||||
.schema=${this._schema(this._data.icon)}
|
||||
.schema=${this._schema(
|
||||
this._data.icon,
|
||||
this._params?.entityId
|
||||
? zoneColor(
|
||||
this._params.entityId,
|
||||
!!this._data.passive,
|
||||
getComputedStyle(this)
|
||||
)
|
||||
: undefined
|
||||
)}
|
||||
.data=${this._formData(this._data)}
|
||||
.error=${this._error}
|
||||
.computeLabel=${this._computeLabel}
|
||||
@@ -153,7 +163,7 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(icon?: string) =>
|
||||
(icon?: string, color?: string) =>
|
||||
[
|
||||
{
|
||||
name: "name",
|
||||
@@ -172,7 +182,7 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
|
||||
{
|
||||
name: "location",
|
||||
required: true,
|
||||
selector: { location: { radius: true, icon } },
|
||||
selector: { location: { radius: true, icon, color } },
|
||||
},
|
||||
{ name: "passive_note", type: "constant" },
|
||||
{ name: "passive", selector: { boolean: {} } },
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
|
||||
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
|
||||
@@ -24,9 +25,14 @@ import type {
|
||||
import { saveCoreConfig } from "../../../data/core";
|
||||
import { subscribeEntityRegistry } from "../../../data/entity/entity_registry";
|
||||
import {
|
||||
HOME_ZONE_ENTITY_ID,
|
||||
subscribeEntityMapColors,
|
||||
zoneColor,
|
||||
} from "../../../common/map/entity-map-colors";
|
||||
import {
|
||||
contrastingZoneContent,
|
||||
zoneInitials,
|
||||
} from "../../../common/map/zone-marker";
|
||||
import type {
|
||||
HomeZoneMutableParams,
|
||||
Zone,
|
||||
@@ -79,6 +85,151 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
// Bumped when entity map colors change to recompute the memoized locations
|
||||
@state() private _colorVersion = 0;
|
||||
|
||||
// Home first, then alphabetical, for UI and YAML zones alike
|
||||
private _sortedItems = memoizeOne(
|
||||
(
|
||||
storageItems: Zone[],
|
||||
stateItems: HassEntity[],
|
||||
language: string
|
||||
): ({ entry: Zone } | { stateObject: HassEntity })[] => {
|
||||
const items = [
|
||||
...storageItems.map((entry) => ({
|
||||
entry,
|
||||
name: entry.name,
|
||||
home: false,
|
||||
})),
|
||||
...stateItems.map((stateObject) => ({
|
||||
stateObject,
|
||||
name: stateObject.attributes.friendly_name || stateObject.entity_id,
|
||||
home: stateObject.entity_id === HOME_ZONE_ENTITY_ID,
|
||||
})),
|
||||
];
|
||||
return items.sort((a, b) =>
|
||||
a.home !== b.home
|
||||
? a.home
|
||||
? -1
|
||||
: 1
|
||||
: stringCompare(a.name, b.name, language)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
private _renderStorageItem(entry: Zone) {
|
||||
const hass = this.hass;
|
||||
return html`
|
||||
<ha-list-item
|
||||
.entry=${entry}
|
||||
.id=${this.narrow ? entry.id : ""}
|
||||
graphic="avatar"
|
||||
.hasMeta=${!this.narrow}
|
||||
@request-selected=${this._itemClicked}
|
||||
.value=${entry.id}
|
||||
?selected=${this._activeEntry === entry.id}
|
||||
>
|
||||
${this._renderZoneGraphic(
|
||||
this._zoneEntityIds[entry.id] ?? `zone.${entry.id}`,
|
||||
!!entry.passive,
|
||||
entry.icon,
|
||||
entry.name
|
||||
)}
|
||||
${entry.name}
|
||||
${
|
||||
!this.narrow
|
||||
? html`
|
||||
<div slot="meta">
|
||||
<ha-icon-button
|
||||
.id=${entry.id}
|
||||
.entry=${entry}
|
||||
@click=${this._openEditEntry}
|
||||
.path=${mdiPencil}
|
||||
.label=${hass.localize("ui.common.edit_item", {
|
||||
name: entry.name,
|
||||
})}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</ha-list-item>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderStateItem(stateObject: HassEntity) {
|
||||
const hass = this.hass;
|
||||
return html`
|
||||
<ha-list-item
|
||||
graphic="avatar"
|
||||
.id=${this.narrow ? stateObject.entity_id : ""}
|
||||
.hasMeta=${!this.narrow || stateObject.entity_id !== "zone.home"}
|
||||
.value=${stateObject.entity_id}
|
||||
@request-selected=${this._stateItemClicked}
|
||||
?selected=${this._activeEntry === stateObject.entity_id}
|
||||
.noEdit=${stateObject.entity_id !== "zone.home" || !this._canEditCore}
|
||||
>
|
||||
${this._renderZoneGraphic(
|
||||
stateObject.entity_id,
|
||||
!!stateObject.attributes.passive,
|
||||
stateObject.attributes.icon,
|
||||
stateObject.attributes.friendly_name || stateObject.entity_id
|
||||
)}
|
||||
${stateObject.attributes.friendly_name || stateObject.entity_id}
|
||||
${
|
||||
this.narrow &&
|
||||
stateObject.entity_id === "zone.home" &&
|
||||
!this._canEditCore
|
||||
? nothing
|
||||
: html`<ha-icon-button
|
||||
.id="zone-${slugify(stateObject.entity_id)}"
|
||||
.entityId=${stateObject.entity_id}
|
||||
.noEdit=${
|
||||
stateObject.entity_id !== "zone.home" || !this._canEditCore
|
||||
}
|
||||
.path=${
|
||||
stateObject.entity_id === "zone.home" && this._canEditCore
|
||||
? mdiPencil
|
||||
: mdiPencilOff
|
||||
}
|
||||
.label=${hass.localize("ui.common.edit_item", {
|
||||
name: hass.config.location_name,
|
||||
})}
|
||||
@click=${this._editHomeZone}
|
||||
slot="meta"
|
||||
></ha-icon-button>
|
||||
<ha-tooltip
|
||||
.for="zone-${slugify(stateObject.entity_id)}"
|
||||
placement="left"
|
||||
.disabled=${stateObject.entity_id === "zone.home"}
|
||||
hoist
|
||||
>
|
||||
${hass.localize("ui.panel.config.zone.configured_in_yaml")}
|
||||
</ha-tooltip>`
|
||||
}
|
||||
</ha-list-item>
|
||||
`;
|
||||
}
|
||||
|
||||
// The zone as it looks on the map: its color, with its icon or initials
|
||||
private _renderZoneGraphic(
|
||||
entityId: string,
|
||||
passive: boolean,
|
||||
icon: string | undefined,
|
||||
name: string
|
||||
) {
|
||||
const color = zoneColor(entityId, passive, getComputedStyle(this));
|
||||
return html`
|
||||
<div
|
||||
slot="graphic"
|
||||
class="zone-avatar"
|
||||
style=${styleMap({
|
||||
background: color,
|
||||
color: contrastingZoneContent(color),
|
||||
})}
|
||||
>
|
||||
${icon ? html`<ha-icon .icon=${icon}></ha-icon>` : zoneInitials(name)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _getZones = memoizeOne(
|
||||
(
|
||||
storageItems: Zone[],
|
||||
@@ -165,102 +316,14 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
`
|
||||
: html`
|
||||
<ha-list>
|
||||
${this._storageItems.map(
|
||||
(entry) => html`
|
||||
<ha-list-item
|
||||
.entry=${entry}
|
||||
.id=${this.narrow ? entry.id : ""}
|
||||
graphic="icon"
|
||||
.hasMeta=${!this.narrow}
|
||||
@request-selected=${this._itemClicked}
|
||||
.value=${entry.id}
|
||||
?selected=${this._activeEntry === entry.id}
|
||||
>
|
||||
<ha-icon .icon=${entry.icon} slot="graphic"></ha-icon>
|
||||
${entry.name}
|
||||
${
|
||||
!this.narrow
|
||||
? html`
|
||||
<div slot="meta">
|
||||
<ha-icon-button
|
||||
.id=${entry.id}
|
||||
.entry=${entry}
|
||||
@click=${this._openEditEntry}
|
||||
.path=${mdiPencil}
|
||||
.label=${hass.localize("ui.common.edit_item", {
|
||||
name: entry.name,
|
||||
})}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</ha-list-item>
|
||||
`
|
||||
)}
|
||||
${this._stateItems.map(
|
||||
(stateObject) => html`
|
||||
<ha-list-item
|
||||
graphic="icon"
|
||||
.id=${this.narrow ? stateObject.entity_id : ""}
|
||||
.hasMeta=${
|
||||
!this.narrow || stateObject.entity_id !== "zone.home"
|
||||
}
|
||||
.value=${stateObject.entity_id}
|
||||
@request-selected=${this._stateItemClicked}
|
||||
?selected=${this._activeEntry === stateObject.entity_id}
|
||||
.noEdit=${
|
||||
stateObject.entity_id !== "zone.home" ||
|
||||
!this._canEditCore
|
||||
}
|
||||
>
|
||||
<ha-icon
|
||||
.icon=${stateObject.attributes.icon}
|
||||
slot="graphic"
|
||||
>
|
||||
</ha-icon>
|
||||
|
||||
${
|
||||
stateObject.attributes.friendly_name ||
|
||||
stateObject.entity_id
|
||||
}
|
||||
${
|
||||
this.narrow &&
|
||||
stateObject.entity_id === "zone.home" &&
|
||||
!this._canEditCore
|
||||
? nothing
|
||||
: html`<ha-icon-button
|
||||
.id="zone-${slugify(stateObject.entity_id)}"
|
||||
.entityId=${stateObject.entity_id}
|
||||
.noEdit=${
|
||||
stateObject.entity_id !== "zone.home" ||
|
||||
!this._canEditCore
|
||||
}
|
||||
.path=${
|
||||
stateObject.entity_id === "zone.home" &&
|
||||
this._canEditCore
|
||||
? mdiPencil
|
||||
: mdiPencilOff
|
||||
}
|
||||
.label=${hass.localize("ui.common.edit_item", {
|
||||
name: hass.config.location_name,
|
||||
})}
|
||||
@click=${this._editHomeZone}
|
||||
slot="meta"
|
||||
></ha-icon-button>
|
||||
<ha-tooltip
|
||||
.for="zone-${slugify(stateObject.entity_id)}"
|
||||
placement="left"
|
||||
.disabled=${stateObject.entity_id === "zone.home"}
|
||||
hoist
|
||||
>
|
||||
${hass.localize(
|
||||
"ui.panel.config.zone.configured_in_yaml"
|
||||
)}
|
||||
</ha-tooltip>`
|
||||
}
|
||||
</ha-list-item>
|
||||
`
|
||||
${this._sortedItems(
|
||||
this._storageItems,
|
||||
this._stateItems,
|
||||
hass.locale.language
|
||||
).map((item) =>
|
||||
"entry" in item
|
||||
? this._renderStorageItem(item.entry)
|
||||
: this._renderStateItem(item.stateObject)
|
||||
)}
|
||||
</ha-list>
|
||||
`;
|
||||
@@ -576,6 +639,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
private async _openDialog(entry?: Zone) {
|
||||
showZoneDetailDialog(this, {
|
||||
entry,
|
||||
entityId: entry ? this._zoneEntityIds[entry.id] : undefined,
|
||||
createEntry: (values) => this._createEntry(values),
|
||||
updateEntry: entry
|
||||
? (values) => this._updateEntry(entry, values, true)
|
||||
@@ -603,6 +667,19 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
ha-icon-button:not([disabled]) {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.zone-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
.zone-avatar ha-icon {
|
||||
color: inherit;
|
||||
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
ha-icon-button {
|
||||
--mdc-theme-text-disabled-on-light: var(--disabled-text-color);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { Zone, ZoneMutableParams } from "../../../data/zone";
|
||||
|
||||
export interface ZoneDetailDialogParams {
|
||||
entry?: Zone;
|
||||
/** The zone's entity, when it exists, for its map color */
|
||||
entityId?: string;
|
||||
createEntry: (values: ZoneMutableParams) => Promise<unknown>;
|
||||
updateEntry?: (updates: Partial<ZoneMutableParams>) => Promise<unknown>;
|
||||
removeEntry?: () => Promise<boolean>;
|
||||
|
||||
@@ -3,14 +3,15 @@ import {
|
||||
mdiGoogleCirclesCommunities,
|
||||
mdiImageFilterCenterFocus,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { getColorByIndex } from "../../../common/color/colors";
|
||||
import { resolveThemeColor } from "../../../common/color/compute-color";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { computeRTL } from "../../../common/util/compute_rtl";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
|
||||
import { computeStateName } from "../../../common/entity/compute_state_name";
|
||||
@@ -29,9 +30,16 @@ import type {
|
||||
MapCardMarkerLabelMode,
|
||||
} from "../../../components/map/ha-map";
|
||||
import type { MapLatLng } from "../../../common/map/map-engine";
|
||||
import {
|
||||
entityMapColor,
|
||||
subscribeEntityMapColors,
|
||||
zoneColor,
|
||||
} from "../../../common/map/entity-map-colors";
|
||||
import type { HistoryStates } from "../../../data/history";
|
||||
import { subscribeHistoryStatesTimeWindow } from "../../../data/history";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { PANEL_VIEW_LAYOUT } from "../views/const";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import {
|
||||
hasConfigChanged,
|
||||
@@ -48,6 +56,12 @@ import {
|
||||
export const DEFAULT_HOURS_TO_SHOW = 0;
|
||||
export const DEFAULT_ZOOM = 14;
|
||||
|
||||
// GPS accuracy (meters) above which the selected person's circle is shown
|
||||
const IMPRECISE_GPS_ACCURACY = 100;
|
||||
|
||||
const FOCUS_PERSON_ZOOM = 19;
|
||||
const FOCUS_ZONE_MAX_ZOOM = 18;
|
||||
|
||||
interface GeoEntity {
|
||||
entity_id: string;
|
||||
label_mode?: MapCardMarkerLabelMode;
|
||||
@@ -62,6 +76,8 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
|
||||
@property({ attribute: false }) public layout?: string;
|
||||
|
||||
@property({ type: Boolean }) public preview = false;
|
||||
|
||||
@state() private _stateHistory?: HistoryStates;
|
||||
|
||||
@state()
|
||||
@@ -76,14 +92,17 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
|
||||
private _filteredMapEntities: HaMapEntity[] = [];
|
||||
|
||||
private _colorDict: Record<string, string> = {};
|
||||
|
||||
private _colorIndex = 0;
|
||||
|
||||
@state() private _error?: { code: string; message: string };
|
||||
|
||||
@state() private _clusterMarkers = true;
|
||||
|
||||
@state() private _overviewSelected?: string;
|
||||
|
||||
// Height of the overview drawer when it sits over the bottom of the map
|
||||
@state() private _overviewHeight = 0;
|
||||
|
||||
private _overviewLoaded = false;
|
||||
|
||||
private _subscribed?: Promise<(() => Promise<void>) | undefined>;
|
||||
|
||||
private _getAllEntities(): string[] {
|
||||
@@ -209,18 +228,35 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
|
||||
return html`
|
||||
<ha-card id="card" .header=${this._config.title}>
|
||||
<div id="root">
|
||||
<div
|
||||
id="root"
|
||||
class=${this.layout === PANEL_VIEW_LAYOUT ? "panel-layout" : ""}
|
||||
@hass-more-info=${this._handleMapMoreInfo}
|
||||
>
|
||||
<ha-map
|
||||
style=${styleMap({
|
||||
"--overview-height": `${this._overviewHeight}px`,
|
||||
})}
|
||||
.entities=${this._filteredMapEntities}
|
||||
.zoom=${this._config.default_zoom ?? DEFAULT_ZOOM}
|
||||
.paths=${this._getHistoryPaths(this._config, this._stateHistory)}
|
||||
.autoFit=${this._config.auto_fit || false}
|
||||
.fitZones=${this._config.fit_zones || false}
|
||||
.zoomPosition=${
|
||||
this.layout === PANEL_VIEW_LAYOUT &&
|
||||
!computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
? "topright"
|
||||
: "topleft"
|
||||
}
|
||||
.themeMode=${themeMode}
|
||||
.clusterMarkers=${this._clusterMarkers}
|
||||
.scaleRuler=${this._config.scale_ruler || false}
|
||||
@map-clicked=${this._handleMapClicked}
|
||||
interactive-zones
|
||||
render-passive
|
||||
.renderPassive=${this.layout !== PANEL_VIEW_LAYOUT}
|
||||
></ha-map>
|
||||
<div id="buttons">
|
||||
${
|
||||
@@ -252,6 +288,17 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
tabindex="0"
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
${
|
||||
this.layout === PANEL_VIEW_LAYOUT
|
||||
? html`<hui-map-overview
|
||||
id="overview"
|
||||
.entities=${this._filteredMapEntities}
|
||||
.selected=${this._overviewSelected}
|
||||
@map-overview-select=${this._handleOverviewSelect}
|
||||
@map-overview-resize=${this._handleOverviewResize}
|
||||
></hui-map-overview>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
@@ -299,6 +346,9 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("hass")) {
|
||||
this._subscribeColors();
|
||||
}
|
||||
if (
|
||||
this._config?.show_all &&
|
||||
!this._config?.entities &&
|
||||
@@ -334,18 +384,75 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
} else {
|
||||
this._filteredMapEntities = this._mapEntities;
|
||||
}
|
||||
|
||||
if (this.layout === PANEL_VIEW_LAYOUT) {
|
||||
if (!this._overviewLoaded) {
|
||||
this._overviewLoaded = true;
|
||||
void import("./map/hui-map-overview");
|
||||
}
|
||||
this._filteredMapEntities = this._decorateOverviewEntities(
|
||||
this._filteredMapEntities,
|
||||
this._overviewSelected,
|
||||
this._overviewSelected
|
||||
? this.hass.states[this._overviewSelected]
|
||||
: undefined,
|
||||
this.preview
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// In panel layout, only the selected zone shows its radius (all of them
|
||||
// while editing) and only an imprecise selected person its accuracy circle
|
||||
private _decorateOverviewEntities = memoizeOne(
|
||||
(
|
||||
entities: HaMapEntity[],
|
||||
selectedId: string | undefined,
|
||||
selectedStateObj: HassEntity | undefined,
|
||||
preview: boolean
|
||||
): HaMapEntity[] => {
|
||||
const selectedLocation = selectedStateObj
|
||||
? getEntityLocation(selectedStateObj, this.hass.states)
|
||||
: undefined;
|
||||
const showSelectedAccuracy =
|
||||
(selectedLocation?.gpsAccuracy ?? 0) > IMPRECISE_GPS_ACCURACY;
|
||||
return entities.map((entity) => ({
|
||||
...entity,
|
||||
hide_accuracy: !(
|
||||
showSelectedAccuracy && entity.entity_id === selectedId
|
||||
),
|
||||
hide_radius: !preview && entity.entity_id !== selectedId,
|
||||
selected: entity.entity_id === selectedId,
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
private _unsubscribeColors?: () => void;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.hasUpdated && this._configEntities?.length) {
|
||||
this._subscribeHistory();
|
||||
}
|
||||
this._subscribeColors();
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribeHistory();
|
||||
this._unsubscribeColors?.();
|
||||
this._unsubscribeColors = undefined;
|
||||
}
|
||||
|
||||
private _subscribeColors(): void {
|
||||
if (this._unsubscribeColors || !this.hass?.connection) {
|
||||
return;
|
||||
}
|
||||
this._unsubscribeColors = subscribeEntityMapColors(
|
||||
this.hass.connection,
|
||||
() => {
|
||||
this._mapEntities = this._getMapEntities();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private _subscribeHistory() {
|
||||
@@ -428,22 +535,90 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
this._map?.fitMap({ unpause_autofit: true });
|
||||
}
|
||||
|
||||
private _handleMapClicked() {
|
||||
// A click on the map itself (not on a marker) deselects
|
||||
if (this._overviewSelected) {
|
||||
this._overviewSelected = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleMapMoreInfo(ev: HASSDomEvent<{ entityId: string | null }>) {
|
||||
if ((ev.target as HTMLElement)?.localName === "hui-map-overview") {
|
||||
// The overview asks for the dialog itself, so let it through
|
||||
return;
|
||||
}
|
||||
const entityId = ev.detail.entityId;
|
||||
if (
|
||||
this.layout !== PANEL_VIEW_LAYOUT ||
|
||||
!entityId ||
|
||||
!["person", "device_tracker", "zone"].includes(computeDomain(entityId)) ||
|
||||
(computeDomain(entityId) !== "zone" &&
|
||||
!this._filteredMapEntities.some(
|
||||
(entity) => entity.entity_id === entityId
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
this._overviewSelected = entityId;
|
||||
this._focusEntity(entityId);
|
||||
}
|
||||
|
||||
private _handleOverviewResize(ev: HASSDomEvent<{ height: number }>) {
|
||||
this._overviewHeight = ev.detail.height;
|
||||
}
|
||||
|
||||
private _handleOverviewSelect(ev: HASSDomEvent<{ entityId?: string }>) {
|
||||
this._overviewSelected = ev.detail.entityId;
|
||||
if (ev.detail.entityId) {
|
||||
this._focusEntity(ev.detail.entityId);
|
||||
}
|
||||
}
|
||||
|
||||
private _focusEntity(entityId: string) {
|
||||
const stateObj = this.hass.states[entityId];
|
||||
if (!stateObj) {
|
||||
return;
|
||||
}
|
||||
if (computeStateDomain(stateObj) === "zone") {
|
||||
const { latitude, longitude, radius } = stateObj.attributes;
|
||||
// Convert the zone radius (meters) to a degree offset for a bounding box
|
||||
const latOffset = (radius ?? 100) / 111320;
|
||||
const lngOffset =
|
||||
latOffset / Math.max(Math.cos((latitude * Math.PI) / 180), 0.01);
|
||||
this._map?.fitBounds(
|
||||
[
|
||||
[latitude - latOffset, longitude - lngOffset],
|
||||
[latitude + latOffset, longitude + lngOffset],
|
||||
],
|
||||
{ pad: 0.2, zoom: FOCUS_ZONE_MAX_ZOOM }
|
||||
);
|
||||
return;
|
||||
}
|
||||
const location = getEntityLocation(stateObj, this.hass.states);
|
||||
if (location) {
|
||||
this._map?.fitBounds([[location.latitude, location.longitude]], {
|
||||
zoom: FOCUS_PERSON_ZOOM,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _toggleClusterMarkers() {
|
||||
this._clusterMarkers = !this._clusterMarkers;
|
||||
}
|
||||
|
||||
// The same color for an entity on every map; a config color still wins
|
||||
private _getColor(entityId: string): string {
|
||||
let color = this._colorDict[entityId];
|
||||
if (color) {
|
||||
return color;
|
||||
}
|
||||
const computedStyles = getComputedStyle(this);
|
||||
color = getColorByIndex(this._colorIndex, computedStyles);
|
||||
if (color) {
|
||||
this._colorIndex++;
|
||||
this._colorDict[entityId] = color;
|
||||
if (computeDomain(entityId) === "zone") {
|
||||
const stateObj = this.hass?.states[entityId];
|
||||
return zoneColor(
|
||||
entityId,
|
||||
!!stateObj?.attributes.passive,
|
||||
computedStyles
|
||||
);
|
||||
}
|
||||
return color;
|
||||
return entityMapColor(entityId, computedStyles);
|
||||
}
|
||||
|
||||
private _getSourceEntities(states?: HassEntities): GeoEntity[] {
|
||||
@@ -595,10 +770,45 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* The overview panel covers the start side in panel layout */
|
||||
#root.panel-layout #buttons {
|
||||
left: auto;
|
||||
inset-inline-start: auto;
|
||||
inset-inline-end: 3px;
|
||||
}
|
||||
|
||||
#root {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#overview {
|
||||
position: absolute;
|
||||
top: var(--ha-space-3);
|
||||
inset-inline-start: var(--ha-space-3);
|
||||
width: min(360px, calc(100% - 2 * var(--ha-space-3)));
|
||||
max-height: calc(100% - 2 * var(--ha-space-3));
|
||||
display: flex;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#overview {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
width: auto;
|
||||
max-height: 70%;
|
||||
}
|
||||
|
||||
/* Keep the attribution and scale ruler above the drawer */
|
||||
#root.panel-layout ha-map {
|
||||
--ha-map-bottom-inset: calc(
|
||||
var(--overview-height, 0px) + var(--ha-space-2)
|
||||
);
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9321,7 +9321,20 @@
|
||||
},
|
||||
"map": {
|
||||
"reset_focus": "Reset focus",
|
||||
"toggle_grouping": "Toggle grouping"
|
||||
"toggle_grouping": "Toggle grouping",
|
||||
"overview": {
|
||||
"people": "People",
|
||||
"devices": "Devices",
|
||||
"zones": "Zones",
|
||||
"activity": "Activity",
|
||||
"no_activity": "No recent activity",
|
||||
"person_arrived": "{name} arrived",
|
||||
"person_left": "{name} left",
|
||||
"show_list": "Show list",
|
||||
"show_more_info": "Show more info",
|
||||
"hide_list": "Hide list",
|
||||
"people_in_zone": "{count, plural, =0 {No one here} one {{count} person} other {{count} people}}"
|
||||
}
|
||||
},
|
||||
"energy": {
|
||||
"loading": "Loading…",
|
||||
|
||||
Reference in New Issue
Block a user