mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-17 15:40:27 +00:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfab3c5f9f | ||
|
|
d2d9a9d75c | ||
|
|
8c24b63fb6 | ||
|
|
26b573bc79 | ||
|
|
eb403e3940 | ||
|
|
2dd3a4ded5 | ||
|
|
adb96352a9 | ||
|
|
4401ec1479 | ||
|
|
f47a484391 | ||
|
|
79e53a84f6 | ||
|
|
393255cf1b | ||
|
|
8d6d4ab725 | ||
|
|
fd85cac5c1 | ||
|
|
10eb16d353 | ||
|
|
edbeabc32a | ||
|
|
511fc8ca22 | ||
|
|
2519723a29 | ||
|
|
de57deb50a | ||
|
|
e595ae8984 | ||
|
|
dbd369a5a0 | ||
|
|
a7f5c51def | ||
|
|
e6c3e0c549 | ||
|
|
7c7549349a | ||
|
|
051f51dd26 | ||
|
|
9a9d747899 | ||
|
|
5b99462fdf | ||
|
|
14ef770c44 |
@@ -186,6 +186,11 @@ export class LeafletMapEngine implements MapEngine {
|
||||
this.leafletMap.fitBounds(bounds, {
|
||||
maxZoom: options?.maxZoom,
|
||||
animate: options?.animate,
|
||||
paddingTopLeft: [options?.padding?.left ?? 0, options?.padding?.top ?? 0],
|
||||
paddingBottomRight: [
|
||||
options?.padding?.right ?? 0,
|
||||
options?.padding?.bottom ?? 0,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -123,8 +123,6 @@ interface ManagedMarker {
|
||||
draggable?: boolean;
|
||||
onDragEnd?: (location: MapLatLng) => void;
|
||||
dragging?: boolean;
|
||||
/** Pre-drag location; the next update echoing it is ignored (see setLocation) */
|
||||
staleLocation?: MapLatLng;
|
||||
mlMarker?: MapLibreMarker;
|
||||
decoration?: MapItemHandle;
|
||||
removed?: boolean;
|
||||
@@ -133,6 +131,8 @@ interface ManagedMarker {
|
||||
interface ClusterGroup {
|
||||
/** Members are shown in a bubble at their spot instead of an icon */
|
||||
open?: boolean;
|
||||
/** Grouped by key (a zone), so it bubbles even with a single member */
|
||||
keyed?: boolean;
|
||||
members: ManagedMarker[];
|
||||
center: MapLatLng;
|
||||
iconMarker?: MapLibreMarker;
|
||||
@@ -518,13 +518,26 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
options?.maxZoom !== undefined
|
||||
? options.maxZoom - ZOOM_OFFSET
|
||||
: undefined;
|
||||
// Passed per fit: easeTo's padding would stick to the map
|
||||
const padding = {
|
||||
top: options?.padding?.top ?? 0,
|
||||
right: options?.padding?.right ?? 0,
|
||||
bottom: options?.padding?.bottom ?? 0,
|
||||
left: options?.padding?.left ?? 0,
|
||||
};
|
||||
if (minLat === maxLat && minLng === maxLng) {
|
||||
// Zero-area bounds: center on the point
|
||||
this._map.easeTo({
|
||||
center: [minLng, minLat],
|
||||
zoom: maxZoom ?? this._map.getZoom(),
|
||||
animate: options?.animate,
|
||||
});
|
||||
// Zero-area bounds: center on the point, keeping the zoom unless given
|
||||
this._map.fitBounds(
|
||||
[
|
||||
[minLng, minLat],
|
||||
[minLng, minLat],
|
||||
],
|
||||
{
|
||||
maxZoom: maxZoom ?? this._map.getZoom(),
|
||||
animate: options?.animate,
|
||||
padding,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
const pad = options?.pad ?? 0.5;
|
||||
@@ -535,7 +548,7 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
[minLng - lngPad, minLat - latPad],
|
||||
[maxLng + lngPad, maxLat + latPad],
|
||||
],
|
||||
{ maxZoom, animate: options?.animate }
|
||||
{ maxZoom, animate: options?.animate, padding }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -600,19 +613,10 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
},
|
||||
clusterData: options.clusterData,
|
||||
setLocation: (newLocation) => {
|
||||
// The user's hand wins while dragging; the host is the truth otherwise
|
||||
if (managed.dragging) {
|
||||
return;
|
||||
}
|
||||
// Skip one update echoing the pre-drag location (the save has not returned yet)
|
||||
const stale = managed.staleLocation;
|
||||
managed.staleLocation = undefined;
|
||||
if (
|
||||
stale &&
|
||||
newLocation[0] === stale[0] &&
|
||||
newLocation[1] === stale[1]
|
||||
) {
|
||||
return;
|
||||
}
|
||||
managed.location = newLocation;
|
||||
managed.mlMarker?.setLngLat([newLocation[1], newLocation[0]]);
|
||||
},
|
||||
@@ -663,7 +667,6 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
if (managed.draggable) {
|
||||
managed.mlMarker.on("dragstart", () => {
|
||||
managed.dragging = true;
|
||||
managed.staleLocation = managed.location;
|
||||
});
|
||||
managed.mlMarker.on("dragend", () => {
|
||||
managed.dragging = false;
|
||||
@@ -801,10 +804,8 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
resizeHandle?.setAttribute("aria-valuetext", radiusText);
|
||||
};
|
||||
|
||||
// Skip one update echoing the pre-edit values (the save has not returned yet)
|
||||
// The user's hand wins while dragging; the host is the truth otherwise
|
||||
let dragging = false;
|
||||
let staleCenter: MapLatLng | undefined;
|
||||
let staleRadius: number | undefined;
|
||||
|
||||
if (options.resizable) {
|
||||
const east = pointEastOf(center, options.radius);
|
||||
@@ -847,8 +848,6 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
if (keyboardRadius === undefined) {
|
||||
// Host updates treat a key resize like a drag
|
||||
dragging = true;
|
||||
staleCenter = currentCenter;
|
||||
staleRadius = currentRadius;
|
||||
}
|
||||
currentRadius = Math.max(
|
||||
1,
|
||||
@@ -875,19 +874,11 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
[centerMarker, resizeMarker].forEach((handleMarker) => {
|
||||
handleMarker?.on("dragstart", () => {
|
||||
dragging = true;
|
||||
staleCenter = currentCenter;
|
||||
staleRadius = currentRadius;
|
||||
});
|
||||
handleMarker?.on("dragend", () => {
|
||||
dragging = false;
|
||||
});
|
||||
});
|
||||
const isStale = (candidateCenter: MapLatLng, candidateRadius: number) =>
|
||||
staleCenter !== undefined &&
|
||||
staleRadius !== undefined &&
|
||||
candidateCenter[0] === staleCenter[0] &&
|
||||
candidateCenter[1] === staleCenter[1] &&
|
||||
candidateRadius === staleRadius;
|
||||
|
||||
if (options.moveable) {
|
||||
centerMarker.on("drag", () => {
|
||||
@@ -944,12 +935,6 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
if (dragging) {
|
||||
return;
|
||||
}
|
||||
const stale = isStale(newCenter, newRadius);
|
||||
staleCenter = undefined;
|
||||
staleRadius = undefined;
|
||||
if (stale) {
|
||||
return;
|
||||
}
|
||||
currentCenter = newCenter;
|
||||
currentRadius = newRadius;
|
||||
centerMarker.setLngLat([newCenter[1], newCenter[0]]);
|
||||
@@ -1219,13 +1204,16 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
const clusterable = this._markers.filter(
|
||||
(managed) => managed.options.cluster && !managed.removed
|
||||
);
|
||||
// A member that had focus hands it to the icon replacing it; read before
|
||||
// the open bubble holding it is removed
|
||||
// A member or bubble that had focus hands it to the icon replacing it;
|
||||
// read before the bubble holding it is removed
|
||||
const active = (
|
||||
this._map.getContainer().getRootNode() as Document | ShadowRoot
|
||||
).activeElement;
|
||||
const focusedMember = active
|
||||
? clusterable.find((managed) => managed.element.contains(active))
|
||||
? (clusterable.find((managed) => managed.element.contains(active)) ??
|
||||
this._clusterGroups.find((group) =>
|
||||
group.iconMarker?.getElement().contains(active)
|
||||
)?.members[0])
|
||||
: undefined;
|
||||
this._clusterGroups.forEach((group) => group.iconMarker?.remove());
|
||||
|
||||
@@ -1250,6 +1238,7 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
const groups: {
|
||||
seed: { x: number; y: number };
|
||||
members: ManagedMarker[];
|
||||
keyed?: boolean;
|
||||
}[] = [];
|
||||
|
||||
// Keyed groups first; one spread too wide falls through to proximity
|
||||
@@ -1272,8 +1261,10 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
Math.max(...xs) - Math.min(...xs),
|
||||
Math.max(...ys) - Math.min(...ys)
|
||||
);
|
||||
if (members.length > 1 && spread <= (groupRadius ?? radius)) {
|
||||
groups.push({ seed: points[0], members });
|
||||
// A keyed group (a zone) bubbles even with a single member, so a lone
|
||||
// person or device in a zone still shows in a bubble pinned to it.
|
||||
if (members.length === 1 || spread <= (groupRadius ?? radius)) {
|
||||
groups.push({ seed: points[0], members, keyed: true });
|
||||
} else {
|
||||
ungrouped.push(...members);
|
||||
}
|
||||
@@ -1299,6 +1290,7 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
}
|
||||
this._clusterGroups = groups.map((group) => ({
|
||||
members: group.members,
|
||||
keyed: group.keyed,
|
||||
center: [
|
||||
group.members.reduce((sum, m) => sum + m.location[0], 0) /
|
||||
group.members.length,
|
||||
@@ -1310,7 +1302,9 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
|
||||
for (const group of this._clusterGroups) {
|
||||
group.iconMarker = undefined;
|
||||
if (group.members.length === 1) {
|
||||
// A lone non-keyed marker shows plainly; a lone zone occupant falls
|
||||
// through to the bubble path so it renders in a bubble at its zone.
|
||||
if (group.members.length === 1 && !group.keyed) {
|
||||
this._showMarker(group.members[0]);
|
||||
continue;
|
||||
}
|
||||
@@ -1379,8 +1373,10 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
const group = this._clusterGroups.find((candidate) =>
|
||||
candidate.members.includes(focusedMember)
|
||||
);
|
||||
if (group?.iconMarker && !group.open) {
|
||||
group.iconMarker.getElement().focus();
|
||||
if (group && !group.open) {
|
||||
// A singleton group has no bubble icon; the member shows on its own,
|
||||
// so send focus back to that member rather than to the document.
|
||||
(group.iconMarker?.getElement() ?? focusedMember.element).focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import memoizeOne from "memoize-one";
|
||||
import { getColorByIndex } from "../color/colors";
|
||||
import { computeDomain } from "../entity/compute_domain";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
|
||||
/**
|
||||
* Map colors for entities, by registry creation order, so an entity has the
|
||||
* same color on every map. The registry comes from the fullEntitiesContext;
|
||||
* entities without an entry (e.g. YAML zones) get a color derived from their
|
||||
* id.
|
||||
*/
|
||||
|
||||
export const HOME_ZONE_ENTITY_ID = "zone.home";
|
||||
|
||||
/** Domains whose entities are colored by creation order */
|
||||
const ORDERED_DOMAINS = ["zone", "person", "device_tracker"];
|
||||
|
||||
// One index per registry update, shared by every map on the page. Zones,
|
||||
// persons and trackers share one sequence, so a person never has the color
|
||||
// of the zone it is in.
|
||||
const creationIndex = memoizeOne(
|
||||
(entries: EntityRegistryEntry[]): Record<string, number> => {
|
||||
const index: Record<string, number> = {};
|
||||
entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
ORDERED_DOMAINS.includes(computeDomain(entry.entity_id)) &&
|
||||
// The home zone has a fixed color and does not take a palette slot
|
||||
entry.entity_id !== HOME_ZONE_ENTITY_ID
|
||||
)
|
||||
.sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id))
|
||||
.forEach((entry, i) => {
|
||||
index[entry.entity_id] = i;
|
||||
});
|
||||
return index;
|
||||
}
|
||||
);
|
||||
|
||||
// For entities without a registry entry
|
||||
const hashIndex = (entityId: string): number => {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < entityId.length; i++) {
|
||||
hash = (hash * 33 + entityId.charCodeAt(i)) % 2147483647;
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
/** The palette color of an entity on a map */
|
||||
export const entityMapColor = (
|
||||
entityId: string,
|
||||
entries: EntityRegistryEntry[],
|
||||
computedStyles: CSSStyleDeclaration
|
||||
): string =>
|
||||
getColorByIndex(
|
||||
creationIndex(entries)[entityId] ?? hashIndex(entityId),
|
||||
computedStyles
|
||||
);
|
||||
|
||||
/** A zone's color: primary for home, muted for passive, its entity map color otherwise */
|
||||
export const zoneColor = (
|
||||
entityId: string,
|
||||
passive: boolean,
|
||||
entries: EntityRegistryEntry[],
|
||||
computedStyles: CSSStyleDeclaration
|
||||
): string => {
|
||||
if (entityId === HOME_ZONE_ENTITY_ID) {
|
||||
return computedStyles.getPropertyValue("--primary-color");
|
||||
}
|
||||
if (passive) {
|
||||
return computedStyles.getPropertyValue("--secondary-text-color");
|
||||
}
|
||||
return entityMapColor(entityId, entries, computedStyles);
|
||||
};
|
||||
@@ -42,6 +42,15 @@ export interface MapFitOptions {
|
||||
pad?: number;
|
||||
/** Ease the camera to the bounds instead of jumping; defaults to true */
|
||||
animate?: boolean;
|
||||
/** Viewport pixels covered by overlays; the bounds fit inside the rest */
|
||||
padding?: MapFitPadding;
|
||||
}
|
||||
|
||||
export interface MapFitPadding {
|
||||
top?: number;
|
||||
right?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
}
|
||||
|
||||
export interface MapMarkerOptions {
|
||||
|
||||
@@ -16,7 +16,11 @@ export const setMarkerAccessibility = (
|
||||
): void => {
|
||||
clearMarkerAccessibility(element);
|
||||
const owned: string[] = [];
|
||||
if (title && !element.hasAttribute("aria-label")) {
|
||||
if (
|
||||
title &&
|
||||
!element.hasAttribute("aria-label") &&
|
||||
!element.hasAttribute("aria-labelledby")
|
||||
) {
|
||||
element.setAttribute("aria-label", title);
|
||||
owned.push("aria-label");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { getContrastedColorHex } from "../color/rgb";
|
||||
|
||||
/** The zone marker: a colored circle with the zone's icon or initials, shared by the map and the zone editor */
|
||||
|
||||
export const ZONE_CIRCLE_SIZE = 36;
|
||||
|
||||
// Content color contrasting the fill; not every theme color parses
|
||||
export const contrastingZoneContent = (color: string): string => {
|
||||
try {
|
||||
return getContrastedColorHex(color.trim());
|
||||
} catch {
|
||||
return "#ffffff";
|
||||
}
|
||||
};
|
||||
|
||||
export const zoneInitials = (name: string): string =>
|
||||
name
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.slice(0, 2);
|
||||
|
||||
export const createZoneMarkerElement = (options: {
|
||||
color: string;
|
||||
icon?: string;
|
||||
/** Path for an ha-svg-icon, when there is no icon name */
|
||||
iconPath?: string;
|
||||
name: string;
|
||||
}): HTMLElement => {
|
||||
const element = document.createElement("div");
|
||||
element.className = "zone-circle";
|
||||
element.style.backgroundColor = options.color;
|
||||
element.style.color = contrastingZoneContent(options.color);
|
||||
if (options.icon) {
|
||||
const icon = document.createElement("ha-icon");
|
||||
icon.setAttribute("icon", options.icon);
|
||||
element.appendChild(icon);
|
||||
} else if (options.iconPath) {
|
||||
const icon = document.createElement("ha-svg-icon");
|
||||
icon.setAttribute("path", options.iconPath);
|
||||
element.appendChild(icon);
|
||||
} else {
|
||||
const initials = document.createElement("span");
|
||||
initials.textContent = zoneInitials(options.name);
|
||||
element.appendChild(initials);
|
||||
}
|
||||
return element;
|
||||
};
|
||||
|
||||
/** Styles for the zone marker, included by ha-map */
|
||||
export const zoneMarkerStyles = `
|
||||
.zone-circle {
|
||||
width: ${ZONE_CIRCLE_SIZE}px;
|
||||
height: ${ZONE_CIRCLE_SIZE}px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid var(--card-background-color, #fff);
|
||||
box-sizing: border-box;
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
overflow: hidden;
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
--mdc-icon-size: ${ZONE_CIRCLE_SIZE / 2}px;
|
||||
}
|
||||
`;
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { BOTTOM_SHEET_ANIMATION_DURATION_MS } from "./ha-bottom-sheet";
|
||||
|
||||
@@ -11,39 +11,80 @@ import { BOTTOM_SHEET_ANIMATION_DURATION_MS } from "./ha-bottom-sheet";
|
||||
* the sheet by dragging the handle at the top. It supports both mouse and touch
|
||||
* interactions and automatically closes when dragged below a 20% of screen height.
|
||||
*
|
||||
* A persistent sheet never closes by dragging; it stops at its minimum height
|
||||
* instead, so a host can keep a collapsed strip of content reachable.
|
||||
*
|
||||
* @fires bottom-sheet-closed - Fired when the bottom sheet is closed
|
||||
* @fires bottom-sheet-resized - Fired with the sheet's height in pixels once
|
||||
* it has opened and whenever a drag ends
|
||||
*
|
||||
* @cssprop --ha-bottom-sheet-border-width - Border width for the sheet
|
||||
* @cssprop --ha-bottom-sheet-border-style - Border style for the sheet
|
||||
* @cssprop --ha-bottom-sheet-border-color - Border color for the sheet
|
||||
* @cssprop --ha-bottom-sheet-handle-padding - How far below the handle the
|
||||
* grab area reaches; shrink it when content sits right under the handle
|
||||
*/
|
||||
@customElement("ha-resizable-bottom-sheet")
|
||||
export class HaResizableBottomSheet extends LitElement {
|
||||
@query("dialog") private _dialog!: HTMLDialogElement;
|
||||
|
||||
/** Dragging down stops at the minimum height instead of closing the sheet */
|
||||
@property({ type: Boolean }) public persistent = false;
|
||||
|
||||
/**
|
||||
* The height in pixels the sheet cannot be dragged below, e.g. what a host
|
||||
* measures for the strip it wants to keep visible. Without it the sheet
|
||||
* stops at 20% of the viewport.
|
||||
*/
|
||||
@property({ type: Number, attribute: "min-height" })
|
||||
public minHeight?: number;
|
||||
|
||||
/**
|
||||
* The largest share of the viewport the sheet opens at, in percent. It
|
||||
* opens at its content height up to this, and can be dragged to 90 after.
|
||||
*/
|
||||
@property({ type: Number, attribute: "open-max-viewport-height" })
|
||||
public openMaxViewportHeight = 70;
|
||||
|
||||
/**
|
||||
* Whether the sheet may open smaller than 55% of the viewport, down to its
|
||||
* content height and the minimum it can be dragged to.
|
||||
*/
|
||||
@property({ type: Boolean, attribute: "open-at-content-height" })
|
||||
public openAtContentHeight = false;
|
||||
|
||||
private _dragging = false;
|
||||
|
||||
private _dragStartY = 0;
|
||||
|
||||
private _initialSize = 0;
|
||||
|
||||
@state() private _dialogMaxViewpointHeight = 70;
|
||||
private _opened = false;
|
||||
|
||||
@state() private _dialogMinViewpointHeight = 55;
|
||||
@state() private _dialogMaxViewpointHeight?: number;
|
||||
|
||||
@state() private _dialogMinViewpointHeight?: number;
|
||||
|
||||
@state() private _dialogViewportHeight?: number;
|
||||
|
||||
render() {
|
||||
// Until it has opened, the sheet sizes to its content within the opening
|
||||
// bounds; afterwards it keeps the height it settled on or was dragged to
|
||||
const maxHeight =
|
||||
this._dialogMaxViewpointHeight ?? this.openMaxViewportHeight;
|
||||
const minHeight =
|
||||
this._dialogMinViewpointHeight ??
|
||||
(this.openAtContentHeight ? this._minViewportHeight() : 55);
|
||||
return html`<dialog
|
||||
open
|
||||
@transitionend=${this._handleTransitionEnd}
|
||||
style=${`
|
||||
--height: ${this._dialogViewportHeight}vh;
|
||||
--height: ${this._dialogViewportHeight}dvh;
|
||||
--max-height: ${this._dialogMaxViewpointHeight}vh;
|
||||
--max-height: ${this._dialogMaxViewpointHeight}dvh;
|
||||
--min-height: ${this._dialogMinViewpointHeight}vh;
|
||||
--min-height: ${this._dialogMinViewpointHeight}dvh;
|
||||
--max-height: ${maxHeight}vh;
|
||||
--max-height: ${maxHeight}dvh;
|
||||
--min-height: ${minHeight}vh;
|
||||
--min-height: ${minHeight}dvh;
|
||||
`}
|
||||
>
|
||||
<div class="handle-wrapper">
|
||||
@@ -83,7 +124,9 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
this._dialogViewportHeight =
|
||||
(this._dialog.offsetHeight / window.innerHeight) * 100;
|
||||
this._dialogMaxViewpointHeight = 90;
|
||||
this._dialogMinViewpointHeight = 20;
|
||||
this._dialogMinViewpointHeight = this._minViewportHeight();
|
||||
this._opened = true;
|
||||
this._fireResized();
|
||||
} else {
|
||||
// after close animation is done close dialog element and fire closed event
|
||||
this._dialog.close();
|
||||
@@ -102,6 +145,7 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
});
|
||||
document.addEventListener("touchend", this._handleTouchEnd);
|
||||
document.addEventListener("touchcancel", this._handleTouchEnd);
|
||||
window.addEventListener("resize", this._handleViewportResize);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@@ -113,8 +157,16 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
document.removeEventListener("touchmove", this._handleTouchMove);
|
||||
document.removeEventListener("touchend", this._handleTouchEnd);
|
||||
document.removeEventListener("touchcancel", this._handleTouchEnd);
|
||||
window.removeEventListener("resize", this._handleViewportResize);
|
||||
}
|
||||
|
||||
// minHeight is a pixel value, so its viewport-relative minimum depends on the
|
||||
// window height; recompute it when the viewport changes (for example on
|
||||
// rotation) so the cached percentage cannot clip the persistent peek content.
|
||||
private _handleViewportResize = () => {
|
||||
this._applyMinViewportHeight();
|
||||
};
|
||||
|
||||
private _handleMouseDown = (ev: MouseEvent) => {
|
||||
this._startDrag(ev.clientY);
|
||||
};
|
||||
@@ -156,8 +208,10 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
let newSize = this._initialSize + deltaVh;
|
||||
newSize = Math.max(10, Math.min(90, newSize));
|
||||
|
||||
// on drag down and below 20vh
|
||||
if (newSize < 20 && deltaY < 0) {
|
||||
if (this.persistent) {
|
||||
newSize = Math.max(this._minViewportHeight(), newSize);
|
||||
} else if (newSize < 20 && deltaY < 0) {
|
||||
// on drag down and below 20vh
|
||||
this._endDrag();
|
||||
this.closeSheet();
|
||||
return;
|
||||
@@ -166,6 +220,45 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
this._dialogViewportHeight = newSize;
|
||||
}
|
||||
|
||||
private _minViewportHeight(): number {
|
||||
return this.minHeight === undefined
|
||||
? 20
|
||||
: (this.minHeight / window.innerHeight) * 100;
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues<this>) {
|
||||
super.updated(changedProperties);
|
||||
// A minimum set after opening applies right away
|
||||
if (changedProperties.has("minHeight") && this._opened) {
|
||||
this._applyMinViewportHeight();
|
||||
}
|
||||
}
|
||||
|
||||
// Applies the current pixel minimum as a viewport percentage and, once it has
|
||||
// rendered, reports the size if it changed: raising the minimum can grow a
|
||||
// sheet resting at the old one, and the host needs the new height to keep its
|
||||
// overlay padding in step.
|
||||
private _applyMinViewportHeight() {
|
||||
if (!this._opened) {
|
||||
return;
|
||||
}
|
||||
this._dialogMinViewpointHeight = this._minViewportHeight();
|
||||
this.updateComplete.then(() => {
|
||||
if (this._dialog.offsetHeight !== this._lastReportedHeight) {
|
||||
this._fireResized();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _lastReportedHeight?: number;
|
||||
|
||||
private _fireResized() {
|
||||
this._lastReportedHeight = this._dialog.offsetHeight;
|
||||
fireEvent(this, "bottom-sheet-resized", {
|
||||
height: this._lastReportedHeight,
|
||||
});
|
||||
}
|
||||
|
||||
private _handleMouseUp = () => {
|
||||
this._endDrag();
|
||||
};
|
||||
@@ -180,6 +273,8 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
}
|
||||
this._dragging = false;
|
||||
document.body.style.removeProperty("cursor");
|
||||
// Hosts lay out around the sheet once, not on every move
|
||||
this.updateComplete.then(() => this._fireResized());
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
@@ -201,7 +296,8 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 7;
|
||||
padding-bottom: 76px;
|
||||
/* Extends the grab area over the content below the handle */
|
||||
padding-bottom: var(--ha-bottom-sheet-handle-padding, 76px);
|
||||
}
|
||||
.handle-wrapper .handle::after {
|
||||
content: "";
|
||||
@@ -280,5 +376,6 @@ declare global {
|
||||
|
||||
interface HASSDomEvents {
|
||||
"bottom-sheet-closed": undefined;
|
||||
"bottom-sheet-resized": { height: number };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +24,13 @@ class HaEntityMarker extends LitElement {
|
||||
|
||||
@property({ attribute: "show-icon", type: Boolean }) public showIcon = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public selected = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div
|
||||
class="marker ${this.entityPicture ? "picture" : ""}"
|
||||
style=${styleMap({ "border-color": this.entityColor })}
|
||||
style=${styleMap({ "--ha-marker-selected-color": this.entityColor })}
|
||||
@click=${this._badgeTap}
|
||||
>
|
||||
${
|
||||
@@ -94,13 +96,22 @@ class HaEntityMarker extends LitElement {
|
||||
height: var(--ha-marker-size, 48px);
|
||||
font-size: var(--ha-marker-font-size, var(--ha-font-size-xl));
|
||||
border-radius: var(--ha-marker-border-radius, 50%);
|
||||
border: 1px solid var(--ha-marker-color, var(--primary-color));
|
||||
border: var(--ha-marker-border-width, 3px) solid
|
||||
var(--ha-marker-color, var(--card-background-color, #fff));
|
||||
box-shadow: var(--ha-marker-shadow, var(--ha-box-shadow-s));
|
||||
color: var(--primary-text-color);
|
||||
background-color: var(--card-background-color);
|
||||
background-color: var(
|
||||
--ha-marker-background,
|
||||
var(--card-background-color)
|
||||
);
|
||||
}
|
||||
.marker.picture {
|
||||
overflow: hidden;
|
||||
}
|
||||
/* A ring in the entity color outside the frame marks the selected marker */
|
||||
:host([selected]) .marker {
|
||||
outline: 3px solid var(--ha-marker-selected-color, var(--primary-color));
|
||||
}
|
||||
.entity-picture {
|
||||
background-size: cover;
|
||||
height: 100%;
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { consume } from "@lit/context";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { MAP_MAX_ZOOM } from "../../common/map/base-layer";
|
||||
import type { MapLatLng } from "../../common/map/map-engine";
|
||||
import { circleBoundsPoints } from "../../common/map/map-engine";
|
||||
import { internationalizationContext } from "../../data/context";
|
||||
import { internationalizationContext, uiContext } from "../../data/context";
|
||||
import type { Themes } from "../../data/ws-themes";
|
||||
import type { HomeAssistantInternationalization, ThemeMode } from "../../types";
|
||||
import "../ha-alert";
|
||||
import "../ha-input-helper-text";
|
||||
@@ -16,6 +19,10 @@ import "./ha-map";
|
||||
import type { HaMap, HaMapEditableLocation } from "./ha-map";
|
||||
import type { HaIcon } from "../ha-icon";
|
||||
import type { HaSvgIcon } from "../ha-svg-icon";
|
||||
import {
|
||||
createZoneMarkerElement,
|
||||
ZONE_CIRCLE_SIZE,
|
||||
} from "../../common/map/zone-marker";
|
||||
|
||||
declare global {
|
||||
// for fire event
|
||||
@@ -68,6 +75,15 @@ export class HaLocationsEditor extends LitElement {
|
||||
|
||||
@state() private _editingAvailable = true;
|
||||
|
||||
// Marker elements bake in theme colors, so they are rebuilt on a theme
|
||||
// change; narrow the UI context to themes to avoid unrelated rerenders
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
@transform<ContextType<typeof uiContext>, Themes>({
|
||||
transformer: ({ themes }) => themes,
|
||||
})
|
||||
private _themes?: Themes;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: HomeAssistantInternationalization;
|
||||
@@ -108,7 +124,10 @@ export class HaLocationsEditor extends LitElement {
|
||||
return html`
|
||||
<div class="map">
|
||||
<ha-map
|
||||
.editableLocations=${this._editableLocations(this.locations)}
|
||||
.editableLocations=${this._editableLocations(
|
||||
this.locations,
|
||||
this._themes
|
||||
)}
|
||||
.zoom=${this.zoom}
|
||||
.autoFit=${this.autoFit}
|
||||
.themeMode=${this.themeMode}
|
||||
@@ -140,7 +159,14 @@ export class HaLocationsEditor extends LitElement {
|
||||
}
|
||||
|
||||
private _editableLocations = memoizeOne(
|
||||
(locations?: MarkerLocation[]): HaMapEditableLocation[] => {
|
||||
(
|
||||
locations: MarkerLocation[] | undefined,
|
||||
themes: Themes | undefined
|
||||
): HaMapEditableLocation[] => {
|
||||
if (themes !== this._elementsThemes) {
|
||||
this._elementsThemes = themes;
|
||||
this._elements.clear();
|
||||
}
|
||||
const ids = new Set((locations ?? []).map((location) => location.id));
|
||||
for (const id of this._elements.keys()) {
|
||||
if (!ids.has(id)) {
|
||||
@@ -152,7 +178,9 @@ export class HaLocationsEditor extends LitElement {
|
||||
location: [location.latitude, location.longitude],
|
||||
radius: location.radius,
|
||||
element: this._elementFor(location),
|
||||
elementSize: [ICON_SIZE, ICON_SIZE],
|
||||
elementSize: location.radius
|
||||
? [ZONE_CIRCLE_SIZE, ZONE_CIRCLE_SIZE]
|
||||
: [ICON_SIZE, ICON_SIZE],
|
||||
title: location.name,
|
||||
color: location.radius_color,
|
||||
locationEditable: location.location_editable,
|
||||
@@ -165,30 +193,46 @@ export class HaLocationsEditor extends LitElement {
|
||||
// Reused while unchanged, so ha-map moves markers instead of rebuilding them
|
||||
private _elements = new Map<string, { key: string; element?: HTMLElement }>();
|
||||
|
||||
private _elementsThemes?: Themes;
|
||||
|
||||
private _elementFor(location: MarkerLocation): HTMLElement | undefined {
|
||||
const isZone = !!location.radius;
|
||||
const key = JSON.stringify([
|
||||
isZone,
|
||||
location.icon,
|
||||
location.iconPath,
|
||||
location.name,
|
||||
location.location_editable,
|
||||
location.radius_color,
|
||||
]);
|
||||
const cached = this._elements.get(location.id);
|
||||
if (cached?.key === key) {
|
||||
return cached.element;
|
||||
}
|
||||
const element = this._createIcon(location);
|
||||
// The zone marker doubles as the circle's draggable center
|
||||
const element = isZone
|
||||
? this._createZoneMarker(location)
|
||||
: this._createIcon(location);
|
||||
this._elements.set(location.id, { key, element });
|
||||
return element;
|
||||
}
|
||||
|
||||
private _createZoneMarker(location: MarkerLocation): HTMLElement {
|
||||
return createZoneMarkerElement({
|
||||
color:
|
||||
location.radius_color ||
|
||||
getComputedStyle(this).getPropertyValue("--accent-color"),
|
||||
icon: location.icon,
|
||||
iconPath: location.iconPath,
|
||||
name: location.name ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
private _createIcon(location: MarkerLocation): HTMLElement | undefined {
|
||||
if (!location.icon && !location.iconPath) {
|
||||
return undefined;
|
||||
}
|
||||
const el = document.createElement("div");
|
||||
el.className = `named-icon ${
|
||||
location.location_editable ? "draggable" : ""
|
||||
}`;
|
||||
el.className = "named-icon";
|
||||
if (location.name !== undefined) {
|
||||
el.innerText = location.name;
|
||||
}
|
||||
@@ -296,12 +340,13 @@ export class HaLocationsEditor extends LitElement {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
/* Over the map, clear of the zoom control, so a fixed-height host shows it */
|
||||
/* Over the map, clear of the zoom control, so a fixed-height host shows it.
|
||||
The control sits at the physical top-left in either direction. */
|
||||
ha-alert {
|
||||
position: absolute;
|
||||
top: var(--ha-space-2);
|
||||
inset-inline-start: 56px;
|
||||
inset-inline-end: var(--ha-space-2);
|
||||
left: 56px;
|
||||
right: var(--ha-space-2);
|
||||
z-index: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
+358
-71
@@ -1,4 +1,4 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { consume, ContextConsumer } from "@lit/context";
|
||||
import { isToday } from "date-fns";
|
||||
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
@@ -18,7 +18,9 @@ import { getEntityLocation } from "../../common/entity/get_entity_location";
|
||||
import { supportsWebGL2 } from "../../common/map/base-layer";
|
||||
import type {
|
||||
MapClusterIcon,
|
||||
MapControlPosition,
|
||||
MapEngine,
|
||||
MapFitPadding,
|
||||
MapItemHandle,
|
||||
MapLatLng,
|
||||
MapMarkerHandle,
|
||||
@@ -31,15 +33,24 @@ import type {
|
||||
} from "../../common/map/map-engine";
|
||||
import { circleBoundsPoints } from "../../common/map/map-engine";
|
||||
import { editableCircleStyles } from "../../common/map/editable-circle";
|
||||
import { entityMapColor, zoneColor } from "../../common/map/entity-map-colors";
|
||||
import {
|
||||
createZoneMarkerElement,
|
||||
ZONE_CIRCLE_SIZE,
|
||||
zoneMarkerStyles,
|
||||
} from "../../common/map/zone-marker";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import {
|
||||
configContext,
|
||||
connectionContext,
|
||||
formattersContext,
|
||||
fullEntitiesContext,
|
||||
internationalizationContext,
|
||||
statesContext,
|
||||
uiContext,
|
||||
} from "../../data/context";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import { ensureMapTilesToken } from "../../data/map_tiles";
|
||||
import type {
|
||||
HomeAssistantConfig,
|
||||
@@ -211,9 +222,37 @@ export interface HaMapEntity {
|
||||
unit?: string;
|
||||
name?: string;
|
||||
focus?: boolean;
|
||||
hide_accuracy?: boolean;
|
||||
hide_radius?: boolean;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
// Data carried by entity markers for rendering cluster bubbles
|
||||
interface ClusterData {
|
||||
entityId: string;
|
||||
picture?: string;
|
||||
label: string;
|
||||
showIcon: boolean;
|
||||
unit: string;
|
||||
color?: string;
|
||||
selected: boolean;
|
||||
zoneId?: string;
|
||||
}
|
||||
|
||||
const CLUSTER_AVATAR_SIZE = 32;
|
||||
const CLUSTER_BUBBLE_PADDING = 6;
|
||||
const CLUSTER_BUBBLE_GAP = 4;
|
||||
const CLUSTER_MAX_AVATARS = 3;
|
||||
const CLUSTER_MORE_WIDTH = 28;
|
||||
const CLUSTER_MORE_MAX = 99;
|
||||
const CLUSTER_TAIL_SIZE = 10;
|
||||
// The tail is a rotated square on the bubble's bottom edge, reaching half its diagonal below
|
||||
const CLUSTER_TAIL_HEIGHT = Math.round((CLUSTER_TAIL_SIZE * Math.SQRT2) / 2);
|
||||
const CLUSTER_ZONE_SPACING = 2;
|
||||
const CLUSTER_RADIUS = 40;
|
||||
// Same-zone markers share the zone's bubble while they span at most this many
|
||||
// pixels; further apart they show their actual positions
|
||||
const ZONE_GROUP_RADIUS = 160;
|
||||
|
||||
@customElement("ha-map")
|
||||
export class HaMap extends ReactiveElement {
|
||||
@@ -264,6 +303,14 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
@property({ attribute: "fit-zones", type: Boolean }) public fitZones = false;
|
||||
|
||||
/** Part of the map an overlay covers; automatic fits keep clear of it */
|
||||
@property({ attribute: false }) public fitPadding?: MapFitPadding;
|
||||
|
||||
@property({ attribute: "zoom-position" })
|
||||
public zoomPosition: MapControlPosition = "topleft";
|
||||
|
||||
private _zonePositions: Record<string, MapLatLng> = {};
|
||||
|
||||
@property({ attribute: "theme-mode", type: String })
|
||||
public themeMode: ThemeMode = "auto";
|
||||
|
||||
@@ -296,6 +343,11 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
private _resizeObserver?: ResizeObserver;
|
||||
|
||||
// Registry creation order decides the palette colors
|
||||
@state() private _entityReg: EntityRegistryEntry[] = [];
|
||||
|
||||
private _registryConsumer?: ContextConsumer<typeof fullEntitiesContext, this>;
|
||||
|
||||
private _entityHandles: MapMarkerHandle[] = [];
|
||||
|
||||
private _zoneHandles: MapItemHandle[] = [];
|
||||
@@ -323,6 +375,21 @@ export class HaMap extends ReactiveElement {
|
||||
this._attachObserver();
|
||||
}
|
||||
|
||||
// Only maps that draw entities ask for the registry; an editor's map, as in
|
||||
// onboarding, never does. The context provider shares one subscription.
|
||||
private _watchRegistry(): void {
|
||||
if (this._registryConsumer || !this.entities?.length) {
|
||||
return;
|
||||
}
|
||||
this._registryConsumer = new ContextConsumer(this, {
|
||||
context: fullEntitiesContext,
|
||||
subscribe: true,
|
||||
callback: (entries) => {
|
||||
this._entityReg = entries;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _handleVisibilityChange = async () => {
|
||||
if (!document.hidden) {
|
||||
setTimeout(() => {
|
||||
@@ -385,10 +452,22 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProps.has("clusterMarkers")) {
|
||||
if (changedProps.has("clusterMarkers") || changedProps.has("_entityReg")) {
|
||||
this._drawEntities();
|
||||
}
|
||||
|
||||
// An overlay that grew or shrank may cover the fitted markers
|
||||
if (
|
||||
changedProps.has("fitPadding") &&
|
||||
!deepEqual(changedProps.get("fitPadding"), this.fitPadding)
|
||||
) {
|
||||
autoFitRequired = !this._pauseAutoFit;
|
||||
}
|
||||
|
||||
if (changedProps.has("zoomPosition")) {
|
||||
this._engine?.setZoomControlPosition(this.zoomPosition);
|
||||
}
|
||||
|
||||
const oldConfig = changedProps.get("_config") as HassConfig | undefined;
|
||||
if (
|
||||
changedProps.has("_loaded") ||
|
||||
@@ -401,6 +480,11 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
if (changedProps.has("_loaded") || changedProps.has("paths")) {
|
||||
this._drawPaths();
|
||||
// Cluster bubbles show entity colors only while trails are visible
|
||||
const oldPaths = changedProps.get("paths") as HaMapPaths[] | undefined;
|
||||
if (!!oldPaths?.length !== !!this.paths?.length) {
|
||||
this._engine?.refreshClusters();
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProps.has("_loaded") || changedProps.has("editableLocations")) {
|
||||
@@ -433,13 +517,19 @@ export class HaMap extends ReactiveElement {
|
||||
const oldUi = changedProps.get("_ui") as HomeAssistantUI | undefined;
|
||||
if (
|
||||
!changedProps.has("themeMode") &&
|
||||
(!changedProps.has("_ui") ||
|
||||
(oldUi && oldUi.themes?.darkMode === this._ui.themes?.darkMode))
|
||||
(!changedProps.has("_ui") || (oldUi && oldUi.themes === this._ui.themes))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._updateMapStyle();
|
||||
// Marker, trail and circle colors were resolved from the theme when drawn
|
||||
this._drawEntities();
|
||||
this._drawPaths();
|
||||
if (this._editableHandles.size) {
|
||||
this._removeEditableLocations();
|
||||
this._drawEditableLocations();
|
||||
}
|
||||
}
|
||||
|
||||
private get _darkMode() {
|
||||
@@ -528,7 +618,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: () => {
|
||||
@@ -623,6 +713,7 @@ export class HaMap extends ReactiveElement {
|
||||
public fitMap(options?: {
|
||||
zoom?: number;
|
||||
pad?: number;
|
||||
padding?: MapFitPadding;
|
||||
unpause_autofit?: boolean;
|
||||
}): void {
|
||||
if (options?.unpause_autofit) {
|
||||
@@ -666,6 +757,7 @@ export class HaMap extends ReactiveElement {
|
||||
this._engine!.fitBounds(points, {
|
||||
maxZoom: options?.zoom || this.zoom,
|
||||
pad: options?.pad ?? 0.5,
|
||||
padding: options?.padding ?? this.fitPadding,
|
||||
animate: this._hasFitted,
|
||||
});
|
||||
});
|
||||
@@ -713,8 +805,11 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
public fitBounds(
|
||||
boundingbox: MapLatLng[],
|
||||
options?: { zoom?: number; pad?: number }
|
||||
options?: { zoom?: number; pad?: number; padding?: MapFitPadding }
|
||||
) {
|
||||
// An explicit fit is user intent, even while it waits for the engine or
|
||||
// a size; an auto-fit must not take its place in the meantime
|
||||
this._pauseAutoFit = true;
|
||||
if (!this._engine) {
|
||||
// Engine still loading (see _loadMap); runs once it is
|
||||
this._pendingFit = () => this.fitBounds(boundingbox, options);
|
||||
@@ -728,6 +823,7 @@ export class HaMap extends ReactiveElement {
|
||||
maxZoom: options?.zoom || this.zoom,
|
||||
pad: options?.pad ?? 0.5,
|
||||
animate: this._hasFitted,
|
||||
padding: options?.padding,
|
||||
});
|
||||
});
|
||||
this._hasFitted = true;
|
||||
@@ -1031,16 +1127,36 @@ export class HaMap extends ReactiveElement {
|
||||
engine.setClustering(null);
|
||||
return;
|
||||
}
|
||||
this._watchRegistry();
|
||||
|
||||
const computedStyles = getComputedStyle(this);
|
||||
const zoneColor = computedStyles.getPropertyValue("--accent-color");
|
||||
const passiveZoneColor = computedStyles.getPropertyValue(
|
||||
"--secondary-text-color"
|
||||
);
|
||||
|
||||
const darkPrimaryColor = computedStyles.getPropertyValue(
|
||||
"--dark-primary-color"
|
||||
);
|
||||
// A person's state is "home" for the home zone, the zone name otherwise
|
||||
const zoneByState: Record<string, string> = {};
|
||||
this._zonePositions = {};
|
||||
for (const entity of this.entities) {
|
||||
const stateObj = states[getEntityId(entity)];
|
||||
// A zone that is not drawn cannot anchor a bubble either
|
||||
if (
|
||||
stateObj &&
|
||||
computeStateDomain(stateObj) === "zone" &&
|
||||
(this.renderPassive || !stateObj.attributes.passive)
|
||||
) {
|
||||
zoneByState[
|
||||
stateObj.entity_id === "zone.home"
|
||||
? "home"
|
||||
: computeStateName(stateObj)
|
||||
] = stateObj.entity_id;
|
||||
if (
|
||||
typeof stateObj.attributes.latitude === "number" &&
|
||||
typeof stateObj.attributes.longitude === "number"
|
||||
) {
|
||||
this._zonePositions[stateObj.entity_id] = [
|
||||
stateObj.attributes.latitude,
|
||||
stateObj.attributes.longitude,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entity of this.entities) {
|
||||
const stateObj = states[getEntityId(entity)];
|
||||
@@ -1069,26 +1185,29 @@ export class HaMap extends ReactiveElement {
|
||||
continue;
|
||||
}
|
||||
|
||||
const zoneMarkerColor = passive ? passiveZoneColor : zoneColor;
|
||||
const hideRadius = typeof entity !== "string" && entity.hide_radius;
|
||||
// A host-set color wins, except passive zones are always muted
|
||||
const markerColor =
|
||||
!passive && typeof entity !== "string" && entity.color
|
||||
? entity.color
|
||||
: zoneColor(
|
||||
stateObj.entity_id,
|
||||
!!passive,
|
||||
this._entityReg,
|
||||
computedStyles
|
||||
);
|
||||
|
||||
if (radius) {
|
||||
if (!hideRadius && radius) {
|
||||
this._zoneHandles.push(
|
||||
engine.addCircle(position, { radius, color: zoneMarkerColor })
|
||||
engine.addCircle(position, { radius, color: markerColor })
|
||||
);
|
||||
}
|
||||
|
||||
// create icon
|
||||
const iconEl = document.createElement("div");
|
||||
iconEl.className = `zone-icon ${this._darkMode ? "dark" : "light"}`;
|
||||
if (icon) {
|
||||
const el = document.createElement("ha-icon");
|
||||
el.setAttribute("icon", icon);
|
||||
iconEl.appendChild(el);
|
||||
} else {
|
||||
const el = document.createElement("span");
|
||||
el.textContent = title;
|
||||
iconEl.appendChild(el);
|
||||
}
|
||||
const circleEl = createZoneMarkerElement({
|
||||
color: markerColor,
|
||||
icon,
|
||||
name: title,
|
||||
});
|
||||
|
||||
if (this.interactiveZones) {
|
||||
const openMoreInfo = (ev: Event) => {
|
||||
@@ -1097,8 +1216,8 @@ export class HaMap extends ReactiveElement {
|
||||
entityId: stateObj.entity_id,
|
||||
});
|
||||
};
|
||||
iconEl.addEventListener("click", openMoreInfo);
|
||||
iconEl.addEventListener("keydown", (ev) => {
|
||||
circleEl.addEventListener("click", openMoreInfo);
|
||||
circleEl.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
openMoreInfo(ev);
|
||||
@@ -1106,10 +1225,9 @@ export class HaMap extends ReactiveElement {
|
||||
});
|
||||
}
|
||||
|
||||
const zoneIconSize = this._getMarkerSize(computedStyles) / 2;
|
||||
this._zoneHandles.push(
|
||||
engine.addMarker(iconEl, position, {
|
||||
size: [zoneIconSize, zoneIconSize],
|
||||
engine.addMarker(circleEl, position, {
|
||||
size: [ZONE_CIRCLE_SIZE, ZONE_CIRCLE_SIZE],
|
||||
interactive: this.interactiveZones,
|
||||
title,
|
||||
})
|
||||
@@ -1119,7 +1237,7 @@ export class HaMap extends ReactiveElement {
|
||||
this.fitZones &&
|
||||
(typeof entity === "string" || entity.focus !== false)
|
||||
) {
|
||||
if (radius) {
|
||||
if (!hideRadius && radius) {
|
||||
this._focusZonePoints.push(...circleBoundsPoints(position, radius));
|
||||
} else {
|
||||
this._focusZonePoints.push(position);
|
||||
@@ -1163,19 +1281,42 @@ export class HaMap extends ReactiveElement {
|
||||
entityPicture && (typeof entity === "string" || !entity.label_mode)
|
||||
? this._connection.hassUrl(entityPicture)
|
||||
: "";
|
||||
// A host may leave the color to the map
|
||||
const entityColor =
|
||||
(typeof entity !== "string" ? entity.color : undefined) ||
|
||||
entityMapColor(getEntityId(entity), this._entityReg, computedStyles);
|
||||
entityMarker.entityColor = entityColor;
|
||||
if (typeof entity !== "string") {
|
||||
entityMarker.entityColor = entity.color;
|
||||
entityMarker.selected = entity.selected ?? false;
|
||||
}
|
||||
|
||||
const clusterData: ClusterData = {
|
||||
entityId: getEntityId(entity),
|
||||
picture: entityMarker.entityPicture || undefined,
|
||||
label: entityName,
|
||||
showIcon: entityMarker.showIcon,
|
||||
unit: entityMarker.entityUnit ?? "",
|
||||
color: entityColor,
|
||||
selected: typeof entity !== "string" && (entity.selected ?? false),
|
||||
zoneId: ["person", "device_tracker"].includes(
|
||||
computeStateDomain(stateObj)
|
||||
)
|
||||
? zoneByState[stateObj.state]
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const showAccuracy =
|
||||
!!gpsAccuracy && !(typeof entity !== "string" && entity.hide_accuracy);
|
||||
|
||||
const markerSize = this._getMarkerSize(computedStyles);
|
||||
this._entityHandles.push(
|
||||
engine.addMarker(entityMarker, position, {
|
||||
size: [markerSize, markerSize],
|
||||
title,
|
||||
cluster: true,
|
||||
// create circle around if entity has accuracy
|
||||
decoration: gpsAccuracy
|
||||
? { radius: gpsAccuracy, color: darkPrimaryColor }
|
||||
clusterData,
|
||||
decoration: showAccuracy
|
||||
? { radius: gpsAccuracy!, color: entityColor }
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
@@ -1187,22 +1328,97 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
engine.setClustering(
|
||||
this.clusterMarkers
|
||||
? { radius: CLUSTER_RADIUS, iconBuilder: this._createClusterIcon }
|
||||
? {
|
||||
radius: CLUSTER_RADIUS,
|
||||
iconBuilder: this._createClusterBubble,
|
||||
// Everyone in a zone shares its bubble until zooming spreads them
|
||||
groupKey: (marker) => (marker.clusterData as ClusterData)?.zoneId,
|
||||
groupRadius: ZONE_GROUP_RADIUS,
|
||||
}
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
// Renders a marker cluster as a circle with the member count
|
||||
private _createClusterIcon = (members: MapMarkerHandle[]): MapClusterIcon => {
|
||||
const size = Math.round(
|
||||
this._getMarkerSize(getComputedStyle(this)) * (2 / 3)
|
||||
);
|
||||
const element = document.createElement("div");
|
||||
element.className = "marker-cluster";
|
||||
const count = document.createElement("span");
|
||||
count.textContent = String(members.length);
|
||||
element.appendChild(count);
|
||||
return { element, size: [size, size] };
|
||||
// Renders a marker cluster as a bubble of its members' avatars
|
||||
private _createClusterBubble = (
|
||||
members: MapMarkerHandle[]
|
||||
): MapClusterIcon => {
|
||||
const data = members.map((member) => member.clusterData as ClusterData);
|
||||
const shown = data.slice(0, CLUSTER_MAX_AVATARS);
|
||||
const hidden = data.length - shown.length;
|
||||
|
||||
// With history trails shown, colored borders match avatars to trails
|
||||
const showColors = !!this.paths?.length;
|
||||
|
||||
const bubble = document.createElement("div");
|
||||
bubble.className = "cluster-bubble";
|
||||
for (const member of shown) {
|
||||
const avatar = document.createElement("ha-entity-marker");
|
||||
avatar.entityId = member?.entityId;
|
||||
avatar.entityName = member?.label ?? "";
|
||||
avatar.entityUnit = member?.unit ?? "";
|
||||
avatar.showIcon = member?.showIcon ?? false;
|
||||
avatar.entityPicture = member?.picture ?? "";
|
||||
avatar.entityColor = member?.color;
|
||||
if (showColors) {
|
||||
avatar.style.setProperty(
|
||||
"--ha-marker-color",
|
||||
member?.color ?? "var(--primary-color)"
|
||||
);
|
||||
avatar.style.setProperty("--ha-marker-border-width", "2px");
|
||||
}
|
||||
if (member?.selected) {
|
||||
avatar.selected = true;
|
||||
}
|
||||
bubble.appendChild(avatar);
|
||||
}
|
||||
|
||||
let width =
|
||||
shown.length * CLUSTER_AVATAR_SIZE +
|
||||
(shown.length - 1) * CLUSTER_BUBBLE_GAP +
|
||||
2 * CLUSTER_BUBBLE_PADDING;
|
||||
if (hidden > 0) {
|
||||
const more = document.createElement("span");
|
||||
more.className = "more";
|
||||
more.textContent =
|
||||
hidden > CLUSTER_MORE_MAX ? `${CLUSTER_MORE_MAX}+` : `+${hidden}`;
|
||||
bubble.appendChild(more);
|
||||
width += CLUSTER_MORE_WIDTH + CLUSTER_BUBBLE_GAP;
|
||||
}
|
||||
|
||||
// A cluster of one zone's occupants attaches to that zone's marker
|
||||
const zoneId = data[0]?.zoneId;
|
||||
const zonePosition = zoneId ? this._zonePositions[zoneId] : undefined;
|
||||
const atZone =
|
||||
!!zoneId &&
|
||||
!!zonePosition &&
|
||||
data.every((member) => member?.zoneId === zoneId);
|
||||
|
||||
let height = CLUSTER_AVATAR_SIZE + 2 * CLUSTER_BUBBLE_PADDING;
|
||||
let root: HTMLElement = bubble;
|
||||
if (atZone) {
|
||||
root = document.createElement("div");
|
||||
root.className = "cluster-marker";
|
||||
const tail = document.createElement("div");
|
||||
tail.className = "cluster-bubble-tail";
|
||||
root.append(bubble, tail);
|
||||
height += CLUSTER_TAIL_HEIGHT;
|
||||
}
|
||||
|
||||
return {
|
||||
element: root,
|
||||
size: [width, height],
|
||||
// Float above the zone circle, tail pointing at it
|
||||
...(atZone && zonePosition
|
||||
? {
|
||||
location: zonePosition,
|
||||
anchor: [
|
||||
width / 2,
|
||||
height + ZONE_CIRCLE_SIZE / 2 + CLUSTER_ZONE_SPACING,
|
||||
] as [number, number],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
private _drawScaleRuler(): void {
|
||||
@@ -1237,12 +1453,19 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
#map {
|
||||
height: 100%;
|
||||
/* A cluster bubble and its tail cast a single shadow around their
|
||||
combined silhouette (drop-shadow on the wrapper), so no shadow seam
|
||||
appears between the bubble and its tail. */
|
||||
--ha-cluster-shadow: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.08))
|
||||
drop-shadow(0 1px 3px rgba(0, 0, 0, 0.12));
|
||||
}
|
||||
#map.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
#map.dark {
|
||||
background: #090909;
|
||||
--ha-cluster-shadow: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.4))
|
||||
drop-shadow(0 1px 3px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
#map.forced-dark {
|
||||
color: #ffffff;
|
||||
@@ -1263,6 +1486,8 @@ export class HaMap extends ReactiveElement {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
isolation: isolate;
|
||||
filter: var(--ha-cluster-shadow);
|
||||
}
|
||||
.cluster-open-members {
|
||||
display: flex;
|
||||
@@ -1274,7 +1499,13 @@ export class HaMap extends ReactiveElement {
|
||||
max-width: calc(6 * var(--ha-marker-size, 48px) + 5 * 4px + 12px);
|
||||
background: var(--card-background-color, #fff);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
}
|
||||
/* Both tails are a rotated square whose upper half sits under the bubble;
|
||||
drawn behind it, so it never covers a member's frame or selected ring */
|
||||
.cluster-open-tail,
|
||||
.cluster-bubble-tail {
|
||||
position: relative;
|
||||
z-index: -1;
|
||||
}
|
||||
.cluster-open-tail {
|
||||
width: 10px;
|
||||
@@ -1301,6 +1532,17 @@ 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);
|
||||
}
|
||||
.maplibregl-ctrl-bottom-left {
|
||||
margin-left: var(--ha-map-left-inset, 0);
|
||||
}
|
||||
.maplibregl-ctrl-bottom-right {
|
||||
margin-right: var(--ha-map-right-inset, 0);
|
||||
}
|
||||
.dark .maplibregl-ctrl.maplibregl-ctrl-group {
|
||||
background-color: #1c1c1c;
|
||||
}
|
||||
@@ -1349,16 +1591,74 @@ export class HaMap extends ReactiveElement {
|
||||
.leaflet-pane {
|
||||
z-index: 0 !important;
|
||||
}
|
||||
/* Zone icons, sized like the Leaflet divIcon they replaced */
|
||||
.zone-icon {
|
||||
.cluster-marker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
isolation: isolate;
|
||||
filter: var(--ha-cluster-shadow);
|
||||
}
|
||||
/* The wrapper carries the shadow around the bubble-plus-tail outline, so
|
||||
the bubble itself drops its own to avoid a seam at the tail. */
|
||||
.cluster-marker .cluster-bubble {
|
||||
filter: none;
|
||||
}
|
||||
.cluster-bubble-tail {
|
||||
width: ${CLUSTER_TAIL_SIZE}px;
|
||||
height: ${CLUSTER_TAIL_SIZE}px;
|
||||
margin-top: ${-CLUSTER_TAIL_SIZE / 2}px;
|
||||
border-radius: 2px;
|
||||
background: var(--card-background-color, #fff);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.cluster-bubble {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${CLUSTER_BUBBLE_GAP}px;
|
||||
padding: ${CLUSTER_BUBBLE_PADDING}px;
|
||||
box-sizing: border-box;
|
||||
background: var(--card-background-color, #fff);
|
||||
border-radius: 14px;
|
||||
filter: var(--ha-cluster-shadow);
|
||||
--ha-marker-size: ${CLUSTER_AVATAR_SIZE}px;
|
||||
--ha-marker-color: transparent;
|
||||
--ha-marker-border-width: 1px;
|
||||
--ha-marker-shadow: none;
|
||||
--ha-marker-font-size: var(--ha-font-size-s);
|
||||
/* distinguish letter tiles from the bubble background */
|
||||
--ha-marker-background: var(--ha-color-fill-neutral-quiet-resting);
|
||||
}
|
||||
.cluster-bubble .more {
|
||||
flex: none;
|
||||
width: ${CLUSTER_MORE_WIDTH}px;
|
||||
height: ${CLUSTER_AVATAR_SIZE}px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--primary-text-color);
|
||||
border-radius: 10px;
|
||||
background: var(--ha-color-fill-neutral-quiet-resting, #f0f0f0);
|
||||
color: var(--primary-text-color, #212121);
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
.zone-icon.dark {
|
||||
color: #ffffff;
|
||||
/* Markers are rounded squares to match the cluster bubble avatars */
|
||||
ha-entity-marker {
|
||||
--ha-marker-border-radius: var(--ha-border-radius-lg);
|
||||
}
|
||||
.cluster-bubble ha-entity-marker {
|
||||
flex: none;
|
||||
--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-bottom.leaflet-left {
|
||||
margin-left: var(--ha-map-left-inset, 0);
|
||||
}
|
||||
.leaflet-bottom.leaflet-right {
|
||||
margin-right: var(--ha-map-right-inset, 0);
|
||||
}
|
||||
.leaflet-control,
|
||||
.leaflet-top,
|
||||
@@ -1410,19 +1710,6 @@ export class HaMap extends ReactiveElement {
|
||||
ha-icon {
|
||||
--mdc-icon-size: calc(var(--ha-marker-size, 48px) / 2);
|
||||
}
|
||||
|
||||
.marker-cluster {
|
||||
box-sizing: border-box;
|
||||
background-clip: padding-box;
|
||||
background-color: var(--primary-color);
|
||||
border: 3px solid rgba(var(--rgb-primary-color), 0.2);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-primary-color);
|
||||
font-size: var(--ha-font-size-m);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -761,9 +761,9 @@ export class HatScriptGraph extends LitElement {
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
--stroke-clr: var(--stroke-color, var(--secondary-text-color));
|
||||
--active-clr: var(--active-color, var(--primary-color));
|
||||
--track-clr: var(--track-color, var(--accent-color));
|
||||
@@ -782,9 +782,10 @@ export class HatScriptGraph extends LitElement {
|
||||
--hat-graph-branch-height: ${BRANCH_HEIGHT}px;
|
||||
}
|
||||
.graph-scroll {
|
||||
flex: 1;
|
||||
grid-area: 1 / 1;
|
||||
overflow: auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.graph-container {
|
||||
display: flex;
|
||||
@@ -795,6 +796,19 @@ export class HatScriptGraph extends LitElement {
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-area: 1 / 1;
|
||||
justify-self: end;
|
||||
align-self: start;
|
||||
margin-top: var(--ha-space-2);
|
||||
margin-inline-end: var(--ha-space-5);
|
||||
z-index: 1;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--card-background-color) 70%,
|
||||
transparent
|
||||
);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: var(--ha-border-radius-pill);
|
||||
}
|
||||
.parent {
|
||||
margin-left: 8px;
|
||||
|
||||
+2
-2
@@ -94,14 +94,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[]
|
||||
|
||||
@@ -21,6 +21,10 @@ import { computeRTLDirection } from "../../common/util/compute_rtl";
|
||||
export class HuiNotificationDrawer extends KeyboardShortcutMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
// Reflected so the styles can apply the right safe-area inset whenever the
|
||||
// drawer spans the full viewport width, not only below a fixed breakpoint.
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
@state() private _notifications: PersistentNotification[] = [];
|
||||
|
||||
@state() public _open = false;
|
||||
@@ -48,6 +52,7 @@ export class HuiNotificationDrawer extends KeyboardShortcutMixin(LitElement) {
|
||||
}
|
||||
|
||||
showDialog({ narrow }) {
|
||||
this.narrow = narrow;
|
||||
this._unsubNotifications = subscribeNotifications(
|
||||
this.hass.connection,
|
||||
(notifications) => {
|
||||
@@ -218,11 +223,9 @@ export class HuiNotificationDrawer extends KeyboardShortcutMixin(LitElement) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
ha-header-bar {
|
||||
--header-bar-padding: var(--safe-area-inset-top, 0px)
|
||||
var(--safe-area-inset-right, 0px) 0 var(--safe-area-inset-left, 0px);
|
||||
}
|
||||
:host([narrow]) ha-header-bar {
|
||||
--header-bar-padding: var(--safe-area-inset-top, 0px)
|
||||
var(--safe-area-inset-right, 0px) 0 var(--safe-area-inset-left, 0px);
|
||||
}
|
||||
|
||||
.list-container {
|
||||
@@ -246,11 +249,9 @@ export class HuiNotificationDrawer extends KeyboardShortcutMixin(LitElement) {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
.notifications {
|
||||
padding-right: var(--safe-area-inset-right, 0px);
|
||||
padding-inline-end: var(--safe-area-inset-right, 0px);
|
||||
}
|
||||
:host([narrow]) .notifications {
|
||||
padding-right: var(--safe-area-inset-right, 0px);
|
||||
padding-inline-end: var(--safe-area-inset-right, 0px);
|
||||
}
|
||||
|
||||
.notification {
|
||||
|
||||
@@ -109,11 +109,11 @@ export const rowStyles = css`
|
||||
text-box-edge: cap alphabetic;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
opacity 180ms ease-out,
|
||||
transform 180ms ease-out,
|
||||
width 180ms ease-out,
|
||||
margin-inline-end 180ms ease-out,
|
||||
border-width 180ms ease-out;
|
||||
opacity var(--ha-animation-duration-fast) ease-out,
|
||||
transform var(--ha-animation-duration-fast) ease-out,
|
||||
width var(--ha-animation-duration-fast) ease-out,
|
||||
margin-inline-end var(--ha-animation-duration-fast) ease-out,
|
||||
border-width var(--ha-animation-duration-fast) ease-out;
|
||||
}
|
||||
|
||||
.trigger-index-badge.hidden {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { mdiLinkVariantOff } from "@mdi/js";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { ensureArray } from "../../../../common/array/ensure-array";
|
||||
import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-tooltip";
|
||||
import "../../../../components/ha-trigger-icon";
|
||||
import type { TriggerCondition } from "../../../../data/automation";
|
||||
import { describeTrigger } from "../../../../data/automation_i18n";
|
||||
@@ -32,6 +33,7 @@ export class HaAutomationTriggerReferences extends LitElement {
|
||||
|
||||
protected render() {
|
||||
const options = this._triggers?.options ?? [];
|
||||
const showIndices = this._triggers?.showIndices ?? false;
|
||||
const selectedIds = ensureArray(this.condition.id).filter(Boolean);
|
||||
const selectedTriggers = options.filter((option) =>
|
||||
selectedIds.includes(option.id)
|
||||
@@ -52,7 +54,28 @@ export class HaAutomationTriggerReferences extends LitElement {
|
||||
selectedTriggers.map(
|
||||
(option) => html`
|
||||
<span class="trigger-reference">
|
||||
<span class="trigger-index-badge">${option.index + 1}</span>
|
||||
<span
|
||||
id="trigger-index-badge-${option.index}"
|
||||
tabindex=${showIndices ? "0" : "-1"}
|
||||
class="trigger-index-badge ${showIndices ? "" : "hidden"}"
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.trigger_index_aria_label",
|
||||
{ number: option.index + 1 }
|
||||
)}
|
||||
aria-hidden=${showIndices ? "false" : "true"}
|
||||
>${option.index + 1}</span
|
||||
>
|
||||
${
|
||||
showIndices
|
||||
? html`<ha-tooltip for="trigger-index-badge-${option.index}"
|
||||
><p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.trigger_index_tooltip"
|
||||
)}
|
||||
</p></ha-tooltip
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
<ha-trigger-icon
|
||||
.trigger=${"trigger" in option.trigger ? option.trigger.trigger : ""}
|
||||
></ha-trigger-icon>
|
||||
@@ -132,6 +155,22 @@ export class HaAutomationTriggerReferences extends LitElement {
|
||||
line-height: 1;
|
||||
text-box-trim: both;
|
||||
text-box-edge: cap alphabetic;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
opacity var(--ha-animation-duration-fast) ease-out,
|
||||
transform var(--ha-animation-duration-fast) ease-out,
|
||||
width var(--ha-animation-duration-fast) ease-out,
|
||||
margin-inline-end var(--ha-animation-duration-fast) ease-out,
|
||||
border-width var(--ha-animation-duration-fast) ease-out;
|
||||
}
|
||||
|
||||
.trigger-index-badge.hidden {
|
||||
opacity: 0;
|
||||
transform: translateX(calc(-8px * var(--scale-direction)));
|
||||
width: 0;
|
||||
margin-inline-end: calc(var(--ha-space-2) * -1);
|
||||
border-width: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1338,14 +1338,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
const configEntries = this._domainConfigEntries(
|
||||
this.domain,
|
||||
this._extraConfigEntries || this.configEntries
|
||||
).filter(
|
||||
(entry) =>
|
||||
entry.source !== "ignore" &&
|
||||
Object.prototype.hasOwnProperty.call(
|
||||
entry.supported_subentry_types,
|
||||
flowType
|
||||
)
|
||||
);
|
||||
).filter((entry) => entry.source !== "ignore");
|
||||
|
||||
if (!configEntries.length) {
|
||||
return;
|
||||
|
||||
@@ -22,7 +22,12 @@ import type {
|
||||
MarkerLocation,
|
||||
} from "../../../components/map/ha-locations-editor";
|
||||
import { saveCoreConfig } from "../../../data/core";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
import { subscribeEntityRegistry } from "../../../data/entity/entity_registry";
|
||||
import {
|
||||
HOME_ZONE_ENTITY_ID,
|
||||
zoneColor,
|
||||
} from "../../../common/map/entity-map-colors";
|
||||
import type {
|
||||
HomeZoneMutableParams,
|
||||
Zone,
|
||||
@@ -47,6 +52,19 @@ import { configSections } from "../config-sections";
|
||||
import { showHomeZoneDetailDialog } from "./show-dialog-home-zone-detail";
|
||||
import { showZoneDetailDialog } from "./show-dialog-zone-detail";
|
||||
|
||||
interface PendingEdit {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
radius?: number;
|
||||
}
|
||||
|
||||
// How close the saved value must come to a pending one to count as saved
|
||||
const PENDING_TOLERANCE: Record<keyof PendingEdit, number> = {
|
||||
latitude: 1e-7,
|
||||
longitude: 1e-7,
|
||||
radius: 0.5,
|
||||
};
|
||||
|
||||
@customElement("ha-config-zone")
|
||||
export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -61,21 +79,36 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _stateItems?: HassEntity[];
|
||||
|
||||
// Values dragged on the map, shown until the saved data reflects them, so
|
||||
// a re-render while the save is in flight does not move the marker back.
|
||||
// A failed save drops them and the marker returns to the saved values.
|
||||
@state() private _pendingEdits: Record<string, PendingEdit> = {};
|
||||
|
||||
@state() private _canEditCore = false;
|
||||
|
||||
@query("ha-locations-editor") private _map?: HaLocationsEditor;
|
||||
|
||||
private _regEntities: string[] = [];
|
||||
|
||||
// Registry creation order decides the zone colors
|
||||
@state() private _entityReg: EntityRegistryEntry[] = [];
|
||||
|
||||
// Storage zone id (its unique id) to entity id
|
||||
@state() private _zoneEntityIds: Record<string, string> = {};
|
||||
|
||||
// Bumped when entity map colors change to recompute the memoized locations
|
||||
@state() private _colorVersion = 0;
|
||||
|
||||
private _getZones = memoizeOne(
|
||||
(storageItems: Zone[], stateItems: HassEntity[]): MarkerLocation[] => {
|
||||
(
|
||||
storageItems: Zone[],
|
||||
stateItems: HassEntity[],
|
||||
zoneEntityIds: Record<string, string>,
|
||||
pendingEdits: Record<string, PendingEdit>,
|
||||
entityReg: EntityRegistryEntry[],
|
||||
_colorVersion: number
|
||||
): MarkerLocation[] => {
|
||||
const computedStyles = getComputedStyle(this);
|
||||
const zoneRadiusColor = computedStyles.getPropertyValue("--accent-color");
|
||||
const passiveRadiusColor = computedStyles.getPropertyValue(
|
||||
"--secondary-text-color"
|
||||
);
|
||||
const homeRadiusColor =
|
||||
computedStyles.getPropertyValue("--primary-color");
|
||||
|
||||
const stateLocations: MarkerLocation[] = stateItems.map(
|
||||
(entityState) => ({
|
||||
@@ -85,21 +118,28 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
latitude: entityState.attributes.latitude,
|
||||
longitude: entityState.attributes.longitude,
|
||||
radius: entityState.attributes.radius,
|
||||
radius_color:
|
||||
entityState.entity_id === "zone.home"
|
||||
? homeRadiusColor
|
||||
: entityState.attributes.passive
|
||||
? passiveRadiusColor
|
||||
: zoneRadiusColor,
|
||||
...pendingEdits[entityState.entity_id],
|
||||
radius_color: zoneColor(
|
||||
entityState.entity_id,
|
||||
!!entityState.attributes.passive,
|
||||
entityReg,
|
||||
computedStyles
|
||||
),
|
||||
location_editable:
|
||||
entityState.entity_id === "zone.home" && this._canEditCore,
|
||||
entityState.entity_id === HOME_ZONE_ENTITY_ID && this._canEditCore,
|
||||
radius_editable:
|
||||
entityState.entity_id === "zone.home" && this._canEditCore,
|
||||
entityState.entity_id === HOME_ZONE_ENTITY_ID && this._canEditCore,
|
||||
})
|
||||
);
|
||||
const storageLocations: MarkerLocation[] = storageItems.map((zone) => ({
|
||||
...zone,
|
||||
radius_color: zone.passive ? passiveRadiusColor : zoneRadiusColor,
|
||||
...pendingEdits[zone.id],
|
||||
radius_color: zoneColor(
|
||||
zoneEntityIds[zone.id] ?? `zone.${zone.id}`,
|
||||
!!zone.passive,
|
||||
entityReg,
|
||||
computedStyles
|
||||
),
|
||||
location_editable: true,
|
||||
radius_editable: true,
|
||||
}));
|
||||
@@ -110,9 +150,18 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
public hassSubscribe(): UnsubscribeFunc[] {
|
||||
return [
|
||||
subscribeEntityRegistry(this.hass.connection!, (entities) => {
|
||||
this._entityReg = entities;
|
||||
this._regEntities = entities.map(
|
||||
(registryEntry) => registryEntry.entity_id
|
||||
);
|
||||
this._zoneEntityIds = Object.fromEntries(
|
||||
entities
|
||||
.filter((registryEntry) => registryEntry.platform === "zone")
|
||||
.map((registryEntry) => [
|
||||
registryEntry.unique_id,
|
||||
registryEntry.entity_id,
|
||||
])
|
||||
);
|
||||
this._filterStates();
|
||||
}),
|
||||
];
|
||||
@@ -265,7 +314,11 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
<ha-locations-editor
|
||||
.locations=${this._getZones(
|
||||
this._storageItems,
|
||||
this._stateItems
|
||||
this._stateItems,
|
||||
this._zoneEntityIds,
|
||||
this._pendingEdits,
|
||||
this._entityReg,
|
||||
this._colorVersion
|
||||
)}
|
||||
@location-updated=${this._locationUpdated}
|
||||
@radius-updated=${this._radiusUpdated}
|
||||
@@ -295,7 +348,8 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
protected updated() {
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
if (
|
||||
!this.route.path.startsWith("/edit/") ||
|
||||
!this._stateItems ||
|
||||
@@ -313,11 +367,99 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
super.willUpdate(changedProps);
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
if (oldHass && this._stateItems) {
|
||||
this._getStates(oldHass);
|
||||
}
|
||||
// Zone colors come from theme variables
|
||||
if (oldHass && oldHass.themes !== this.hass.themes) {
|
||||
this._colorVersion++;
|
||||
}
|
||||
this._settlePendingEdits();
|
||||
}
|
||||
|
||||
// A pending edit is done once the saved data carries its values
|
||||
private _settlePendingEdits() {
|
||||
for (const [id, pending] of Object.entries(this._pendingEdits)) {
|
||||
const saved =
|
||||
this._storageItems?.find((zone) => zone.id === id) ??
|
||||
this.hass.states[id]?.attributes;
|
||||
if (
|
||||
saved &&
|
||||
(Object.keys(pending) as (keyof PendingEdit)[]).every(
|
||||
(key) =>
|
||||
Math.abs((saved[key] as number) - pending[key]!) <
|
||||
PENDING_TOLERANCE[key]
|
||||
)
|
||||
) {
|
||||
this._dropPendingEdit(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _dropPendingEdit(id: string) {
|
||||
const { [id]: _done, ...rest } = this._pendingEdits;
|
||||
this._pendingEdits = rest;
|
||||
}
|
||||
|
||||
// Saves for one zone run in order, so each sees the entry the previous one
|
||||
// produced and a failure only drops the values its own request carried
|
||||
private _saveQueue: Record<string, Promise<void>> = {};
|
||||
|
||||
private _saveEdit(id: string, pending: PendingEdit): Promise<void> {
|
||||
this._pendingEdits = {
|
||||
...this._pendingEdits,
|
||||
[id]: { ...this._pendingEdits[id], ...pending },
|
||||
};
|
||||
const save = (this._saveQueue[id] ?? Promise.resolve())
|
||||
.then(() => this._performSave(id, pending))
|
||||
.finally(() => {
|
||||
// Only the last save in the chain removes the queue entry
|
||||
if (this._saveQueue[id] === save) {
|
||||
delete this._saveQueue[id];
|
||||
}
|
||||
});
|
||||
this._saveQueue[id] = save;
|
||||
return save;
|
||||
}
|
||||
|
||||
private async _performSave(id: string, pending: PendingEdit) {
|
||||
try {
|
||||
if (id === HOME_ZONE_ENTITY_ID) {
|
||||
await saveCoreConfig(this.hass, pending);
|
||||
return;
|
||||
}
|
||||
const entry = this._storageItems!.find((item) => item.id === id);
|
||||
if (entry) {
|
||||
await this._updateEntry(entry, pending);
|
||||
}
|
||||
} catch (err: any) {
|
||||
// The saved values are the truth again for what this request changed;
|
||||
// a later edit of other values stays pending for its own save
|
||||
this._dropPendingValues(id, pending);
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.zone.can_not_edit"),
|
||||
text: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _dropPendingValues(id: string, failed: PendingEdit) {
|
||||
const current = this._pendingEdits[id];
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
const rest = Object.fromEntries(
|
||||
Object.entries(current).filter(
|
||||
([key, value]) => failed[key as keyof PendingEdit] !== value
|
||||
)
|
||||
) as PendingEdit;
|
||||
if (Object.keys(rest).length) {
|
||||
this._pendingEdits = { ...this._pendingEdits, [id]: rest };
|
||||
} else {
|
||||
this._dropPendingEdit(id);
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchData() {
|
||||
@@ -359,37 +501,19 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
private async _locationUpdated(ev: CustomEvent) {
|
||||
if (ev.detail.id === "zone.home" && this._canEditCore) {
|
||||
await saveCoreConfig(this.hass, {
|
||||
latitude: ev.detail.location[0],
|
||||
longitude: ev.detail.location[1],
|
||||
});
|
||||
return;
|
||||
}
|
||||
const entry = this._storageItems!.find((item) => item.id === ev.detail.id);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
this._updateEntry(entry, {
|
||||
private _locationUpdated(ev: CustomEvent) {
|
||||
this._saveEdit(ev.detail.id, {
|
||||
latitude: ev.detail.location[0],
|
||||
longitude: ev.detail.location[1],
|
||||
});
|
||||
}
|
||||
|
||||
private async _radiusUpdated(ev: CustomEvent) {
|
||||
if (ev.detail.id === "zone.home" && this._canEditCore) {
|
||||
await saveCoreConfig(this.hass, {
|
||||
radius: Math.round(ev.detail.radius),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const entry = this._storageItems!.find((item) => item.id === ev.detail.id);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
this._updateEntry(entry, {
|
||||
radius: ev.detail.radius,
|
||||
private _radiusUpdated(ev: CustomEvent) {
|
||||
this._saveEdit(ev.detail.id, {
|
||||
radius:
|
||||
ev.detail.id === HOME_ZONE_ENTITY_ID
|
||||
? Math.round(ev.detail.radius)
|
||||
: ev.detail.radius,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -416,7 +540,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
|
||||
const entryId: string = (ev.currentTarget! as any).value;
|
||||
|
||||
if (this.narrow && entryId === "zone.home") {
|
||||
if (this.narrow && entryId === HOME_ZONE_ENTITY_ID) {
|
||||
this._editHomeZone(ev);
|
||||
return;
|
||||
}
|
||||
@@ -475,7 +599,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
longitude: values.longitude,
|
||||
radius: values.radius,
|
||||
});
|
||||
this._zoomZone("zone.home");
|
||||
this._zoomZone(HOME_ZONE_ENTITY_ID);
|
||||
}
|
||||
|
||||
private async _updateEntry(
|
||||
|
||||
@@ -3,14 +3,18 @@ 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 { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { getColorByIndex } from "../../../common/color/colors";
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { consume, ContextConsumer } from "@lit/context";
|
||||
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";
|
||||
@@ -28,10 +32,21 @@ import type {
|
||||
HaMapPaths,
|
||||
MapCardMarkerLabelMode,
|
||||
} from "../../../components/map/ha-map";
|
||||
import type { MapLatLng } from "../../../common/map/map-engine";
|
||||
import type { MapFitPadding, MapLatLng } from "../../../common/map/map-engine";
|
||||
import { circleBoundsPoints } from "../../../common/map/map-engine";
|
||||
import {
|
||||
entityMapColor,
|
||||
zoneColor,
|
||||
} from "../../../common/map/entity-map-colors";
|
||||
import type { HistoryStates } from "../../../data/history";
|
||||
import { subscribeHistoryStatesTimeWindow } from "../../../data/history";
|
||||
import type { Themes } from "../../../data/ws-themes";
|
||||
import { fullEntitiesContext, uiContext } from "../../../data/context";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
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 +63,15 @@ 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;
|
||||
|
||||
// Margin around the overview (--ha-space-3), in pixels
|
||||
const OVERVIEW_GAP = 12;
|
||||
|
||||
const FOCUS_PERSON_ZOOM = 19;
|
||||
const FOCUS_ZONE_MAX_ZOOM = 18;
|
||||
|
||||
interface GeoEntity {
|
||||
entity_id: string;
|
||||
label_mode?: MapCardMarkerLabelMode;
|
||||
@@ -58,10 +82,24 @@ interface GeoEntity {
|
||||
|
||||
@customElement("hui-map-card")
|
||||
class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
constructor() {
|
||||
super();
|
||||
new ContextConsumer(this, {
|
||||
context: fullEntitiesContext,
|
||||
subscribe: true,
|
||||
callback: (entries) => {
|
||||
this._entityReg = entries;
|
||||
this._mapEntities = this._getMapEntities();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public layout?: string;
|
||||
|
||||
@property({ type: Boolean }) public preview = false;
|
||||
|
||||
@state() private _stateHistory?: HistoryStates;
|
||||
|
||||
@state()
|
||||
@@ -76,14 +114,33 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
|
||||
private _filteredMapEntities: HaMapEntity[] = [];
|
||||
|
||||
private _colorDict: Record<string, string> = {};
|
||||
|
||||
private _colorIndex = 0;
|
||||
// The overview lists people the map snapshot missed (e.g. no location yet),
|
||||
// so it holds the map entities plus those, who may have no marker of their
|
||||
// own until they are located.
|
||||
private _overviewEntities: HaMapEntity[] = [];
|
||||
|
||||
@state() private _error?: { code: string; message: string };
|
||||
|
||||
// Registry creation order decides the palette colors
|
||||
@state() private _entityReg: EntityRegistryEntry[] = [];
|
||||
|
||||
// Palette colors are read from the theme when the entities are built
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
@transform<ContextType<typeof uiContext>, Themes>({
|
||||
transformer: ({ themes }) => themes,
|
||||
})
|
||||
private _themes?: Themes;
|
||||
|
||||
@state() private _clusterMarkers = true;
|
||||
|
||||
@state() private _overviewSelected?: string;
|
||||
|
||||
// Height of the overview drawer when it sits over the bottom of the map
|
||||
@state() private _overviewSize = { width: 0, height: 0 };
|
||||
|
||||
private _overviewLoaded = false;
|
||||
|
||||
private _subscribed?: Promise<(() => Promise<void>) | undefined>;
|
||||
|
||||
private _getAllEntities(): string[] {
|
||||
@@ -209,18 +266,48 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
|
||||
return html`
|
||||
<ha-card id="card" .header=${this._config.title}>
|
||||
<div id="root">
|
||||
<div
|
||||
id="root"
|
||||
class=${classMap({
|
||||
"panel-layout": this.layout === PANEL_VIEW_LAYOUT,
|
||||
rtl: computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
),
|
||||
})}
|
||||
@hass-more-info=${this._handleMapMoreInfo}
|
||||
>
|
||||
<ha-map
|
||||
style=${styleMap({
|
||||
"--overview-height": `${this._overviewSize.height}px`,
|
||||
"--overview-width": `${this._overviewSize.width}px`,
|
||||
})}
|
||||
.entities=${this._filteredMapEntities}
|
||||
.zoom=${this._config.default_zoom ?? DEFAULT_ZOOM}
|
||||
.paths=${this._getHistoryPaths(this._config, this._stateHistory)}
|
||||
.paths=${this._getHistoryPaths(
|
||||
this._config,
|
||||
this._stateHistory,
|
||||
this._entityReg,
|
||||
this._themes
|
||||
)}
|
||||
.autoFit=${this._config.auto_fit || false}
|
||||
.fitPadding=${this._overviewPadding()}
|
||||
.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 +339,18 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
tabindex="0"
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
${
|
||||
this.layout === PANEL_VIEW_LAYOUT
|
||||
? html`<hui-map-overview
|
||||
id="overview"
|
||||
.hass=${this.hass}
|
||||
.entities=${this._overviewEntities}
|
||||
.selected=${this._overviewSelected}
|
||||
@map-overview-select=${this._handleOverviewSelect}
|
||||
@map-overview-resize=${this._handleOverviewResize}
|
||||
></hui-map-overview>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
@@ -321,21 +420,117 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
) {
|
||||
this._mapEntities = this._getMapEntities();
|
||||
}
|
||||
// Private state is not in keyof this
|
||||
if ((changedProps as PropertyValues).has("_themes") && this.hasUpdated) {
|
||||
this._mapEntities = this._getMapEntities();
|
||||
}
|
||||
|
||||
// Filter entities by conditions
|
||||
if (this._config?.conditions && this._mapEntities) {
|
||||
const conditions = this._config.conditions;
|
||||
this._filteredMapEntities = this._mapEntities.filter((entity) => {
|
||||
const conditionWithEntity = conditions.map((condition) =>
|
||||
addEntityToCondition(condition, entity.entity_id)
|
||||
);
|
||||
return checkConditionsMet(conditionWithEntity, this.hass!, {});
|
||||
});
|
||||
this._filteredMapEntities = this._mapEntities.filter((entity) =>
|
||||
this._meetsConditions(entity.entity_id)
|
||||
);
|
||||
} else {
|
||||
this._filteredMapEntities = this._mapEntities;
|
||||
}
|
||||
|
||||
if (this.layout === PANEL_VIEW_LAYOUT) {
|
||||
if (!this._overviewLoaded) {
|
||||
this._overviewLoaded = true;
|
||||
void import("./map/hui-map-overview");
|
||||
}
|
||||
// Keep people and standalone trackers the snapshot missed so they appear
|
||||
// once they locate.
|
||||
const entities = this._config?.show_all
|
||||
? this._withMissingTracked(this._filteredMapEntities)
|
||||
: this._filteredMapEntities;
|
||||
this._filteredMapEntities = this._decorateOverviewEntities(
|
||||
entities,
|
||||
this._overviewSelected,
|
||||
this._overviewSelected
|
||||
? this.hass.states[this._overviewSelected]
|
||||
: undefined,
|
||||
this.preview
|
||||
);
|
||||
this._overviewEntities = this._filteredMapEntities;
|
||||
}
|
||||
}
|
||||
|
||||
private _meetsConditions(entityId: string): boolean {
|
||||
const conditions = this._config?.conditions;
|
||||
if (!conditions) {
|
||||
return true;
|
||||
}
|
||||
return checkConditionsMet(
|
||||
conditions.map((condition) => addEntityToCondition(condition, entityId)),
|
||||
this.hass!,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
// show_all freezes located entities, so a person or standalone tracker that
|
||||
// locates later is missing. Add every eligible one and let the map and
|
||||
// overview skip it until it has coordinates. Trackers owned by a person are
|
||||
// shown through that person, so they are left out here.
|
||||
private _withMissingTracked(entities: HaMapEntity[]): HaMapEntity[] {
|
||||
const hass = this.hass;
|
||||
if (!hass) {
|
||||
return entities;
|
||||
}
|
||||
const present = new Set(entities.map((entity) => entity.entity_id));
|
||||
const personSources = new Set<string>();
|
||||
Object.values(hass.states).forEach((stateObj) => {
|
||||
if (
|
||||
computeStateDomain(stateObj) === "person" &&
|
||||
stateObj.attributes.source
|
||||
) {
|
||||
personSources.add(stateObj.attributes.source);
|
||||
}
|
||||
});
|
||||
const extra: HaMapEntity[] = [];
|
||||
Object.values(hass.states).forEach((stateObj) => {
|
||||
const entityId = stateObj.entity_id;
|
||||
const domain = computeStateDomain(stateObj);
|
||||
const eligible =
|
||||
domain === "person" ||
|
||||
(domain === "device_tracker" && !personSources.has(entityId));
|
||||
if (
|
||||
eligible &&
|
||||
!present.has(entityId) &&
|
||||
!hass.entities?.[entityId]?.hidden &&
|
||||
this._meetsConditions(entityId)
|
||||
) {
|
||||
extra.push({ entity_id: entityId, color: this._getColor(entityId) });
|
||||
}
|
||||
});
|
||||
return extra.length ? [...entities, ...extra] : entities;
|
||||
}
|
||||
|
||||
// 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,
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.hasUpdated && this._configEntities?.length) {
|
||||
@@ -428,22 +623,147 @@ 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<{ width: number; height: number }>
|
||||
) {
|
||||
const { width, height } = ev.detail;
|
||||
// A collapsed overview keeps its width; zero both so it reserves no space.
|
||||
this._overviewSize =
|
||||
width && height ? { width, height } : { width: 0, height: 0 };
|
||||
// A focused fit pauses auto-fit, so refit to apply the new padding.
|
||||
if (this._overviewSelected) {
|
||||
this._focusEntity(this._overviewSelected);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
this._map?.fitBounds(
|
||||
circleBoundsPoints([latitude, longitude], radius ?? 100),
|
||||
{
|
||||
pad: 0.2,
|
||||
zoom: FOCUS_ZONE_MAX_ZOOM,
|
||||
padding: this._overviewPadding(),
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
const location = getEntityLocation(stateObj, this.hass.states);
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
const center: MapLatLng = [location.latitude, location.longitude];
|
||||
const accuracy = location.gpsAccuracy ?? 0;
|
||||
// Fit the accuracy circle so it is not mostly off-screen, capping the zoom
|
||||
if (accuracy > IMPRECISE_GPS_ACCURACY) {
|
||||
this._map?.fitBounds(circleBoundsPoints(center, accuracy), {
|
||||
pad: 0.2,
|
||||
zoom: FOCUS_PERSON_ZOOM,
|
||||
padding: this._overviewPadding(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
this._map?.fitBounds([center], {
|
||||
zoom: FOCUS_PERSON_ZOOM,
|
||||
padding: this._overviewPadding(),
|
||||
});
|
||||
}
|
||||
|
||||
// The part of the map the overview covers, so fitted markers land next to
|
||||
// it rather than under it: the bottom sheet on phones, the start side
|
||||
// otherwise (see the #overview styles). Memoized so the map only refits
|
||||
// when the drawer actually changes size.
|
||||
private _overviewPadding(): MapFitPadding | undefined {
|
||||
// The overview, and its padding, only exist in panel layout.
|
||||
if (this.layout !== PANEL_VIEW_LAYOUT) {
|
||||
return undefined;
|
||||
}
|
||||
return this._paddingFor(
|
||||
this._overviewSize.width,
|
||||
this._overviewSize.height,
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
}
|
||||
|
||||
private _paddingFor = memoizeOne(
|
||||
(
|
||||
width: number,
|
||||
height: number,
|
||||
language: string,
|
||||
translations: HomeAssistant["translationMetadata"]["translations"]
|
||||
): MapFitPadding | undefined => {
|
||||
if (!width || !height) {
|
||||
return undefined;
|
||||
}
|
||||
if (window.matchMedia("(max-width: 600px)").matches) {
|
||||
return { bottom: height + OVERVIEW_GAP };
|
||||
}
|
||||
const side = width + 2 * OVERVIEW_GAP;
|
||||
return computeRTL(language, translations)
|
||||
? { right: side }
|
||||
: { left: side };
|
||||
}
|
||||
);
|
||||
|
||||
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,
|
||||
this._entityReg,
|
||||
computedStyles
|
||||
);
|
||||
}
|
||||
return color;
|
||||
return entityMapColor(entityId, this._entityReg, computedStyles);
|
||||
}
|
||||
|
||||
private _getSourceEntities(states?: HassEntities): GeoEntity[] {
|
||||
@@ -503,7 +823,10 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
private _getHistoryPaths = memoizeOne(
|
||||
(
|
||||
config: MapCardConfig,
|
||||
history?: HistoryStates
|
||||
history: HistoryStates | undefined,
|
||||
// Trail colors follow the registry order and the theme like the markers
|
||||
_entityReg: EntityRegistryEntry[],
|
||||
_themes: Themes | undefined
|
||||
): HaMapPaths[] | undefined => {
|
||||
if (!history || !(config.hours_to_show ?? DEFAULT_HOURS_TO_SHOW)) {
|
||||
return undefined;
|
||||
@@ -595,10 +918,61 @@ 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;
|
||||
}
|
||||
|
||||
/* Keep the attribution and scale ruler clear of the drawer: beside it on
|
||||
wide layouts, above it on phones. The controls sit at physical corners. */
|
||||
#root.panel-layout ha-map {
|
||||
--ha-map-left-inset: calc(
|
||||
var(--overview-width, 0px) + 2 * var(--ha-space-3)
|
||||
);
|
||||
}
|
||||
#root.panel-layout.rtl ha-map {
|
||||
--ha-map-left-inset: 0px;
|
||||
--ha-map-right-inset: calc(
|
||||
var(--overview-width, 0px) + 2 * var(--ha-space-3)
|
||||
);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#overview {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
width: auto;
|
||||
max-height: 70%;
|
||||
}
|
||||
|
||||
#root.panel-layout ha-map,
|
||||
#root.panel-layout.rtl ha-map {
|
||||
--ha-map-left-inset: 0px;
|
||||
--ha-map-right-inset: 0px;
|
||||
--ha-map-bottom-inset: calc(
|
||||
var(--overview-height, 0px) + var(--ha-space-2)
|
||||
);
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,990 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { mdiHistory } from "@mdi/js";
|
||||
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,
|
||||
queryAll,
|
||||
state,
|
||||
} from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { zoneColor } from "../../../../common/map/entity-map-colors";
|
||||
import { contrastingZoneContent } from "../../../../common/map/zone-marker";
|
||||
import { computeDomain } from "../../../../common/entity/compute_domain";
|
||||
import { computeStateDomain } from "../../../../common/entity/compute_state_domain";
|
||||
import { computeStateName } from "../../../../common/entity/compute_state_name";
|
||||
import { getEntityLocation } from "../../../../common/entity/get_entity_location";
|
||||
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-icon-button-prev";
|
||||
import "../../../../components/ha-resizable-bottom-sheet";
|
||||
import "../../../../components/ha-md-list";
|
||||
import "../../../../components/ha-md-list-item";
|
||||
import "../../../../components/ha-relative-time";
|
||||
import "../../../../components/ha-spinner";
|
||||
import "../../../../components/ha-state-icon";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { HaMapEntity } from "../../../../components/map/ha-map";
|
||||
import "../../../logbook/ha-logbook-entry";
|
||||
import type { LogbookEntry } from "../../../../data/logbook";
|
||||
import type { ActivityEntry } from "./map-activity";
|
||||
import { personActivity, zoneActivity } from "./map-activity";
|
||||
import {
|
||||
apiContext,
|
||||
connectionContext,
|
||||
formattersContext,
|
||||
fullEntitiesContext,
|
||||
internationalizationContext,
|
||||
statesContext,
|
||||
} from "../../../../data/context";
|
||||
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
|
||||
import type { HistoryStates } from "../../../../data/history";
|
||||
import { fetchDateWS } from "../../../../data/history";
|
||||
import { computeUserInitials } from "../../../../data/user";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
HomeAssistantApi,
|
||||
HomeAssistantConnection,
|
||||
HomeAssistantFormatters,
|
||||
HomeAssistantInternationalization,
|
||||
} from "../../../../types";
|
||||
|
||||
type OverviewTab = "people" | "devices" | "zones";
|
||||
|
||||
const ACTIVITY_HOURS = 24;
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"map-overview-select": { entityId?: string };
|
||||
/** Rendered size, so the host keeps controls and focused markers clear of it */
|
||||
"map-overview-resize": { width: number; height: number };
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("hui-map-overview")
|
||||
export class HuiMapOverview extends LitElement {
|
||||
@property({ attribute: false }) public entities: HaMapEntity[] = [];
|
||||
|
||||
// Only handed to ha-logbook-entry, which takes the broad object
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selected?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states!: HassEntities;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: HomeAssistantInternationalization;
|
||||
|
||||
@state()
|
||||
@consume({ context: formattersContext, subscribe: true })
|
||||
private _formatters!: HomeAssistantFormatters;
|
||||
|
||||
@state()
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
private _connection!: HomeAssistantConnection;
|
||||
|
||||
@state()
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: HomeAssistantApi;
|
||||
|
||||
// Registry creation order decides the zone colors
|
||||
@state()
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
private _entityReg: EntityRegistryEntry[] = [];
|
||||
|
||||
@queryAll(".tablist button")
|
||||
private _tabButtons!: NodeListOf<HTMLButtonElement>;
|
||||
|
||||
@state() private _tab: OverviewTab = "people";
|
||||
|
||||
// On phones the overview is a bottom sheet; elsewhere a floating panel
|
||||
@state() private _phone = false;
|
||||
|
||||
private _phoneQuery = window.matchMedia("(max-width: 600px)");
|
||||
|
||||
// The sheet never shrinks below the strip that stays useful when collapsed
|
||||
@state() private _sheetMinHeight?: number;
|
||||
|
||||
@query(".panel.sheet") private _sheetPanel?: HTMLElement;
|
||||
|
||||
@query(".peek") private _peek?: HTMLElement;
|
||||
|
||||
@query(".detail-name") private _detailName?: HTMLElement;
|
||||
|
||||
@queryAll("ha-md-list-item")
|
||||
private _listItems!: NodeListOf<HTMLElement>;
|
||||
|
||||
private _peekObserver?: ResizeObserver;
|
||||
|
||||
private _observedPeek?: HTMLElement;
|
||||
|
||||
@state() private _activity?: ActivityEntry[];
|
||||
|
||||
@state() private _activityFailed = false;
|
||||
|
||||
// Counts activity loads so only the newest one may show its result, even
|
||||
// when the same entity is selected again while an older load is pending
|
||||
private _activityRequest = 0;
|
||||
|
||||
private _resizeObserver?: ResizeObserver;
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._phone = this._phoneQuery.matches;
|
||||
this._phoneQuery.addEventListener("change", this._handlePhoneChange);
|
||||
this._resizeObserver ??= new ResizeObserver(() => {
|
||||
// The sheet reports its own size
|
||||
if (this._phone) {
|
||||
return;
|
||||
}
|
||||
const { width, height } = this.getBoundingClientRect();
|
||||
fireEvent(this, "map-overview-resize", { width, height });
|
||||
});
|
||||
this._resizeObserver.observe(this);
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._phoneQuery.removeEventListener("change", this._handlePhoneChange);
|
||||
this._resizeObserver?.disconnect();
|
||||
this._peekObserver?.disconnect();
|
||||
this._observedPeek = undefined;
|
||||
fireEvent(this, "map-overview-resize", { width: 0, height: 0 });
|
||||
}
|
||||
|
||||
private _handlePhoneChange = () => {
|
||||
this._phone = this._phoneQuery.matches;
|
||||
};
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>): void {
|
||||
super.updated(changedProps);
|
||||
this._watchPeek();
|
||||
if (changedProps.has("selected")) {
|
||||
this._moveFocus(changedProps.get("selected"));
|
||||
}
|
||||
}
|
||||
|
||||
// Selecting an item replaces the list with the detail view and going back
|
||||
// replaces it again; move focus with the content so keyboard and screen
|
||||
// reader users keep their place instead of losing it on the removed element.
|
||||
private _moveFocus(previous: string | undefined): void {
|
||||
if (this.selected) {
|
||||
this._detailName?.focus();
|
||||
} else if (previous) {
|
||||
const item = [...this._listItems].find(
|
||||
(listItem) => listItem.dataset.entityId === previous
|
||||
);
|
||||
// The row may be on another tab; fall back to the selected tab button.
|
||||
(
|
||||
item ??
|
||||
[...this._tabButtons].find(
|
||||
(button) => button.getAttribute("aria-selected") === "true"
|
||||
)
|
||||
)?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Measures the tabs or the detail header, whichever the sheet shows, plus
|
||||
// the panel's padding around it
|
||||
private _watchPeek(): void {
|
||||
const peek = this._phone ? this._peek : undefined;
|
||||
if (peek === this._observedPeek) {
|
||||
return;
|
||||
}
|
||||
this._peekObserver?.disconnect();
|
||||
this._observedPeek = peek;
|
||||
if (!peek) {
|
||||
this._sheetMinHeight = undefined;
|
||||
// The phone sheet is gone; clear the size so the host drops its inset.
|
||||
fireEvent(this, "map-overview-resize", { width: 0, height: 0 });
|
||||
return;
|
||||
}
|
||||
this._peekObserver = new ResizeObserver(() => {
|
||||
const panel = this._sheetPanel;
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
const style = getComputedStyle(panel);
|
||||
this._sheetMinHeight =
|
||||
parseFloat(style.paddingTop) +
|
||||
peek.offsetHeight +
|
||||
parseFloat(style.paddingBottom);
|
||||
});
|
||||
this._peekObserver.observe(peek);
|
||||
}
|
||||
|
||||
private _handleSheetResized(
|
||||
ev: HASSDomEvent<HASSDomEvents["bottom-sheet-resized"]>
|
||||
) {
|
||||
fireEvent(this, "map-overview-resize", {
|
||||
width: window.innerWidth,
|
||||
height: ev.detail.height,
|
||||
});
|
||||
}
|
||||
|
||||
private _getPeople(): HassEntity[] {
|
||||
return this.entities
|
||||
.map((entity) => this._states[entity.entity_id])
|
||||
.filter(
|
||||
(stateObj): stateObj is HassEntity =>
|
||||
!!stateObj && computeStateDomain(stateObj) === "person"
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aLocated = !!getEntityLocation(a, this._states);
|
||||
const bLocated = !!getEntityLocation(b, this._states);
|
||||
if (aLocated !== bLocated) {
|
||||
return aLocated ? -1 : 1;
|
||||
}
|
||||
return computeStateName(a).localeCompare(
|
||||
computeStateName(b),
|
||||
this._i18n.locale.language
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private _getDevices(): HassEntity[] {
|
||||
return this.entities
|
||||
.map((entity) => this._states[entity.entity_id])
|
||||
.filter(
|
||||
(stateObj): stateObj is HassEntity =>
|
||||
!!stateObj &&
|
||||
computeStateDomain(stateObj) === "device_tracker" &&
|
||||
!!getEntityLocation(stateObj, this._states)
|
||||
)
|
||||
.sort((a, b) =>
|
||||
computeStateName(a).localeCompare(
|
||||
computeStateName(b),
|
||||
this._i18n.locale.language
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private _getZones(): HassEntity[] {
|
||||
return this.entities
|
||||
.map((entity) => this._states[entity.entity_id])
|
||||
.filter(
|
||||
(stateObj): stateObj is HassEntity =>
|
||||
!!stateObj &&
|
||||
computeStateDomain(stateObj) === "zone" &&
|
||||
!stateObj.attributes.passive &&
|
||||
stateObj.attributes.latitude !== undefined &&
|
||||
stateObj.attributes.longitude !== undefined
|
||||
)
|
||||
.sort((a, b) =>
|
||||
computeStateName(a).localeCompare(
|
||||
computeStateName(b),
|
||||
this._i18n.locale.language
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||
if (changedProps.size > 1 || !changedProps.has("_states")) {
|
||||
return true;
|
||||
}
|
||||
// Only the map's entities matter; ignore other state churn
|
||||
const oldStates = changedProps.get("_states") as HassEntities | undefined;
|
||||
return (
|
||||
!oldStates ||
|
||||
this.entities.some(
|
||||
(entity) =>
|
||||
oldStates[entity.entity_id] !== this._states[entity.entity_id]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("selected")) {
|
||||
this._activity = undefined;
|
||||
this._activityFailed = false;
|
||||
const request = ++this._activityRequest;
|
||||
if (this.selected) {
|
||||
this._loadActivity(this.selected, request);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The window's start; history before it only seeds the previous state
|
||||
private _activitySince(): number {
|
||||
return Date.now() - ACTIVITY_HOURS * 3600 * 1000;
|
||||
}
|
||||
|
||||
/** Resolves to null when history could not be loaded */
|
||||
private async _fetchHistory(
|
||||
entityIds: string[],
|
||||
since: number
|
||||
): Promise<HistoryStates | undefined | null> {
|
||||
try {
|
||||
return await fetchDateWS(
|
||||
{ states: this._states, callWS: this._api.callWS },
|
||||
new Date(since),
|
||||
new Date(),
|
||||
entityIds
|
||||
);
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadActivity(
|
||||
entityId: string,
|
||||
request: number
|
||||
): Promise<void> {
|
||||
if (computeDomain(entityId) === "zone") {
|
||||
await this._loadZoneActivity(entityId, request);
|
||||
} else {
|
||||
await this._loadPersonActivity(entityId, request);
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadPersonActivity(
|
||||
entityId: string,
|
||||
request: number
|
||||
): Promise<void> {
|
||||
const since = this._activitySince();
|
||||
const history = await this._fetchHistory([entityId], since);
|
||||
if (request !== this._activityRequest) {
|
||||
// A newer selection took over while loading
|
||||
return;
|
||||
}
|
||||
if (history === null) {
|
||||
this._activityFailed = true;
|
||||
this._activity = [];
|
||||
return;
|
||||
}
|
||||
this._activity = personActivity(history?.[entityId], since);
|
||||
}
|
||||
|
||||
private async _loadZoneActivity(
|
||||
entityId: string,
|
||||
request: number
|
||||
): Promise<void> {
|
||||
const zone = this._states[entityId];
|
||||
if (!zone) {
|
||||
this._activity = [];
|
||||
return;
|
||||
}
|
||||
const personIds = this._getPeople().map((person) => person.entity_id);
|
||||
const since = this._activitySince();
|
||||
const history = personIds.length
|
||||
? await this._fetchHistory(personIds, since)
|
||||
: {};
|
||||
if (request !== this._activityRequest) {
|
||||
// A newer selection took over while loading
|
||||
return;
|
||||
}
|
||||
if (history === null) {
|
||||
this._activityFailed = true;
|
||||
this._activity = [];
|
||||
return;
|
||||
}
|
||||
// A person's state is "home" for the home zone, the zone name otherwise
|
||||
const zoneState =
|
||||
entityId === "zone.home" ? "home" : computeStateName(zone);
|
||||
this._activity = zoneActivity(history, personIds, zoneState, since);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const people = this._getPeople();
|
||||
const devices = this._getDevices();
|
||||
const zones = this._getZones();
|
||||
|
||||
if (!people.length && !devices.length && !zones.length) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const selectedStateObj = this.selected
|
||||
? this._states[this.selected]
|
||||
: undefined;
|
||||
const content = selectedStateObj
|
||||
? this._renderDetail(selectedStateObj)
|
||||
: this._renderList(people, devices, zones);
|
||||
|
||||
return html`
|
||||
${
|
||||
this._phone
|
||||
? html`<ha-resizable-bottom-sheet
|
||||
persistent
|
||||
open-at-content-height
|
||||
open-max-viewport-height="45"
|
||||
.minHeight=${this._sheetMinHeight}
|
||||
@bottom-sheet-resized=${this._handleSheetResized}
|
||||
>
|
||||
<div class="panel sheet">${content}</div>
|
||||
</ha-resizable-bottom-sheet>`
|
||||
: html`<div class="panel">${content}</div>`
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderDetail(stateObj: HassEntity) {
|
||||
const isZone = computeStateDomain(stateObj) === "zone";
|
||||
const zoneCount = isZone ? Number(stateObj.state) : undefined;
|
||||
|
||||
return html`
|
||||
<div class="detail-header peek">
|
||||
<ha-icon-button-prev
|
||||
.label=${this._i18n.localize("ui.common.back")}
|
||||
@click=${this._handleBack}
|
||||
></ha-icon-button-prev>
|
||||
<div class="detail-title">
|
||||
<button
|
||||
type="button"
|
||||
class="detail-name"
|
||||
.title=${this._i18n.localize(
|
||||
"ui.panel.lovelace.cards.show_more_info"
|
||||
)}
|
||||
@click=${this._handleMoreInfo}
|
||||
>
|
||||
${computeStateName(stateObj)}
|
||||
</button>
|
||||
<span class="detail-state">
|
||||
${
|
||||
isZone
|
||||
? zoneCount === undefined || Number.isNaN(zoneCount)
|
||||
? nothing
|
||||
: this._i18n.localize(
|
||||
"ui.panel.lovelace.cards.map.overview.people_in_zone",
|
||||
{ count: zoneCount }
|
||||
)
|
||||
: html`${this._formatters.formatEntityState(stateObj)} ·
|
||||
<ha-relative-time
|
||||
.datetime=${stateObj.last_updated}
|
||||
format="short"
|
||||
></ha-relative-time>`
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list">
|
||||
<div class="activity">
|
||||
<span class="activity-icon">
|
||||
<ha-svg-icon .path=${mdiHistory}></ha-svg-icon>
|
||||
</span>
|
||||
<span class="activity-title">
|
||||
${this._i18n.localize(
|
||||
"ui.panel.lovelace.cards.map.overview.activity"
|
||||
)}
|
||||
</span>
|
||||
${
|
||||
!this._activity
|
||||
? html`<div class="activity-loading">
|
||||
<ha-spinner size="small"></ha-spinner>
|
||||
</div>`
|
||||
: !this._activity.length
|
||||
? html`<span class="activity-empty">
|
||||
${this._i18n.localize(
|
||||
this._activityFailed
|
||||
? "ui.panel.lovelace.cards.map.overview.activity_unavailable"
|
||||
: "ui.panel.lovelace.cards.map.overview.no_activity"
|
||||
)}
|
||||
</span>`
|
||||
: html`<div class="timeline">
|
||||
${this._activity.map((entry, index, all) =>
|
||||
this._renderActivityEntry(
|
||||
stateObj,
|
||||
entry,
|
||||
index === all.length - 1
|
||||
)
|
||||
)}
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// A logbook row: the person, the zone they are in now, and when. On the
|
||||
// zone tab the row belongs to the person who arrived or left.
|
||||
private _renderActivityEntry(
|
||||
stateObj: HassEntity,
|
||||
entry: ActivityEntry,
|
||||
last: boolean
|
||||
) {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
const entityId = entry.personId ?? stateObj.entity_id;
|
||||
const subject = this._states[entityId];
|
||||
const item: LogbookEntry = {
|
||||
when: entry.when.getTime() / 1000,
|
||||
name: subject ? computeStateName(subject) : entityId,
|
||||
entity_id: entityId,
|
||||
state: entry.state,
|
||||
};
|
||||
return html`
|
||||
<ha-logbook-entry
|
||||
.hass=${this.hass}
|
||||
.item=${item}
|
||||
.lastOfDay=${last}
|
||||
narrow
|
||||
no-detail
|
||||
></ha-logbook-entry>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderList(
|
||||
people: HassEntity[],
|
||||
devices: HassEntity[],
|
||||
zones: HassEntity[]
|
||||
) {
|
||||
const itemsPerTab: Partial<Record<OverviewTab, HassEntity[]>> = {
|
||||
people,
|
||||
// A devices tab only for devices with a location, or while it is selected
|
||||
...(devices.length || this._tab === "devices" ? { devices } : {}),
|
||||
zones,
|
||||
};
|
||||
const tabs = Object.keys(itemsPerTab) as OverviewTab[];
|
||||
// The selected tab stays selected even when empty; never switch away from it
|
||||
const tab = tabs.includes(this._tab) ? this._tab : "people";
|
||||
const items = itemsPerTab[tab]!;
|
||||
|
||||
return html`
|
||||
<div class="tabs peek">
|
||||
<span
|
||||
class="pill"
|
||||
style=${styleMap({
|
||||
"--tab-count": String(tabs.length),
|
||||
"--tab-index": String(tabs.indexOf(tab)),
|
||||
})}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<div class="tablist" role="tablist" @keydown=${this._handleTabKeydown}>
|
||||
${tabs.map(
|
||||
(tabId) => html`
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id="tab-${tabId}"
|
||||
aria-controls="tabpanel"
|
||||
aria-selected=${tab === tabId}
|
||||
tabindex=${tab === tabId ? 0 : -1}
|
||||
data-tab=${tabId}
|
||||
.disabled=${!itemsPerTab[tabId]!.length && tabId !== tab}
|
||||
@click=${this._handleTabClick}
|
||||
>
|
||||
${this._i18n.localize(
|
||||
`ui.panel.lovelace.cards.map.overview.${tabId}`
|
||||
)}
|
||||
</button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="list"
|
||||
id="tabpanel"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-${tab}"
|
||||
>
|
||||
${
|
||||
items.length
|
||||
? html`<ha-md-list>
|
||||
${items.map((stateObj) =>
|
||||
tab === "zones"
|
||||
? this._renderZone(stateObj)
|
||||
: this._renderEntity(stateObj)
|
||||
)}
|
||||
</ha-md-list>`
|
||||
: html`<div class="empty">
|
||||
${this._i18n.localize(
|
||||
`ui.panel.lovelace.cards.map.overview.no_${tab}`
|
||||
)}
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderEntity(stateObj: HassEntity) {
|
||||
const name = computeStateName(stateObj);
|
||||
const location = getEntityLocation(stateObj, this._states);
|
||||
const picture = stateObj.attributes.entity_picture;
|
||||
|
||||
return html`
|
||||
<ha-md-list-item
|
||||
type="button"
|
||||
data-entity-id=${stateObj.entity_id}
|
||||
class=${classMap({ "no-location": !location })}
|
||||
@click=${this._handleItemClick}
|
||||
>
|
||||
<div slot="start" class="avatar">
|
||||
${
|
||||
picture
|
||||
? html`<img
|
||||
src=${this._connection.hassUrl(picture)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>`
|
||||
: computeStateDomain(stateObj) === "person"
|
||||
? html`<span class="initials"
|
||||
>${computeUserInitials(name)}</span
|
||||
>`
|
||||
: html`<ha-state-icon .stateObj=${stateObj}></ha-state-icon>`
|
||||
}
|
||||
</div>
|
||||
<span slot="headline">${name}</span>
|
||||
<span slot="supporting-text">
|
||||
${this._formatters.formatEntityState(stateObj)} ·
|
||||
<ha-relative-time
|
||||
.datetime=${stateObj.last_updated}
|
||||
format="short"
|
||||
></ha-relative-time>
|
||||
</span>
|
||||
</ha-md-list-item>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderZone(stateObj: HassEntity) {
|
||||
const count = Number(stateObj.state);
|
||||
// The same color as the zone's marker on the map
|
||||
const color = zoneColor(
|
||||
stateObj.entity_id,
|
||||
!!stateObj.attributes.passive,
|
||||
this._entityReg,
|
||||
getComputedStyle(this)
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-md-list-item
|
||||
type="button"
|
||||
data-entity-id=${stateObj.entity_id}
|
||||
@click=${this._handleItemClick}
|
||||
>
|
||||
<div
|
||||
slot="start"
|
||||
class="avatar zone"
|
||||
style=${styleMap({
|
||||
background: color,
|
||||
color: contrastingZoneContent(color),
|
||||
})}
|
||||
>
|
||||
<ha-state-icon .stateObj=${stateObj}></ha-state-icon>
|
||||
</div>
|
||||
<span slot="headline">${computeStateName(stateObj)}</span>
|
||||
<span slot="supporting-text">
|
||||
${
|
||||
Number.isNaN(count)
|
||||
? nothing
|
||||
: this._i18n.localize(
|
||||
"ui.panel.lovelace.cards.map.overview.people_in_zone",
|
||||
{ count }
|
||||
)
|
||||
}
|
||||
</span>
|
||||
</ha-md-list-item>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleTabClick(ev: Event) {
|
||||
this._tab = (ev.currentTarget as HTMLElement).dataset.tab as OverviewTab;
|
||||
}
|
||||
|
||||
private _handleTabKeydown(ev: KeyboardEvent) {
|
||||
const step =
|
||||
ev.key === "ArrowRight" ? 1 : ev.key === "ArrowLeft" ? -1 : undefined;
|
||||
if (step === undefined) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
const buttons = [...this._tabButtons].filter((button) => !button.disabled);
|
||||
const current = buttons.indexOf(ev.target as HTMLButtonElement);
|
||||
const rtl = getComputedStyle(this).direction === "rtl";
|
||||
const next =
|
||||
buttons[
|
||||
(current + (rtl ? -step : step) + buttons.length) % buttons.length
|
||||
];
|
||||
if (next?.dataset.tab) {
|
||||
this._tab = next.dataset.tab as OverviewTab;
|
||||
next.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private _handleItemClick(ev: Event) {
|
||||
const entityId = (ev.currentTarget as HTMLElement).dataset.entityId;
|
||||
if (entityId) {
|
||||
fireEvent(this, "map-overview-select", { entityId });
|
||||
}
|
||||
}
|
||||
|
||||
private _handleBack() {
|
||||
fireEvent(this, "map-overview-select", { entityId: undefined });
|
||||
}
|
||||
|
||||
private _handleMoreInfo() {
|
||||
if (this.selected) {
|
||||
fireEvent(this, "hass-more-info", { entityId: this.selected });
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.panel {
|
||||
pointer-events: auto;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--ha-space-3);
|
||||
border-radius: var(--ha-border-radius-xl);
|
||||
background: var(--card-background-color);
|
||||
color: var(--primary-text-color);
|
||||
/* Floats over the map, so it keeps its shadow where cards lose theirs */
|
||||
box-shadow: var(--ha-box-shadow-m);
|
||||
}
|
||||
|
||||
ha-resizable-bottom-sheet {
|
||||
pointer-events: auto;
|
||||
/* The tabs sit right under the handle */
|
||||
--ha-bottom-sheet-handle-padding: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.panel.sheet {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding-top: var(--ha-space-7);
|
||||
padding-bottom: max(var(--ha-space-3), env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.tabs {
|
||||
position: relative;
|
||||
flex: none;
|
||||
padding: var(--ha-space-1);
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
background: var(--ha-color-fill-neutral-quiet-resting);
|
||||
}
|
||||
|
||||
.tablist {
|
||||
display: flex;
|
||||
gap: var(--ha-space-1);
|
||||
}
|
||||
|
||||
.tabs .pill {
|
||||
--segment-width: calc(
|
||||
(100% - (var(--tab-count) + 1) * var(--ha-space-1)) / var(--tab-count)
|
||||
);
|
||||
position: absolute;
|
||||
inset-block: var(--ha-space-1);
|
||||
inset-inline-start: calc(
|
||||
var(--ha-space-1) + var(--tab-index) *
|
||||
(var(--segment-width) + var(--ha-space-1))
|
||||
);
|
||||
width: var(--segment-width);
|
||||
border-radius: calc(var(--ha-border-radius-lg) - var(--ha-space-1));
|
||||
background: var(--card-background-color);
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
transition: inset-inline-start 200ms ease;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: var(--ha-space-2) var(--ha-space-3);
|
||||
border-radius: calc(var(--ha-border-radius-lg) - var(--ha-space-1));
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
color: var(--secondary-text-color);
|
||||
transition: color 180ms ease-in-out;
|
||||
}
|
||||
|
||||
.tabs button[aria-selected="true"] {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tabs .pill {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tabs button:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--ha-space-6) var(--ha-space-3);
|
||||
color: var(--secondary-text-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
ha-md-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
margin-top: var(--ha-space-2);
|
||||
background: none;
|
||||
--md-list-item-leading-space: var(--ha-space-2);
|
||||
--md-list-item-trailing-space: var(--ha-space-2);
|
||||
--md-list-item-one-line-container-height: 56px;
|
||||
--md-list-item-two-line-container-height: 64px;
|
||||
}
|
||||
|
||||
ha-md-list-item {
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
--md-list-item-supporting-text-size: var(--ha-font-size-s);
|
||||
--md-list-item-label-text-weight: var(--ha-font-weight-medium);
|
||||
--ha-md-list-item-gap: var(--ha-space-3);
|
||||
}
|
||||
|
||||
ha-md-list-item.no-location {
|
||||
--md-sys-color-on-surface: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
overflow: visible;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--ha-color-fill-neutral-quiet-resting);
|
||||
color: var(--secondary-text-color);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.avatar.zone {
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--accent-color);
|
||||
color: var(--text-accent-color, var(--text-primary-color));
|
||||
}
|
||||
|
||||
ha-relative-time {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-1);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
font-size: var(--ha-font-size-l);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
|
||||
.detail-name:hover,
|
||||
.detail-name:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.detail-state {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
|
||||
.activity {
|
||||
margin-top: var(--ha-space-2);
|
||||
padding: var(--ha-space-3);
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
background: var(--ha-color-fill-neutral-quiet-resting);
|
||||
/* Scrolls within the drawer's height instead of being clipped by it */
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.activity-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
background: var(--primary-color);
|
||||
color: var(--text-primary-color);
|
||||
--mdc-icon-size: 20px;
|
||||
}
|
||||
|
||||
.activity-title {
|
||||
display: block;
|
||||
margin: var(--ha-space-2) 0;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
|
||||
.activity-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--ha-space-3);
|
||||
}
|
||||
|
||||
.activity-empty {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
|
||||
.timeline {
|
||||
margin-top: var(--ha-space-2);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-map-overview": HuiMapOverview;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
EntityHistoryState,
|
||||
HistoryStates,
|
||||
} from "../../../../data/history";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../../../data/entity/entity";
|
||||
|
||||
// A person or tracker without a location; skipped so a dropout is not an event
|
||||
const NO_LOCATION_STATES: string[] = [UNAVAILABLE, UNKNOWN];
|
||||
|
||||
/** One line of the overview's activity timeline */
|
||||
export interface ActivityEntry {
|
||||
state: string;
|
||||
when: Date;
|
||||
/** Zone detail: the person that arrived at or left the zone */
|
||||
personId?: string;
|
||||
arrived?: boolean;
|
||||
}
|
||||
|
||||
export const ACTIVITY_MAX_ENTRIES = 20;
|
||||
|
||||
/**
|
||||
* A person's state changes inside the window, newest first. History starts
|
||||
* with the state at the window's start, which is not an event; no-location
|
||||
* samples are skipped so a dropout does not count as a change.
|
||||
*/
|
||||
export const personActivity = (
|
||||
history: EntityHistoryState[] | undefined,
|
||||
since: number
|
||||
): ActivityEntry[] => {
|
||||
const entries: ActivityEntry[] = [];
|
||||
let previous: string | undefined;
|
||||
for (const entry of history ?? []) {
|
||||
if (NO_LOCATION_STATES.includes(entry.s)) {
|
||||
continue;
|
||||
}
|
||||
const changed = entry.s !== previous;
|
||||
previous = entry.s;
|
||||
if (!changed || entry.lu * 1000 < since) {
|
||||
continue;
|
||||
}
|
||||
entries.push({ state: entry.s, when: new Date(entry.lu * 1000) });
|
||||
}
|
||||
return entries.reverse().slice(0, ACTIVITY_MAX_ENTRIES);
|
||||
};
|
||||
|
||||
/**
|
||||
* Arrivals at and departures from a zone by the given persons, newest first.
|
||||
* A person is in the zone while their state equals zoneState.
|
||||
*/
|
||||
export const zoneActivity = (
|
||||
history: HistoryStates | undefined,
|
||||
personIds: string[],
|
||||
zoneState: string,
|
||||
since: number
|
||||
): ActivityEntry[] => {
|
||||
const entries: ActivityEntry[] = [];
|
||||
for (const personId of personIds) {
|
||||
let wasInZone: boolean | undefined;
|
||||
for (const entry of history?.[personId] ?? []) {
|
||||
if (NO_LOCATION_STATES.includes(entry.s)) {
|
||||
continue;
|
||||
}
|
||||
const inZone = entry.s === zoneState;
|
||||
// The first sample is an event only if it is an in-window arrival.
|
||||
const isEvent = wasInZone === undefined ? inZone : inZone !== wasInZone;
|
||||
if (isEvent && entry.lu * 1000 >= since) {
|
||||
entries.push({
|
||||
state: entry.s,
|
||||
personId,
|
||||
arrived: inZone,
|
||||
when: new Date(entry.lu * 1000),
|
||||
});
|
||||
}
|
||||
wasInZone = inZone;
|
||||
}
|
||||
}
|
||||
entries.sort((a, b) => b.when.getTime() - a.when.getTime());
|
||||
return entries.slice(0, ACTIVITY_MAX_ENTRIES);
|
||||
};
|
||||
@@ -189,6 +189,10 @@ export const getMyRedirects = (): Redirects => ({
|
||||
component: "radio_frequency",
|
||||
redirect: "/config/radio-frequency",
|
||||
},
|
||||
config_serial: {
|
||||
component: "usb",
|
||||
redirect: "/config/serial",
|
||||
},
|
||||
config_ssdp: {
|
||||
component: "ssdp",
|
||||
redirect: "/config/ssdp",
|
||||
|
||||
@@ -9394,7 +9394,19 @@
|
||||
},
|
||||
"map": {
|
||||
"reset_focus": "Reset focus",
|
||||
"toggle_grouping": "Toggle grouping"
|
||||
"toggle_grouping": "Toggle grouping",
|
||||
"overview": {
|
||||
"people": "People",
|
||||
"devices": "Devices",
|
||||
"zones": "Zones",
|
||||
"no_people": "No people",
|
||||
"no_devices": "No devices with a location",
|
||||
"no_zones": "No zones",
|
||||
"activity": "Activity",
|
||||
"no_activity": "No recent activity",
|
||||
"activity_unavailable": "Activity couldn't be loaded",
|
||||
"people_in_zone": "{count, plural, =0 {No one here} one {{count} person} other {{count} people}}"
|
||||
}
|
||||
},
|
||||
"energy": {
|
||||
"loading": "Loading…",
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
|
||||
import {
|
||||
entityMapColor,
|
||||
HOME_ZONE_ENTITY_ID,
|
||||
zoneColor,
|
||||
} from "../../../src/common/map/entity-map-colors";
|
||||
|
||||
// Palette slot N reads back as "color-N", so an index is visible in the result
|
||||
const styles = {
|
||||
getPropertyValue: (name: string) => name.replace("--", ""),
|
||||
} as unknown as CSSStyleDeclaration;
|
||||
|
||||
const entry = (entityId: string, createdAt: number, id = entityId) =>
|
||||
({
|
||||
entity_id: entityId,
|
||||
created_at: createdAt,
|
||||
id,
|
||||
}) as EntityRegistryEntry;
|
||||
|
||||
describe("entity map colors", () => {
|
||||
it("colors zones, persons and trackers in one creation order", () => {
|
||||
const entries = [
|
||||
entry("zone.work", 40),
|
||||
entry("zone.school", 10),
|
||||
entry("person.anne", 20),
|
||||
entry("device_tracker.phone", 30),
|
||||
entry("light.kitchen", 25),
|
||||
];
|
||||
|
||||
expect(entityMapColor("zone.school", entries, styles)).toBe("color-1");
|
||||
// A person takes the next slot, never a zone's color
|
||||
expect(entityMapColor("person.anne", entries, styles)).toBe("color-2");
|
||||
expect(entityMapColor("device_tracker.phone", entries, styles)).toBe(
|
||||
"color-3"
|
||||
);
|
||||
expect(entityMapColor("zone.work", entries, styles)).toBe("color-4");
|
||||
});
|
||||
|
||||
it("breaks creation ties by registry id", () => {
|
||||
const entries = [entry("zone.b", 10, "b"), entry("zone.a", 10, "a")];
|
||||
|
||||
expect(entityMapColor("zone.a", entries, styles)).toBe("color-1");
|
||||
expect(entityMapColor("zone.b", entries, styles)).toBe("color-2");
|
||||
});
|
||||
|
||||
it("keeps the home zone out of the palette", () => {
|
||||
const entries = [entry(HOME_ZONE_ENTITY_ID, 10), entry("zone.work", 20)];
|
||||
|
||||
expect(entityMapColor("zone.work", entries, styles)).toBe("color-1");
|
||||
expect(zoneColor(HOME_ZONE_ENTITY_ID, false, entries, styles)).toBe(
|
||||
"primary-color"
|
||||
);
|
||||
});
|
||||
|
||||
it("mutes passive zones", () => {
|
||||
const entries = [entry("zone.quiet", 10)];
|
||||
|
||||
expect(zoneColor("zone.quiet", true, entries, styles)).toBe(
|
||||
"secondary-text-color"
|
||||
);
|
||||
expect(zoneColor("zone.quiet", false, entries, styles)).toBe("color-1");
|
||||
});
|
||||
|
||||
it("gives entities outside the registry a stable color", () => {
|
||||
const entries = [entry("zone.work", 10)];
|
||||
|
||||
const color = entityMapColor("zone.yaml_zone", entries, styles);
|
||||
expect(entityMapColor("zone.yaml_zone", entries, styles)).toBe(color);
|
||||
expect(entityMapColor("zone.other_yaml_zone", entries, styles)).not.toBe(
|
||||
color
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -604,6 +604,27 @@ describe("MapLibreMapEngine", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fitting", () => {
|
||||
it("keeps overlays clear of the fitted bounds", async () => {
|
||||
const { engine, map, ready } = await createEngine();
|
||||
await ready;
|
||||
|
||||
engine.fitBounds([[52, 4]], { maxZoom: 15, padding: { bottom: 200 } });
|
||||
expect(map.fitBounds).toHaveBeenCalledOnce();
|
||||
const [bounds, options] = map.fitBounds.mock.calls[0];
|
||||
// A single point centers on itself at the requested zoom
|
||||
expect(bounds[0]).toEqual([4, 52]);
|
||||
expect(bounds[1]).toEqual([4, 52]);
|
||||
expect(options.maxZoom).toBe(14);
|
||||
expect(options.padding).toEqual({
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 200,
|
||||
left: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("markers", () => {
|
||||
it("hands a removed element back without MapLibre's positioning", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
@@ -1030,10 +1051,7 @@ describe("MapLibreMapEngine", () => {
|
||||
expect(handle.radius).toBeCloseTo(110, 5);
|
||||
|
||||
element.dispatchEvent(new KeyboardEvent("keyup", { key: "ArrowUp" }));
|
||||
// The host echoes the old radius once before its save returns
|
||||
handle.update([52, 4], 100);
|
||||
expect(handle.radius).toBeCloseTo(110, 5);
|
||||
// A later identical update is a real change
|
||||
// Once committed, the host is the truth again
|
||||
handle.update([52, 4], 100);
|
||||
expect(handle.radius).toBe(100);
|
||||
});
|
||||
@@ -1100,25 +1118,24 @@ describe("MapLibreMapEngine", () => {
|
||||
expect(element.getAttribute("aria-valuenow")).toBe("150000");
|
||||
});
|
||||
|
||||
it("ignores one update echoing the values from before a drag", async () => {
|
||||
it("ignores host updates only while a drag is in progress", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
await ready;
|
||||
const { handle, center } = addCircle(engine);
|
||||
|
||||
center.lngLat = [4.01, 52];
|
||||
center.fire("dragstart");
|
||||
// Nothing moves while a drag is in progress
|
||||
// The user's hand wins while dragging
|
||||
handle.update([53, 5], 500);
|
||||
expect(handle.center).toEqual([52, 4]);
|
||||
center.fire("drag");
|
||||
center.fire("dragend");
|
||||
|
||||
// The host saves and echoes the old values once; that is not a move back
|
||||
handle.update([52, 4], 100);
|
||||
expect(handle.center).toEqual([52, 4.01]);
|
||||
// A later identical update is a real change
|
||||
|
||||
// Afterwards the host is the truth, even when it moves the circle back
|
||||
handle.update([52, 4], 100);
|
||||
expect(handle.center).toEqual([52, 4]);
|
||||
expect(handle.radius).toBe(100);
|
||||
});
|
||||
|
||||
it("activates the center by click, Enter and Space, but not after a drag", async () => {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { EntityHistoryState } from "../../../../../src/data/history";
|
||||
import {
|
||||
ACTIVITY_MAX_ENTRIES,
|
||||
personActivity,
|
||||
zoneActivity,
|
||||
} from "../../../../../src/panels/lovelace/cards/map/map-activity";
|
||||
|
||||
// The window starts at t=1000s; samples are (state, seconds)
|
||||
const SINCE = 1000 * 1000;
|
||||
const sample = (s: string, seconds: number): EntityHistoryState =>
|
||||
({ s, lu: seconds, a: {} }) as EntityHistoryState;
|
||||
|
||||
describe("personActivity", () => {
|
||||
it("lists state changes inside the window, newest first", () => {
|
||||
const entries = personActivity(
|
||||
[sample("home", 900), sample("not_home", 1100), sample("work", 1200)],
|
||||
SINCE
|
||||
);
|
||||
expect(entries.map((e) => e.state)).toEqual(["work", "not_home"]);
|
||||
expect(entries[0].when.getTime()).toBe(1200 * 1000);
|
||||
});
|
||||
|
||||
it("counts the first sample only when it lies inside the window", () => {
|
||||
// Before the window it is the state at the window's start, not an event
|
||||
expect(personActivity([sample("home", 900)], SINCE)).toEqual([]);
|
||||
expect(personActivity([sample("home", 1100)], SINCE)).toEqual([
|
||||
{ state: "home", when: new Date(1100 * 1000) },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores repeated states and unavailable or unknown dropouts", () => {
|
||||
const entries = personActivity(
|
||||
[
|
||||
sample("home", 900),
|
||||
sample("home", 1100),
|
||||
sample("unavailable", 1150),
|
||||
sample("home", 1160),
|
||||
sample("unknown", 1170),
|
||||
sample("home", 1180),
|
||||
sample("not_home", 1200),
|
||||
],
|
||||
SINCE
|
||||
);
|
||||
expect(entries.map((e) => e.state)).toEqual(["not_home"]);
|
||||
});
|
||||
|
||||
it("caps the list", () => {
|
||||
const history = Array.from({ length: 30 }, (_, i) =>
|
||||
sample(`zone_${i}`, 1001 + i)
|
||||
);
|
||||
expect(personActivity(history, SINCE)).toHaveLength(ACTIVITY_MAX_ENTRIES);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zoneActivity", () => {
|
||||
it("reports arrivals and departures per person, newest first", () => {
|
||||
const entries = zoneActivity(
|
||||
{
|
||||
"person.anne": [
|
||||
sample("not_home", 900),
|
||||
sample("Work", 1100),
|
||||
sample("not_home", 1300),
|
||||
],
|
||||
"person.bob": [sample("home", 900), sample("Work", 1200)],
|
||||
},
|
||||
["person.anne", "person.bob"],
|
||||
"Work",
|
||||
SINCE
|
||||
);
|
||||
expect(
|
||||
entries.map((e) => [e.personId, e.arrived, e.when.getTime() / 1000])
|
||||
).toEqual([
|
||||
["person.anne", false, 1300],
|
||||
["person.bob", true, 1200],
|
||||
["person.anne", true, 1100],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not count moving between other zones or the window's first sample", () => {
|
||||
const entries = zoneActivity(
|
||||
{
|
||||
"person.anne": [
|
||||
sample("Work", 900),
|
||||
sample("home", 950),
|
||||
sample("Gym", 1100),
|
||||
],
|
||||
},
|
||||
["person.anne"],
|
||||
"Work",
|
||||
SINCE
|
||||
);
|
||||
// Leaving Work happened before the window; Gym is not Work
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts an in-window first sample as an arrival", () => {
|
||||
// A newly created person has no state at the window's start, so their
|
||||
// first sample inside the window is a real arrival
|
||||
const entries = zoneActivity(
|
||||
{ "person.anne": [sample("Work", 1100)] },
|
||||
["person.anne"],
|
||||
"Work",
|
||||
SINCE
|
||||
);
|
||||
expect(entries.map((e) => [e.arrived, e.when.getTime() / 1000])).toEqual([
|
||||
[true, 1100],
|
||||
]);
|
||||
});
|
||||
|
||||
it("bridges an unavailable or unknown dropout without an event", () => {
|
||||
const entries = zoneActivity(
|
||||
{
|
||||
"person.anne": [
|
||||
sample("Work", 900),
|
||||
sample("unavailable", 1100),
|
||||
sample("Work", 1150),
|
||||
sample("unknown", 1200),
|
||||
sample("Work", 1250),
|
||||
],
|
||||
},
|
||||
["person.anne"],
|
||||
"Work",
|
||||
SINCE
|
||||
);
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user