Compare commits

..
Author SHA1 Message Date
Maarten Lakerveld bfab3c5f9f Recover located-later device trackers in the show_all overview
show_all freezes the located entities at generation, so a standalone device
tracker that gains a location afterwards stayed out of the Devices tab until
reload. Recover eligible standalone trackers alongside people, skipping those
that belong to a person.
2026-09-17 15:58:36 +02:00
Maarten Lakerveld d2d9a9d75c Bubble a lone person or device in a zone
A zone group only bubbled with more than one member, so a single person or
device in a zone showed as a plain marker instead of a bubble at the zone.
Keyed (zone) groups now bubble even with one member, pinned to the zone; a
marker that is not in a zone still shows plainly.
2026-09-17 15:24:18 +02:00
Maarten Lakerveld 8c24b63fb6 Draw the cluster bubble and its tail with one shadow
The bubble carried its own box-shadow while the tail sat behind it, so the
bubble's shadow fell across the tail and showed a seam. The bubble and tail now
share one drop-shadow on the wrapper, so the tail reads as part of the bubble.
2026-09-17 15:24:11 +02:00
Maarten Lakerveld 26b573bc79 Address Copilot review on the map overview
- Keep locationless people in the map entities so a marker appears once
  they are located, not only after a rebuild
- Restore focus to the active tab when a back navigation's row is on
  another tab
- Only reserve overview padding in panel layout
2026-09-17 15:22:44 +02:00
Maarten Lakerveld eb403e3940 Keep the bottom sheet's minimum height correct after opening
minHeight is a pixel value converted to a viewport percentage and cached
once the sheet opens. Recompute it when the viewport changes (e.g. rotation)
so the cached percentage can no longer clip the persistent peek content, and
emit bottom-sheet-resized once a raised minimum has grown the sheet, so the
host updates its overlay padding instead of holding a stale value until the
next drag.
2026-09-17 15:22:44 +02:00
Maarten Lakerveld 2dd3a4ded5 Move focus with the map overview detail view
Selecting an item swapped the list for the detail view (and back) without
moving focus, so keyboard and screen-reader users lost their place on the
removed element. Focus the detail heading when it opens, and restore focus
to the originating item when returning to the list.
2026-09-17 15:22:44 +02:00
Maarten Lakerveld adb96352a9 Include people without a location in the map overview
show_all builds the map entity list from located entities only, so people
whose location is currently unknown never reached the overview's people
list (with its no-location rows) or its zone activity history. Build a
separate overview list that adds those people back, while the map markers
keep the location filter.
2026-09-17 15:22:44 +02:00
Maarten Lakerveld 4401ec1479 Let only the newest activity load show its result
Comparing the selected entity after the fetch let an older load for the
same entity pass when the user selected it, moved away, and came back,
so a stale result or failure flag could overwrite the newer one. Number
the loads and let only the most recent one commit its result.
2026-09-17 15:22:44 +02:00
Maarten Lakerveld f47a484391 Show the overview as a bottom sheet on phones
The overview had its own collapsible panel with a handle button and swipe
handling. On phones it now renders inside ha-resizable-bottom-sheet in its
persistent mode, so it resizes like the automation editor's sheet and stops
at a strip with the tabs instead of closing. The sheet reports its height
for the map's insets and fit padding; wide layouts keep the floating panel.
2026-09-17 15:22:44 +02:00
Maarten Lakerveld 79e53a84f6 Let the resizable bottom sheet stay open at a minimum height
A persistent sheet stops at a pixel minimum instead of closing when dragged
down, can open at its content height within a configurable share of the
viewport rather than at least 55%, reports its height after opening and
resizing, and lets a host shrink the grab area below the handle for content
that sits right under it. The map overview uses all of this on phones; the
automation editor keeps the previous behavior.
2026-09-17 15:22:44 +02:00
Maarten Lakerveld 393255cf1b Show the activity timeline as logbook rows
The overview rendered its own timeline items. It now uses ha-logbook-entry
in its narrow layout, so activity reads like the logbook everywhere: the
person, the zone they are in, and when. Arrivals and departures on the zone
tab are the same rows for the person who moved. The card hands hass to the
overview only for these rows.
2026-09-17 15:22:44 +02:00
Maarten Lakerveld 8d6d4ab725 Extract the activity timeline parsing and test it
The window seeding, unavailable skipping and zone transition rules moved
into pure functions with tests, so a regression in the People or Zones
timeline no longer needs manual checking.
2026-09-17 15:22:44 +02:00
Maarten Lakerveld fd85cac5c1 Keep the scale ruler and attribution beside the drawer on wide layouts
The bottom inset that lifts the map controls above the drawer only applied
on phones, so on desktop a tall drawer covered the scale ruler. ha-map gains
left and right insets for its bottom controls, and the card reserves the
drawer's width on the side it occupies.
2026-09-17 15:22:44 +02:00
Maarten Lakerveld 10eb16d353 Let the activity timeline scroll inside the drawer
On phones the drawer body is capped at 60vh and clips its overflow for the
collapse animation, so activity entries beyond that were unreachable. The
activity box now scrolls within the drawer.
2026-09-17 15:22:44 +02:00
Maarten Lakerveld edbeabc32a Keep automatic fits clear of the overview
Only a selection fit knew about the drawer; the first fit and every
auto-fit used the whole viewport, so markers could land under the drawer.
ha-map takes a fit padding that automatic fits apply, and refits when it
changes, so the map settles once the drawer reports its size.
2026-09-17 15:22:44 +02:00
Maarten Lakerveld 511fc8ca22 Take the map card and overview colors from the registry context
The card no longer runs its own color subscription, and the overview reads
the registry entries for its zone list.
2026-09-17 15:22:44 +02:00
Maarten Lakerveld 2519723a29 Report activity that could not load, and list only events inside the window
A failed history request, or an instance without the history integration,
read as no recent activity; the panel now says the activity could not be
loaded. The first history sample is the state at the window's start, not
a change inside it, so a person who has not moved for days no longer shows
that old state as recent activity: samples before the window only seed
the previous state, for zones and people alike.
2026-09-17 15:22:44 +02:00
Maarten Lakerveld de57deb50a Keep a focused marker clear of the overview
Focusing a person or zone fitted the full viewport, so the marker could
land under the overview: the start side on desktop, the bottom sheet on
phones. Fits now take viewport padding, both engines apply it per fit,
and the card passes the overview's size as padding on the side it covers,
honoring right-to-left layouts.
2026-09-17 15:22:44 +02:00
Maarten Lakerveld e595ae8984 Reuse the cards' show-more-info label in the map overview
The overview added its own copy of a label the other cards already have
translated; it uses the shared key and the duplicate is gone.
2026-09-17 15:22:44 +02:00
Maarten Lakerveld dbd369a5a0 Pause auto-fit before a deferred fit, and never select an empty tab
An explicit fit that had to wait for the engine or for a size did not
pause auto-fit until it ran, so an auto-fit queued in between could take
its place. The pause comes first now.

