Compare commits

...
Author SHA1 Message Date
Maarten Lakerveld e949e28c12 Sort zones alphabetically with home first
The zone config page listed UI zones sorted and YAML zones after them in
state order; the map overview sorted all zones by name. Both now show the
home zone first and the rest alphabetically.
2026-09-07 16:17:19 +02:00
Maarten Lakerveld d7aa815631 Show zone markers in the zone list
The zones list on the zone config page shows each zone as it looks on
the map: its color with its icon, or initials without one, instead of
a plain icon.
2026-09-07 16:17:19 +02:00
Maarten Lakerveld fbc4106c48 Show a zone's own icon and color on the zone dialog map
The location selector's icon option was never applied: the expression
parsed as (icon || radius) ? radius-icon : marker-icon, so the zone
dialog always showed the radius marker instead of the zone's icon. The
selector also gains a color option, and both zone dialogs pass the
zone's icon and its map color (the config panel hands the dialog the
zone's entity id for that), so the map in the dialog looks like the zone
does everywhere else, including the home zone and a live passive toggle.
2026-09-07 16:17:19 +02:00
Maarten Lakerveld 3e001665d5 Color activity timeline dots by zone
Zone changes in a person's or device's activity timeline show the dot in
that zone's color, the same color as its marker on the map and its row
in the zones tab; away and unknown stay grey. Arrivals and departures in
a zone's own timeline keep their green and grey.
2026-09-07 16:17:19 +02:00
Maarten Lakerveld c0f15212c9 Add people, devices, and zones overview to the map card in panel view
When the map card is the single card of a panel view it shows a
floating overview with People, Devices, and Zones tabs styled as an
animated segmented control; on mobile it becomes a bottom sheet drawer
that can be collapsed by tap or swipe. People list their zone or Away
state with the last update time, devices only appear when they have a
location, and zones show their occupant count while passive zones are
hidden in this view.

Selecting a row or map marker opens a detail view with a back button,
a name that opens the more info dialog, and a 24h activity timeline:
zone changes for a person or device, arrivals and departures per person
for a zone. Selecting focuses the map (street level for people, the
radius for zones, shown only while selected), marks the selected marker
with a thicker border, shows the accuracy circle when the position is
imprecise, and clicking the map deselects. In panel layout the overview
sits at the start side, so the zoom control and map buttons move to the
other side: ha-map gains a zoom-position property for that, and honours
--ha-map-bottom-inset so the attribution and scale stay clear of the
bottom sheet.
2026-09-07 16:17:19 +02:00
11 changed files with 1525 additions and 131 deletions
@@ -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,
+20 -1
View File
@@ -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
View File
@@ -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[]
+2
View File
@@ -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}
+13 -3
View File
@@ -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: {} } },
+173 -96
View File
@@ -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>;
+227 -17
View File
@@ -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
+14 -1
View File
@@ -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…",