The overview's selected tab could be an empty, disabled one, showing an
empty list while another tab had content and leaving aria-selected on a
disabled button; the selection falls back to the first tab with items,
which the tab stop already did.
2026-09-17 15:22:44 +02:00
Maarten Lakerveld a7f5c51def Resubscribe the map card's colors when the connection changes
The card kept its entity color subscription on whichever connection it
first saw. It now remembers that connection and subscribes anew when
hass brings a different one, as ha-map already does.
2026-09-17 15:22:44 +02:00
Maarten Lakerveld e6c3e0c549 Add people, devices, and zones overview to the map card in panel view
When the map card is the single card of a panel view it shows a
floating overview with People, Devices, and Zones tabs styled as an
animated segmented control; on mobile it becomes a bottom sheet drawer
that can be collapsed by tap or swipe. People list their zone or Away
state with the last update time, devices only appear when they have a
location, and zones show their occupant count while passive zones are
hidden in this view.

Selecting a row or map marker opens a detail view with a back button,
a name that opens the more info dialog, and a 24h activity timeline:
zone changes for a person or device, arrivals and departures per person
for a zone. Selecting focuses the map (street level for people, the
radius for zones, shown only while selected), marks the selected marker
with a thicker border, shows the accuracy circle when the position is
imprecise, and clicking the map deselects. In panel layout the overview
sits at the start side, so the zoom control and map buttons move to the
other side: ha-map gains a zoom-position property for that, and honours
--ha-map-bottom-inset so the attribution and scale stay clear of the
bottom sheet.
2026-09-17 15:22:44 +02:00
c0ffeeca7 7c7549349a Add my link for serial panel under Connectivity (#54225) 2026-09-17 14:05:14 +02:00
Jan-Philipp Benecke 051f51dd26 Hide trigger index in trigger references unless triggered-by is edited (#54228) 2026-09-17 14:00:50 +02:00
Maarten Lakerveld 9a9d747899 Redesign map markers (#54025)
* Redesign map markers with zone circles and avatar cluster bubbles

Zones are drawn as colored circles containing the zone icon, using the
entity's color with the zone radius and GPS accuracy circles matching
it. Marker clusters render as a bubble of up to three rounded square
avatars with a +N chip; when all members are in the same zone and the
bubble would overlap that zone's circle, it floats above the circle,
pinned to the zone's coordinate, with a tail pointing at it. Individual
markers are rounded squares, and while history trails are shown bubble
avatars get borders in their entity color so trails can be matched.
Avatars in bubbles are clickable and open the entity, and the whole
bubble zooms in on its members.

ha-map gains per-entity hide_radius, hide_accuracy, and selected
options for hosts that manage a selection; ha-entity-marker exposes its
background and border width via CSS variables and a selected state with
a thicker border. The OSMF vector tiles carry some places twice (node
and boundary centroid); towns and larger now require a population,
keeping the node, and smaller places get a collision padding.

* Frame person and device markers, ring the selected one

Markers get a card-colored frame with a soft shadow instead of a thin
border in the entity color, and the selected marker gets a primary-color
ring outside that frame. With history trails shown the frame still takes
the entity color, so a marker can be matched to its trail, and the
markers redraw when trails appear or disappear.

* Drop the editor's drag classes

The engine sets the move cursor on the elements it actually drags, so the
zone marker and the named icon no longer carry a class for it, and the
editor's element cache no longer varies with editability.

* Keep dragged values on the zone page until they are saved

The engine used to guess at the host's save state: after a drag it
skipped one update that matched the pre-drag values, taking it for a
re-render with data the save had not yet replaced. That guess is wrong
when a save fails, since the old values are then the truth and the
marker stayed at an unsaved position. The engine now applies every host
update except while a drag is in progress.

The zone page, which knows when it is saving, keeps the dragged values
as pending edits and shows them until the saved data carries them; a
failed save drops them and the marker returns.

* Subscribe to entity colors only for maps that draw entities

Every map with a connection subscribed to the entity registry for the
creation-order colors, including editor maps that never draw an entity.
Onboarding's location picker is one, and its mocked server rejects the
registry request, failing the onboarding end-to-end tests. The
subscription now starts when a map first draws entities.

* Address code review on the marker redesign

The selected ring's color came from an inline outline-color that beat the
rule's outline shorthand by precedence, which reads as if the shorthand
won; the inline style now sets a variable the rule consumes, with the
primary color as fallback. A map whose connection context changes
resubscribes to the entity colors instead of staying bound to the old
connection. The zone page's willUpdate called the wrong lifecycle hook on
its base class.

* Tell the user when a dragged zone edit fails to save

A failed save dropped the pending edit and rethrew, which no caller
awaited, leaving an unhandled rejection and a marker that silently moved
back. The page now shows the error in a dialog instead.

* Drop the numeric cluster badge styles, follow a replaced connection for colors

Clusters are avatar bubbles now, so the styles for the old count badge
had nothing left to style. The shared entity color subscription is a
module-level singleton keyed to whichever connection subscribed first; a
map arriving with a different connection now takes the stream over
instead of reading colors from the old one.

* Fall back to the palette color for entities without one, recolor zones on theme change

An entity object without a color reached the accuracy circle's paint
with an undefined color; the map's own palette color fills in. The zone
page's markers are memoized on their data, while their colors come from
theme variables, so a theme change now bumps the color version and
recolors them at once.

* Redraw markers on a theme change, and test the entity color assignment

Marker and trail colors are resolved from theme variables when drawn, but
a theme change only swapped the base style, so the old palette stayed on
the markers until something else redrew them. Any change to the themes
now redraws entities and trails.

The color assignment gets unit tests: creation order per domain, ties
broken by registry id, the home zone kept out of the palette, muted
passive zones, a stable fallback for entities outside the registry, and
one shared registry stream released with the last subscriber.

* Count entity color subscriptions from the listener set

The listener set deduplicated a reused callback while a separate counter
counted every call, so the two could disagree and the shared registry
stream could leak or be released early. Each subscription is now its own
listener, the count is the set's size, and unsubscribing twice is a no-op.

* Release the entity color subscription when a map stops drawing entities

A map whose entities were unset kept its registry subscription, holding
the shared stream open for nothing. It is released along with the
markers, and the three release sites share one helper.

* Address code review on the marker redesign, third round

The zone page's updated() override never called its base, so once
willUpdate stopped misusing super.updated the subscribe mixin had no hook
left to run hassSubscribe when hass arrives after connection; the override
calls super.updated now.

Saves for one zone run in order, each looking the entry up when it starts,
so a second response can replace what the first produced, and a failure
only drops the pending values its own request carried. A passive zone that
is not drawn no longer anchors a cluster bubble. A map whose entity list
empties releases the color subscription, not only one whose list is unset.

* Address code review: drop settled zone save queues

The per-zone save chain kept its last resolved promise for the life of the panel.

* Remove label dedupe

The doubled place labels are a tile issue, not part of the marker redesign,
and the population filter did nothing on the OSMF tiles, which fill in a
default population for every place. It moves to its own branch.

* Address code review: keep the label mode in cluster bubbles

Bubble avatars only carried the label text and picture, so an entity set to
the icon label mode showed initials once clustered and the attribute mode lost
its unit. The icon flag and unit now travel with the cluster data.

* Address code review: color map card entities from the shared palette

The card kept its own color dictionary, so an entity could have a different
color on every map. It now uses the palette that ha-map and the zone editor
already share, ordered by registry creation; a configured color still wins.

* Address code review: take entity colors from the registry context

The color module kept its own shared registry subscription with listener
counting and connection takeover. The lazy fullEntitiesContext already does
that for the whole app, so the module is now pure: it turns the registry
entries into a palette index, memoized per registry update. Maps ask for
the context only once they draw entities, so an editor's map, as in
onboarding, never requests the registry; the map card and the zone page
consume it directly.

* Address code review: one palette sequence for zones, persons and trackers

Per-domain sequences all started at slot 0, so the first person and the
first zone shared a color and a person could match the zone it was in.

* Address code review: recolor history trails when the registry changes

The trail colors are memoized with the registry entries as a key, like the
marker colors, so trails recolor with their markers when the registry
arrives or changes.

* Address code review: follow theme changes and keep bubble focus

The map card rebuilds its entity colors and trail colors when the theme
changes, ha-map redraws editable circles on a theme change, and the
locations editor rebuilds its cached marker elements. A focused cluster
bubble keeps focus on the bubble that replaces it, and a caller naming a
marker by aria-labelledby keeps that name.

* Address code review: recolor the map card from the themes context

The card consumed the theme through hass, whose update gate only lets a
dark mode flip through, so switching between two light or two dark themes
kept the old palette. It now consumes the themes from the UI context, like
ha-map, and rebuilds its entity and trail colors whenever they change.

* Address code review: keep the editor alert clear of the zoom control in RTL

The zoom control sits at the physical top-left in either text direction,
but the alert used logical insets, so in RTL its clearance moved to the
right and it covered the control.

* Fix the bubble tail covering the middle member

The tail is a rotated square whose upper half sits under the bubble. It was
painted on top, so in an open bubble it covered the bottom of the middle
member's frame and selected ring as a white triangle. Both tails now sit
behind their bubble.

* Narrow the locations editor to the themes context

ha-locations-editor consumed the whole uiContext but only reads themes, so
it rerendered on unrelated panel, sidebar, and UI-state changes. Transform
the context to Themes, following the pattern in hui-map-card.

* Keep focus when a cluster collapses to a single marker

Regrouping restored focus only to a replacement cluster icon, but a group
that becomes a single member is shown as a standalone marker with no icon,
so focus fell back to the document. Focus the member element in that case.
2026-09-17 12:26:11 +01:00
Bruno Pantaleão Gonçalves 5b99462fdf Fix notification drawer right safe area inset (#54230)
Apply the right safe-area inset to the full-width notification drawer

The drawer spans the full viewport whenever narrow is true (viewport
under 870px), but the right inset padding on the header and list was
only applied below a 450px media query. Between those widths the close
button ended up under the right inset. Reflect narrow as an attribute
and key the padding off it so the inset follows the same rule as the
drawer width.
2026-09-17 14:22:39 +03:00
Jan-Philipp BeneckeandCopilot Autofix powered by AI 14ef770c44 Overlay trace graph step navigation on the graph (#54227)
* Overlay trace graph step navigation on the graph

* Fix height problem

* Increase margin

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-17 14:20:00 +03:00
25 changed files with 2702 additions and 259 deletions
@@ -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,
],
});
}
+43 -47
View File
@@ -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();
}
}
}
+73
View File
@@ -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);
};
+9
View File
@@ -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 {
+5 -1
View File
@@ -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");
}
+67
View File
@@ -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;
}
`;
+108 -11
View File
@@ -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 };
}
}
+14 -3
View File
@@ -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%;
+57 -12
View File
@@ -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
View File
@@ -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);
}
`;
}
+17 -3
View File
@@ -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
View File
@@ -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 {
+5 -5
View File
@@ -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;
+171 -47
View File
@@ -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(
+400 -26
View File
@@ -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);
};
+4
View File
@@ -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",
+13 -1
View File
@@ -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…",
+74
View File
@@ -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
);
});
});
+27 -10
View File
@@ -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([]);
});
});